I like where we are now
This commit is contained in:
@@ -11,7 +11,9 @@
|
|||||||
"Bash(JAVA_HOME=/nix/store/3xf2cjni3xqn10xnsa0cyvjmnd8sqg7b-openjdk-17.0.18+8 jar tf:*)",
|
"Bash(JAVA_HOME=/nix/store/3xf2cjni3xqn10xnsa0cyvjmnd8sqg7b-openjdk-17.0.18+8 jar tf:*)",
|
||||||
"Bash(unzip:*)",
|
"Bash(unzip:*)",
|
||||||
"WebFetch(domain:raw.githubusercontent.com)",
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
"Bash(export TERM=dumb:*)"
|
"Bash(export TERM=dumb:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(jar tf:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
|
|||||||
- **Package**: `com.example.fluffytrix`
|
- **Package**: `com.example.fluffytrix`
|
||||||
- **Build system**: Gradle with Kotlin DSL, version catalog at `gradle/libs.versions.toml`
|
- **Build system**: Gradle with Kotlin DSL, version catalog at `gradle/libs.versions.toml`
|
||||||
- **AGP**: 9.0.1
|
- **AGP**: 9.0.1
|
||||||
- **Java compatibility**: 11 (configured in `app/build.gradle.kts`)
|
- **Java compatibility**: 17 (configured in `app/build.gradle.kts`)
|
||||||
- Single module (`:app`)
|
- Single module (`:app`)
|
||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
@@ -36,5 +36,5 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K
|
|||||||
- Discord-like layout: space sidebar → channel list → message area → member list
|
- Discord-like layout: space sidebar → channel list → message area → member list
|
||||||
- Static channel ordering (never auto-sort by recency)
|
- Static channel ordering (never auto-sort by recency)
|
||||||
- Material You (Material 3 dynamic colors) theming
|
- Material You (Material 3 dynamic colors) theming
|
||||||
- Trixnity SDK for Matrix protocol
|
- Matrix Rust SDK (`org.matrix.rustcomponents:sdk-android`) for Matrix protocol
|
||||||
- Jetpack Compose for UI
|
- Jetpack Compose for UI
|
||||||
|
|||||||
@@ -22,9 +22,6 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
// Compose is extremely slow in unoptimized debug builds.
|
|
||||||
// R8 with isDebuggable keeps debuggability but strips the massive
|
|
||||||
// material-icons-extended library and optimizes Compose codegen.
|
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
isDebuggable = true
|
isDebuggable = true
|
||||||
@@ -85,20 +82,11 @@ dependencies {
|
|||||||
implementation(libs.coroutines.core)
|
implementation(libs.coroutines.core)
|
||||||
implementation(libs.coroutines.android)
|
implementation(libs.coroutines.android)
|
||||||
|
|
||||||
// Trixnity
|
// Matrix Rust SDK
|
||||||
implementation(libs.trixnity.client) {
|
implementation(libs.matrix.rust.sdk)
|
||||||
exclude(group = "net.folivo", module = "trixnity-olm-jvm")
|
|
||||||
}
|
|
||||||
implementation(libs.trixnity.clientserverapi.client) {
|
|
||||||
exclude(group = "net.folivo", module = "trixnity-olm-jvm")
|
|
||||||
}
|
|
||||||
implementation(libs.trixnity.olm)
|
|
||||||
implementation(libs.trixnity.client.repository.room) {
|
|
||||||
exclude(group = "net.folivo", module = "trixnity-olm-jvm")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ktor engine for Trixnity
|
// Kotlinx Serialization
|
||||||
implementation(libs.ktor.client.okhttp)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
// Coil (image loading)
|
// Coil (image loading)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
|
|||||||
24
app/proguard-rules.pro
vendored
24
app/proguard-rules.pro
vendored
@@ -17,13 +17,17 @@
|
|||||||
kotlinx.serialization.KSerializer serializer(...);
|
kotlinx.serialization.KSerializer serializer(...);
|
||||||
}
|
}
|
||||||
|
|
||||||
# Trixnity — keep all SDK classes (uses reflection/serialization heavily)
|
# Matrix Rust SDK (native JNI bindings)
|
||||||
-keep class net.folivo.trixnity.** { *; }
|
-keep class org.matrix.rustcomponents.sdk.** { *; }
|
||||||
-dontwarn net.folivo.trixnity.**
|
-keep class uniffi.** { *; }
|
||||||
|
|
||||||
# Ktor
|
# JNA (required by Matrix Rust SDK)
|
||||||
-keep class io.ktor.** { *; }
|
-keep class com.sun.jna.** { *; }
|
||||||
-dontwarn io.ktor.**
|
-keep class * implements com.sun.jna.** { *; }
|
||||||
|
-dontwarn java.awt.Component
|
||||||
|
-dontwarn java.awt.GraphicsEnvironment
|
||||||
|
-dontwarn java.awt.HeadlessException
|
||||||
|
-dontwarn java.awt.Window
|
||||||
|
|
||||||
# OkHttp
|
# OkHttp
|
||||||
-dontwarn okhttp3.**
|
-dontwarn okhttp3.**
|
||||||
@@ -35,11 +39,6 @@
|
|||||||
# Coil
|
# Coil
|
||||||
-keep class coil3.** { *; }
|
-keep class coil3.** { *; }
|
||||||
|
|
||||||
# JNA (used by Trixnity OLM bindings)
|
|
||||||
-keep class com.sun.jna.** { *; }
|
|
||||||
-keep class * implements com.sun.jna.** { *; }
|
|
||||||
-dontwarn com.sun.jna.**
|
|
||||||
|
|
||||||
# Media3 / ExoPlayer
|
# Media3 / ExoPlayer
|
||||||
-keep class androidx.media3.** { *; }
|
-keep class androidx.media3.** { *; }
|
||||||
-dontwarn androidx.media3.**
|
-dontwarn androidx.media3.**
|
||||||
@@ -48,6 +47,3 @@
|
|||||||
-keep class com.mikepenz.markdown.** { *; }
|
-keep class com.mikepenz.markdown.** { *; }
|
||||||
-keep class dev.snipme.highlights.** { *; }
|
-keep class dev.snipme.highlights.** { *; }
|
||||||
-dontwarn dev.snipme.highlights.**
|
-dontwarn dev.snipme.highlights.**
|
||||||
|
|
||||||
# Olm native library
|
|
||||||
-keep class org.matrix.olm.** { *; }
|
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ class FluffytrixApplication : Application(), SingletonImageLoader.Factory {
|
|||||||
val okHttpClient = OkHttpClient.Builder()
|
val okHttpClient = OkHttpClient.Builder()
|
||||||
.addInterceptor { chain ->
|
.addInterceptor { chain ->
|
||||||
val request = chain.request()
|
val request = chain.request()
|
||||||
val token = authRepository.getAccessToken()
|
val client = authRepository.getClient()
|
||||||
|
val token = try { client?.session()?.accessToken } catch (_: Exception) { null }
|
||||||
if (token != null && request.url.encodedPath.contains("/_matrix/")) {
|
if (token != null && request.url.encodedPath.contains("/_matrix/")) {
|
||||||
chain.proceed(
|
chain.proceed(
|
||||||
request.newBuilder()
|
request.newBuilder()
|
||||||
|
|||||||
@@ -18,9 +18,12 @@ class PreferencesManager(private val context: Context) {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token")
|
private val KEY_ACCESS_TOKEN = stringPreferencesKey("access_token")
|
||||||
|
private val KEY_REFRESH_TOKEN = stringPreferencesKey("refresh_token")
|
||||||
private val KEY_USER_ID = stringPreferencesKey("user_id")
|
private val KEY_USER_ID = stringPreferencesKey("user_id")
|
||||||
private val KEY_DEVICE_ID = stringPreferencesKey("device_id")
|
private val KEY_DEVICE_ID = stringPreferencesKey("device_id")
|
||||||
private val KEY_HOMESERVER_URL = stringPreferencesKey("homeserver_url")
|
private val KEY_HOMESERVER_URL = stringPreferencesKey("homeserver_url")
|
||||||
|
private val KEY_OIDC_DATA = stringPreferencesKey("oidc_data")
|
||||||
|
private val KEY_SLIDING_SYNC_VERSION = stringPreferencesKey("sliding_sync_version")
|
||||||
private val KEY_USERNAME = stringPreferencesKey("username")
|
private val KEY_USERNAME = stringPreferencesKey("username")
|
||||||
private val KEY_PASSWORD = stringPreferencesKey("password")
|
private val KEY_PASSWORD = stringPreferencesKey("password")
|
||||||
private val KEY_IS_LOGGED_IN = booleanPreferencesKey("is_logged_in")
|
private val KEY_IS_LOGGED_IN = booleanPreferencesKey("is_logged_in")
|
||||||
@@ -52,18 +55,31 @@ class PreferencesManager(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun saveSession(
|
suspend fun saveSession(
|
||||||
|
accessToken: String,
|
||||||
|
refreshToken: String?,
|
||||||
userId: String,
|
userId: String,
|
||||||
deviceId: String,
|
deviceId: String,
|
||||||
homeserverUrl: String,
|
homeserverUrl: String,
|
||||||
|
oidcData: String?,
|
||||||
|
slidingSyncVersion: String,
|
||||||
) {
|
) {
|
||||||
context.dataStore.edit { prefs ->
|
context.dataStore.edit { prefs ->
|
||||||
|
prefs[KEY_ACCESS_TOKEN] = accessToken
|
||||||
|
if (refreshToken != null) prefs[KEY_REFRESH_TOKEN] = refreshToken
|
||||||
prefs[KEY_USER_ID] = userId
|
prefs[KEY_USER_ID] = userId
|
||||||
prefs[KEY_DEVICE_ID] = deviceId
|
prefs[KEY_DEVICE_ID] = deviceId
|
||||||
prefs[KEY_HOMESERVER_URL] = homeserverUrl
|
prefs[KEY_HOMESERVER_URL] = homeserverUrl
|
||||||
|
if (oidcData != null) prefs[KEY_OIDC_DATA] = oidcData
|
||||||
|
prefs[KEY_SLIDING_SYNC_VERSION] = slidingSyncVersion
|
||||||
prefs[KEY_IS_LOGGED_IN] = true
|
prefs[KEY_IS_LOGGED_IN] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val refreshToken: Flow<String?> = context.dataStore.data.map { prefs -> prefs[KEY_REFRESH_TOKEN] }
|
||||||
|
val deviceId: Flow<String?> = context.dataStore.data.map { prefs -> prefs[KEY_DEVICE_ID] }
|
||||||
|
val oidcData: Flow<String?> = context.dataStore.data.map { prefs -> prefs[KEY_OIDC_DATA] }
|
||||||
|
val slidingSyncVersion: Flow<String?> = context.dataStore.data.map { prefs -> prefs[KEY_SLIDING_SYNC_VERSION] }
|
||||||
|
|
||||||
val channelOrder: Flow<Map<String, List<String>>> = context.dataStore.data.map { prefs ->
|
val channelOrder: Flow<Map<String, List<String>>> = context.dataStore.data.map { prefs ->
|
||||||
val raw = prefs[KEY_CHANNEL_ORDER] ?: return@map emptyMap()
|
val raw = prefs[KEY_CHANNEL_ORDER] ?: return@map emptyMap()
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,64 +1,91 @@
|
|||||||
package com.example.fluffytrix.data.repository
|
package com.example.fluffytrix.data.repository
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.room.Room
|
|
||||||
import com.example.fluffytrix.data.local.PreferencesManager
|
import com.example.fluffytrix.data.local.PreferencesManager
|
||||||
import io.ktor.http.Url
|
|
||||||
import kotlinx.coroutines.flow.firstOrNull
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
import net.folivo.trixnity.client.MatrixClient
|
import org.matrix.rustcomponents.sdk.Client
|
||||||
import net.folivo.trixnity.client.fromStore
|
import org.matrix.rustcomponents.sdk.ClientBuilder
|
||||||
import net.folivo.trixnity.client.loginWithPassword
|
import org.matrix.rustcomponents.sdk.Session
|
||||||
import net.folivo.trixnity.client.media.createInMemoryMediaStoreModule
|
import org.matrix.rustcomponents.sdk.SlidingSyncVersion
|
||||||
import net.folivo.trixnity.client.store.AccountStore
|
import org.matrix.rustcomponents.sdk.SlidingSyncVersionBuilder
|
||||||
import net.folivo.trixnity.client.store.repository.room.TrixnityRoomDatabase
|
import org.matrix.rustcomponents.sdk.SyncService
|
||||||
import net.folivo.trixnity.client.store.repository.room.createRoomRepositoriesModule
|
import java.io.File
|
||||||
import net.folivo.trixnity.clientserverapi.model.authentication.IdentifierType
|
|
||||||
|
|
||||||
class AuthRepository(
|
class AuthRepository(
|
||||||
private val preferencesManager: PreferencesManager,
|
private val preferencesManager: PreferencesManager,
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
) {
|
) {
|
||||||
private var matrixClient: MatrixClient? = null
|
private var matrixClient: Client? = null
|
||||||
private var accessToken: String? = null
|
private var syncService: SyncService? = null
|
||||||
|
|
||||||
private fun createDatabaseBuilder() =
|
suspend fun getOrStartSync(): SyncService? {
|
||||||
Room.databaseBuilder(context, TrixnityRoomDatabase::class.java, "trixnity")
|
syncService?.let { return it }
|
||||||
.fallbackToDestructiveMigration(false)
|
val client = matrixClient ?: run {
|
||||||
|
android.util.Log.e("AuthRepo", "getOrStartSync: no client!")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
android.util.Log.d("AuthRepo", "Building sync service...")
|
||||||
|
val ss = client.syncService().finish()
|
||||||
|
android.util.Log.d("AuthRepo", "Starting sync service...")
|
||||||
|
ss.start()
|
||||||
|
android.util.Log.d("AuthRepo", "Sync service started")
|
||||||
|
syncService = ss
|
||||||
|
ss
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("AuthRepo", "Failed to start sync", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSyncService(): SyncService? = syncService
|
||||||
|
|
||||||
|
private fun sessionDataPath(): String {
|
||||||
|
val dir = File(context.filesDir, "matrix_session_data")
|
||||||
|
dir.mkdirs()
|
||||||
|
return dir.absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sessionCachePath(): String {
|
||||||
|
val dir = File(context.cacheDir, "matrix_session_cache")
|
||||||
|
dir.mkdirs()
|
||||||
|
return dir.absolutePath
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun login(
|
suspend fun login(
|
||||||
homeserverUrl: String,
|
homeserverUrl: String,
|
||||||
username: String,
|
username: String,
|
||||||
password: String,
|
password: String,
|
||||||
): Result<MatrixClient> {
|
): Result<Client> {
|
||||||
val normalizedUrl = homeserverUrl.let {
|
val normalizedUrl = homeserverUrl.let {
|
||||||
if (!it.startsWith("http")) "https://$it" else it
|
if (!it.startsWith("http")) "https://$it" else it
|
||||||
}
|
}
|
||||||
val baseUrl = Url(normalizedUrl)
|
|
||||||
|
|
||||||
val result = MatrixClient.loginWithPassword(
|
return try {
|
||||||
baseUrl = baseUrl,
|
val client = ClientBuilder()
|
||||||
identifier = IdentifierType.User(username),
|
.sessionPaths(sessionDataPath(), sessionCachePath())
|
||||||
password = password,
|
.serverNameOrHomeserverUrl(normalizedUrl)
|
||||||
initialDeviceDisplayName = "Fluffytrix Android",
|
.slidingSyncVersionBuilder(SlidingSyncVersionBuilder.DISCOVER_NATIVE)
|
||||||
repositoriesModule = createRoomRepositoriesModule(createDatabaseBuilder()),
|
.build()
|
||||||
mediaStoreModule = createInMemoryMediaStoreModule(),
|
|
||||||
)
|
client.login(username, password, "Fluffytrix Android", null)
|
||||||
|
|
||||||
result.onSuccess { client ->
|
|
||||||
matrixClient = client
|
matrixClient = client
|
||||||
try {
|
val session = client.session()
|
||||||
val accountStore = client.di.get<AccountStore>()
|
|
||||||
accessToken = accountStore.getAccount()?.accessToken
|
|
||||||
} catch (_: Exception) { }
|
|
||||||
preferencesManager.saveSession(
|
preferencesManager.saveSession(
|
||||||
userId = client.userId.full,
|
accessToken = session.accessToken,
|
||||||
deviceId = client.deviceId,
|
refreshToken = session.refreshToken,
|
||||||
homeserverUrl = homeserverUrl,
|
userId = session.userId,
|
||||||
|
deviceId = session.deviceId,
|
||||||
|
homeserverUrl = session.homeserverUrl,
|
||||||
|
oidcData = session.oidcData,
|
||||||
|
slidingSyncVersion = session.slidingSyncVersion.name,
|
||||||
)
|
)
|
||||||
client.startSync()
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
Result.success(client)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun restoreSession(): Boolean {
|
suspend fun restoreSession(): Boolean {
|
||||||
@@ -67,40 +94,58 @@ class AuthRepository(
|
|||||||
val isLoggedIn = preferencesManager.isLoggedIn.firstOrNull() ?: false
|
val isLoggedIn = preferencesManager.isLoggedIn.firstOrNull() ?: false
|
||||||
if (!isLoggedIn) return false
|
if (!isLoggedIn) return false
|
||||||
|
|
||||||
|
val accessToken = preferencesManager.accessToken.firstOrNull() ?: return false
|
||||||
|
val userId = preferencesManager.userId.firstOrNull() ?: return false
|
||||||
|
val deviceId = preferencesManager.deviceId.firstOrNull() ?: return false
|
||||||
|
val homeserverUrl = preferencesManager.homeserverUrl.firstOrNull() ?: return false
|
||||||
|
val refreshToken = preferencesManager.refreshToken.firstOrNull()
|
||||||
|
val oidcData = preferencesManager.oidcData.firstOrNull()
|
||||||
|
val slidingSyncVersionStr = preferencesManager.slidingSyncVersion.firstOrNull() ?: "NATIVE"
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val client = MatrixClient.fromStore(
|
val client = ClientBuilder()
|
||||||
repositoriesModule = createRoomRepositoriesModule(createDatabaseBuilder()),
|
.sessionPaths(sessionDataPath(), sessionCachePath())
|
||||||
mediaStoreModule = createInMemoryMediaStoreModule(),
|
.homeserverUrl(homeserverUrl)
|
||||||
).getOrNull()
|
.slidingSyncVersionBuilder(SlidingSyncVersionBuilder.DISCOVER_NATIVE)
|
||||||
|
.build()
|
||||||
|
|
||||||
if (client != null) {
|
val slidingSyncVersion = try {
|
||||||
|
SlidingSyncVersion.valueOf(slidingSyncVersionStr)
|
||||||
|
} catch (_: Exception) { SlidingSyncVersion.NATIVE }
|
||||||
|
|
||||||
|
client.restoreSession(Session(
|
||||||
|
accessToken = accessToken,
|
||||||
|
refreshToken = refreshToken,
|
||||||
|
userId = userId,
|
||||||
|
deviceId = deviceId,
|
||||||
|
homeserverUrl = homeserverUrl,
|
||||||
|
oidcData = oidcData,
|
||||||
|
slidingSyncVersion = slidingSyncVersion,
|
||||||
|
))
|
||||||
|
|
||||||
|
android.util.Log.d("AuthRepo", "restoreSession: success, userId=${client.userId()}")
|
||||||
matrixClient = client
|
matrixClient = client
|
||||||
try {
|
|
||||||
val accountStore = client.di.get<AccountStore>()
|
|
||||||
accessToken = accountStore.getAccount()?.accessToken
|
|
||||||
} catch (_: Exception) { }
|
|
||||||
client.startSync()
|
|
||||||
true
|
true
|
||||||
} else {
|
} catch (e: Exception) {
|
||||||
// Store was empty or corrupt — clear saved state
|
android.util.Log.e("AuthRepo", "restoreSession failed", e)
|
||||||
preferencesManager.clearSession()
|
|
||||||
false
|
|
||||||
}
|
|
||||||
} catch (_: Exception) {
|
|
||||||
preferencesManager.clearSession()
|
preferencesManager.clearSession()
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getClient(): MatrixClient? = matrixClient
|
fun getClient(): Client? = matrixClient
|
||||||
fun getAccessToken(): String? = accessToken
|
|
||||||
fun getBaseUrl(): String? = matrixClient?.baseUrl?.toString()?.trimEnd('/')
|
|
||||||
|
|
||||||
suspend fun logout() {
|
suspend fun logout() {
|
||||||
|
try {
|
||||||
|
syncService?.stop()
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
syncService = null
|
||||||
|
try {
|
||||||
matrixClient?.logout()
|
matrixClient?.logout()
|
||||||
matrixClient?.close()
|
} catch (_: Exception) { }
|
||||||
matrixClient = null
|
matrixClient = null
|
||||||
accessToken = null
|
|
||||||
preferencesManager.clearSession()
|
preferencesManager.clearSession()
|
||||||
|
File(sessionDataPath()).deleteRecursively()
|
||||||
|
File(sessionCachePath()).deleteRecursively()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class LoginViewModel(
|
|||||||
password = password.value,
|
password = password.value,
|
||||||
)
|
)
|
||||||
_authState.value = result.fold(
|
_authState.value = result.fold(
|
||||||
onSuccess = { AuthState.Success(it.userId.full) },
|
onSuccess = { AuthState.Success(it.userId()) },
|
||||||
onFailure = { AuthState.Error(it.message ?: "Login failed") },
|
onFailure = { AuthState.Error(it.message ?: "Login failed") },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ fun MainScreen(
|
|||||||
val members by viewModel.members.collectAsState()
|
val members by viewModel.members.collectAsState()
|
||||||
val channelName by viewModel.channelName.collectAsState()
|
val channelName by viewModel.channelName.collectAsState()
|
||||||
val isReorderMode by viewModel.isReorderMode.collectAsState()
|
val isReorderMode by viewModel.isReorderMode.collectAsState()
|
||||||
|
val homeUnreadStatus by viewModel.homeUnreadStatus.collectAsState()
|
||||||
|
|
||||||
Scaffold { padding ->
|
Scaffold { padding ->
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
@@ -44,7 +45,9 @@ fun MainScreen(
|
|||||||
SpaceList(
|
SpaceList(
|
||||||
spaces = spaces,
|
spaces = spaces,
|
||||||
selectedSpace = selectedSpace,
|
selectedSpace = selectedSpace,
|
||||||
|
homeUnreadStatus = homeUnreadStatus,
|
||||||
onSpaceClick = { viewModel.selectSpace(it) },
|
onSpaceClick = { viewModel.selectSpace(it) },
|
||||||
|
onHomeClick = { viewModel.selectHome() },
|
||||||
onToggleChannelList = { viewModel.toggleChannelList() },
|
onToggleChannelList = { viewModel.toggleChannelList() },
|
||||||
contentPadding = padding,
|
contentPadding = padding,
|
||||||
)
|
)
|
||||||
@@ -56,6 +59,7 @@ fun MainScreen(
|
|||||||
onToggleMemberList = { viewModel.toggleMemberList() },
|
onToggleMemberList = { viewModel.toggleMemberList() },
|
||||||
onSendMessage = { viewModel.sendMessage(it) },
|
onSendMessage = { viewModel.sendMessage(it) },
|
||||||
onSendFile = { viewModel.sendFile(it) },
|
onSendFile = { viewModel.sendFile(it) },
|
||||||
|
onLoadMore = { viewModel.loadMoreMessages() },
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
contentPadding = padding,
|
contentPadding = padding,
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -52,14 +52,13 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import com.example.fluffytrix.ui.screens.main.ChannelItem
|
import com.example.fluffytrix.ui.screens.main.ChannelItem
|
||||||
import com.example.fluffytrix.ui.screens.main.UnreadStatus
|
import com.example.fluffytrix.ui.screens.main.UnreadStatus
|
||||||
import net.folivo.trixnity.core.model.RoomId
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChannelList(
|
fun ChannelList(
|
||||||
channels: List<ChannelItem>,
|
channels: List<ChannelItem>,
|
||||||
selectedChannel: RoomId?,
|
selectedChannel: String?,
|
||||||
onChannelClick: (RoomId) -> Unit,
|
onChannelClick: (String) -> Unit,
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
isReorderMode: Boolean = false,
|
isReorderMode: Boolean = false,
|
||||||
@@ -136,7 +135,7 @@ fun ChannelList(
|
|||||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(channels, key = { _, ch -> ch.id.full }) { index, channel ->
|
itemsIndexed(channels, key = { _, ch -> ch.id }) { index, channel ->
|
||||||
val isSelected = channel.id == selectedChannel
|
val isSelected = channel.id == selectedChannel
|
||||||
val isDragging = draggingIndex == index
|
val isDragging = draggingIndex == index
|
||||||
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp, label = "elevation")
|
val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp, label = "elevation")
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ fun MemberList(
|
|||||||
contentPadding = PaddingValues(horizontal = 8.dp),
|
contentPadding = PaddingValues(horizontal = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
) {
|
) {
|
||||||
items(members, key = { it.userId.full }) { member ->
|
items(members, key = { it.userId }) { member ->
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ import kotlinx.coroutines.launch
|
|||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.compose.ui.window.Dialog
|
import androidx.compose.ui.window.Dialog
|
||||||
import androidx.compose.ui.window.DialogProperties
|
import androidx.compose.ui.window.DialogProperties
|
||||||
@@ -79,7 +78,6 @@ import org.koin.compose.koinInject
|
|||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.example.fluffytrix.ui.screens.main.MessageContent
|
import com.example.fluffytrix.ui.screens.main.MessageContent
|
||||||
import com.example.fluffytrix.ui.screens.main.MessageItem
|
import com.example.fluffytrix.ui.screens.main.MessageItem
|
||||||
import net.folivo.trixnity.core.model.RoomId
|
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -114,12 +112,13 @@ private fun formatTimestamp(timestamp: Long): String {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MessageTimeline(
|
fun MessageTimeline(
|
||||||
selectedChannel: RoomId?,
|
selectedChannel: String?,
|
||||||
channelName: String?,
|
channelName: String?,
|
||||||
messages: List<MessageItem>,
|
messages: List<MessageItem>,
|
||||||
onToggleMemberList: () -> Unit,
|
onToggleMemberList: () -> Unit,
|
||||||
onSendMessage: (String) -> Unit,
|
onSendMessage: (String) -> Unit,
|
||||||
onSendFile: (Uri) -> Unit,
|
onSendFile: (Uri) -> Unit,
|
||||||
|
onLoadMore: () -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
contentPadding: PaddingValues = PaddingValues(),
|
contentPadding: PaddingValues = PaddingValues(),
|
||||||
) {
|
) {
|
||||||
@@ -151,7 +150,7 @@ fun MessageTimeline(
|
|||||||
.padding(top = contentPadding.calculateTopPadding()),
|
.padding(top = contentPadding.calculateTopPadding()),
|
||||||
) {
|
) {
|
||||||
if (selectedChannel != null) {
|
if (selectedChannel != null) {
|
||||||
TopBar(channelName ?: selectedChannel.full, onToggleMemberList)
|
TopBar(channelName ?: selectedChannel, onToggleMemberList)
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
|
HorizontalDivider(color = MaterialTheme.colorScheme.surfaceVariant)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,6 +174,17 @@ fun MessageTimeline(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load more when scrolled near top (high index in reversed layout)
|
||||||
|
val shouldLoadMore by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||||
|
lastVisible >= messages.size - 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(shouldLoadMore, messages.size) {
|
||||||
|
if (shouldLoadMore && messages.isNotEmpty()) onLoadMore()
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-scroll when near bottom and new messages arrive
|
// Auto-scroll when near bottom and new messages arrive
|
||||||
LaunchedEffect(messages.size) {
|
LaunchedEffect(messages.size) {
|
||||||
if (listState.firstVisibleItemIndex <= 2) {
|
if (listState.firstVisibleItemIndex <= 2) {
|
||||||
@@ -360,7 +370,7 @@ private fun GifContent(content: MessageContent.Gif) {
|
|||||||
content.width.toFloat() / content.height.toFloat() else 16f / 9f
|
content.width.toFloat() / content.height.toFloat() else 16f / 9f
|
||||||
|
|
||||||
val exoPlayer = remember(content.url) {
|
val exoPlayer = remember(content.url) {
|
||||||
val token = authRepository.getAccessToken()
|
val token = try { authRepository.getClient()?.session()?.accessToken } catch (_: Exception) { null }
|
||||||
val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
|
val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
|
setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
|
||||||
@@ -490,7 +500,7 @@ private fun FullscreenVideoPlayer(url: String, onDismiss: () -> Unit) {
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val authRepository: AuthRepository = koinInject()
|
val authRepository: AuthRepository = koinInject()
|
||||||
val exoPlayer = remember {
|
val exoPlayer = remember {
|
||||||
val token = authRepository.getAccessToken()
|
val token = try { authRepository.getClient()?.session()?.accessToken } catch (_: Exception) { null }
|
||||||
val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
|
val dataSourceFactory = DefaultHttpDataSource.Factory().apply {
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
|
setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token"))
|
||||||
|
|||||||
@@ -35,13 +35,14 @@ import androidx.compose.ui.unit.sp
|
|||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.example.fluffytrix.ui.screens.main.SpaceItem
|
import com.example.fluffytrix.ui.screens.main.SpaceItem
|
||||||
import com.example.fluffytrix.ui.screens.main.UnreadStatus
|
import com.example.fluffytrix.ui.screens.main.UnreadStatus
|
||||||
import net.folivo.trixnity.core.model.RoomId
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SpaceList(
|
fun SpaceList(
|
||||||
spaces: List<SpaceItem>,
|
spaces: List<SpaceItem>,
|
||||||
selectedSpace: RoomId?,
|
selectedSpace: String?,
|
||||||
onSpaceClick: (RoomId) -> Unit,
|
homeUnreadStatus: UnreadStatus = UnreadStatus.NONE,
|
||||||
|
onSpaceClick: (String) -> Unit,
|
||||||
|
onHomeClick: () -> Unit,
|
||||||
onToggleChannelList: () -> Unit,
|
onToggleChannelList: () -> Unit,
|
||||||
contentPadding: PaddingValues,
|
contentPadding: PaddingValues,
|
||||||
) {
|
) {
|
||||||
@@ -68,6 +69,7 @@ fun SpaceList(
|
|||||||
|
|
||||||
// Home button
|
// Home button
|
||||||
item {
|
item {
|
||||||
|
Box(modifier = Modifier.size(48.dp)) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(48.dp)
|
.size(48.dp)
|
||||||
@@ -76,7 +78,7 @@ fun SpaceList(
|
|||||||
if (selectedSpace == null) MaterialTheme.colorScheme.primary
|
if (selectedSpace == null) MaterialTheme.colorScheme.primary
|
||||||
else MaterialTheme.colorScheme.surface
|
else MaterialTheme.colorScheme.surface
|
||||||
)
|
)
|
||||||
.clickable { /* home/all rooms */ },
|
.clickable { onHomeClick() },
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -86,9 +88,22 @@ fun SpaceList(
|
|||||||
else MaterialTheme.colorScheme.onSurface,
|
else MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (homeUnreadStatus != UnreadStatus.NONE) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.align(Alignment.TopEnd)
|
||||||
|
.background(
|
||||||
|
if (homeUnreadStatus == UnreadStatus.MENTIONED) androidx.compose.ui.graphics.Color.Red
|
||||||
|
else androidx.compose.ui.graphics.Color.Gray,
|
||||||
|
CircleShape,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items(spaces, key = { it.id.full }) { space ->
|
items(spaces, key = { it.id }) { space ->
|
||||||
val isSelected = space.id == selectedSpace
|
val isSelected = space.id == selectedSpace
|
||||||
Box(modifier = Modifier.size(48.dp)) {
|
Box(modifier = Modifier.size(48.dp)) {
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@@ -5,14 +5,12 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import com.example.fluffytrix.data.repository.AuthRepository
|
import com.example.fluffytrix.data.repository.AuthRepository
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import net.folivo.trixnity.client.verification
|
import org.matrix.rustcomponents.sdk.SessionVerificationController
|
||||||
import net.folivo.trixnity.client.verification.ActiveDeviceVerification
|
import org.matrix.rustcomponents.sdk.SessionVerificationControllerDelegate
|
||||||
import net.folivo.trixnity.client.verification.ActiveSasVerificationState
|
import org.matrix.rustcomponents.sdk.SessionVerificationData
|
||||||
import net.folivo.trixnity.client.verification.ActiveVerificationState
|
import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails
|
||||||
import net.folivo.trixnity.client.verification.SelfVerificationMethod
|
import org.matrix.rustcomponents.sdk.VerificationState
|
||||||
import net.folivo.trixnity.client.verification.VerificationService.SelfVerificationMethods
|
|
||||||
|
|
||||||
sealed class VerificationUiState {
|
sealed class VerificationUiState {
|
||||||
data object Loading : VerificationUiState()
|
data object Loading : VerificationUiState()
|
||||||
@@ -41,158 +39,126 @@ class VerificationViewModel(
|
|||||||
private val _uiState = MutableStateFlow<VerificationUiState>(VerificationUiState.Loading)
|
private val _uiState = MutableStateFlow<VerificationUiState>(VerificationUiState.Loading)
|
||||||
val uiState: StateFlow<VerificationUiState> = _uiState
|
val uiState: StateFlow<VerificationUiState> = _uiState
|
||||||
|
|
||||||
private var selfVerificationMethods: SelfVerificationMethods? = null
|
private var verificationController: SessionVerificationController? = null
|
||||||
private var activeDeviceVerification: ActiveDeviceVerification? = null
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadVerificationMethods()
|
checkVerificationStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadVerificationMethods() {
|
private fun checkVerificationStatus() {
|
||||||
val client = authRepository.getClient() ?: run {
|
val client = authRepository.getClient() ?: run {
|
||||||
_uiState.value = VerificationUiState.Error("Not logged in")
|
_uiState.value = VerificationUiState.Error("Not logged in")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
client.verification.getSelfVerificationMethods().collectLatest { methods ->
|
try {
|
||||||
selfVerificationMethods = methods
|
// Sync must be running before encryption/verification APIs work
|
||||||
when (methods) {
|
authRepository.getOrStartSync()
|
||||||
is SelfVerificationMethods.PreconditionsNotMet ->
|
// Give sync a moment to initialize encryption state
|
||||||
_uiState.value = VerificationUiState.Loading
|
kotlinx.coroutines.delay(2000)
|
||||||
|
|
||||||
is SelfVerificationMethods.NoCrossSigningEnabled ->
|
val verState = try {
|
||||||
_uiState.value = VerificationUiState.NoCrossSigning
|
client.encryption().verificationState()
|
||||||
|
} catch (_: Exception) { VerificationState.UNKNOWN }
|
||||||
|
|
||||||
is SelfVerificationMethods.AlreadyCrossSigned ->
|
if (verState == VerificationState.VERIFIED) {
|
||||||
_uiState.value = VerificationUiState.AlreadyVerified
|
_uiState.value = VerificationUiState.AlreadyVerified
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
is SelfVerificationMethods.CrossSigningEnabled -> {
|
val controller = try {
|
||||||
if (methods.methods.isEmpty()) {
|
client.getSessionVerificationController()
|
||||||
_uiState.value = VerificationUiState.NoCrossSigning
|
} catch (_: Exception) { null }
|
||||||
} else {
|
verificationController = controller
|
||||||
_uiState.value = VerificationUiState.MethodSelection(
|
|
||||||
hasDeviceVerification = methods.methods.any {
|
controller?.setDelegate(object : SessionVerificationControllerDelegate {
|
||||||
it is SelfVerificationMethod.CrossSignedDeviceVerification
|
override fun didReceiveVerificationRequest(details: SessionVerificationRequestDetails) {
|
||||||
},
|
// Another device requested verification with us
|
||||||
hasRecoveryKey = methods.methods.any {
|
}
|
||||||
it is SelfVerificationMethod.AesHmacSha2RecoveryKey
|
|
||||||
},
|
override fun didAcceptVerificationRequest() {
|
||||||
hasPassphrase = methods.methods.any {
|
_uiState.value = VerificationUiState.WaitingForDevice
|
||||||
it is SelfVerificationMethod.AesHmacSha2RecoveryKeyWithPbkdf2Passphrase
|
}
|
||||||
|
|
||||||
|
override fun didStartSasVerification() {
|
||||||
|
// SAS verification started, waiting for emoji data
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun didReceiveVerificationData(data: SessionVerificationData) {
|
||||||
|
when (data) {
|
||||||
|
is SessionVerificationData.Emojis -> {
|
||||||
|
_uiState.value = VerificationUiState.EmojiComparison(
|
||||||
|
emojis = data.emojis.map { emoji ->
|
||||||
|
emoji.symbol().codePointAt(0) to emoji.description()
|
||||||
},
|
},
|
||||||
|
decimals = emptyList(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is SessionVerificationData.Decimals -> {
|
||||||
|
_uiState.value = VerificationUiState.EmojiComparison(
|
||||||
|
emojis = emptyList(),
|
||||||
|
decimals = data.values.map { it.toInt() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun didCancel() {
|
||||||
|
_uiState.value = VerificationUiState.Error("Verification cancelled")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun didFail() {
|
||||||
|
_uiState.value = VerificationUiState.Error("Verification failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun didFinish() {
|
||||||
|
_uiState.value = VerificationUiState.VerificationDone
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
_uiState.value = VerificationUiState.MethodSelection(
|
||||||
|
hasDeviceVerification = true,
|
||||||
|
hasRecoveryKey = true,
|
||||||
|
hasPassphrase = false,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.value = VerificationUiState.Error(e.message ?: "Failed to initialize verification")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startDeviceVerification() {
|
fun startDeviceVerification() {
|
||||||
val methods = selfVerificationMethods as? SelfVerificationMethods.CrossSigningEnabled ?: return
|
val controller = verificationController ?: return
|
||||||
val deviceMethod = methods.methods.filterIsInstance<SelfVerificationMethod.CrossSignedDeviceVerification>()
|
|
||||||
.firstOrNull() ?: return
|
|
||||||
|
|
||||||
_uiState.value = VerificationUiState.WaitingForDevice
|
_uiState.value = VerificationUiState.WaitingForDevice
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
deviceMethod.createDeviceVerification()
|
try {
|
||||||
.onSuccess { verification ->
|
controller.requestDeviceVerification()
|
||||||
activeDeviceVerification = verification
|
} catch (e: Exception) {
|
||||||
observeVerificationState(verification)
|
_uiState.value = VerificationUiState.Error(e.message ?: "Failed to start verification")
|
||||||
}
|
|
||||||
.onFailure {
|
|
||||||
_uiState.value = VerificationUiState.Error(it.message ?: "Failed to start verification")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun observeVerificationState(verification: ActiveDeviceVerification) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
verification.state.collectLatest { state ->
|
|
||||||
when (state) {
|
|
||||||
is ActiveVerificationState.TheirRequest -> {
|
|
||||||
state.ready()
|
|
||||||
}
|
|
||||||
|
|
||||||
is ActiveVerificationState.Ready -> {
|
|
||||||
state.start(net.folivo.trixnity.core.model.events.m.key.verification.VerificationMethod.Sas)
|
|
||||||
}
|
|
||||||
|
|
||||||
is ActiveVerificationState.Start -> {
|
|
||||||
val method = state.method
|
|
||||||
if (method is net.folivo.trixnity.client.verification.ActiveSasVerificationMethod) {
|
|
||||||
observeSasState(method)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is ActiveVerificationState.Done -> {
|
|
||||||
_uiState.value = VerificationUiState.VerificationDone
|
|
||||||
}
|
|
||||||
|
|
||||||
is ActiveVerificationState.Cancel -> {
|
|
||||||
_uiState.value = VerificationUiState.Error(
|
|
||||||
"Verification cancelled: ${state.content.reason}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> { /* OwnRequest, WaitForDone, etc - keep current state */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun observeSasState(method: net.folivo.trixnity.client.verification.ActiveSasVerificationMethod) {
|
|
||||||
viewModelScope.launch {
|
|
||||||
method.state.collectLatest { sasState ->
|
|
||||||
when (sasState) {
|
|
||||||
is ActiveSasVerificationState.TheirSasStart -> {
|
|
||||||
sasState.accept()
|
|
||||||
}
|
|
||||||
|
|
||||||
is ActiveSasVerificationState.ComparisonByUser -> {
|
|
||||||
_uiState.value = VerificationUiState.EmojiComparison(
|
|
||||||
emojis = sasState.emojis,
|
|
||||||
decimals = sasState.decimal,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> { /* OwnSasStart, Accept, WaitForKeys, WaitForMacs */ }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun confirmEmojiMatch() {
|
fun confirmEmojiMatch() {
|
||||||
val state = (_uiState.value as? VerificationUiState.EmojiComparison) ?: return
|
val controller = verificationController ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val verification = activeDeviceVerification ?: return@launch
|
try {
|
||||||
val verState = verification.state.value
|
controller.approveVerification()
|
||||||
if (verState is ActiveVerificationState.Start) {
|
} catch (e: Exception) {
|
||||||
val method = verState.method
|
_uiState.value = VerificationUiState.Error(e.message ?: "Failed to confirm verification")
|
||||||
if (method is net.folivo.trixnity.client.verification.ActiveSasVerificationMethod) {
|
|
||||||
val sasState = method.state.value
|
|
||||||
if (sasState is ActiveSasVerificationState.ComparisonByUser) {
|
|
||||||
sasState.match()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun rejectEmojiMatch() {
|
fun rejectEmojiMatch() {
|
||||||
|
val controller = verificationController ?: return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val verification = activeDeviceVerification ?: return@launch
|
try {
|
||||||
val verState = verification.state.value
|
controller.cancelVerification()
|
||||||
if (verState is ActiveVerificationState.Start) {
|
} catch (e: Exception) {
|
||||||
val method = verState.method
|
_uiState.value = VerificationUiState.Error(e.message ?: "Failed to cancel verification")
|
||||||
if (method is net.folivo.trixnity.client.verification.ActiveSasVerificationMethod) {
|
|
||||||
val sasState = method.state.value
|
|
||||||
if (sasState is ActiveSasVerificationState.ComparisonByUser) {
|
|
||||||
sasState.noMatch()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,43 +172,24 @@ class VerificationViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun verifyWithRecoveryKey(recoveryKey: String) {
|
fun verifyWithRecoveryKey(recoveryKey: String) {
|
||||||
val methods = selfVerificationMethods as? SelfVerificationMethods.CrossSigningEnabled ?: return
|
val client = authRepository.getClient() ?: return
|
||||||
val keyMethod = methods.methods.filterIsInstance<SelfVerificationMethod.AesHmacSha2RecoveryKey>()
|
|
||||||
.firstOrNull() ?: return
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
keyMethod.verify(recoveryKey)
|
try {
|
||||||
.onSuccess {
|
client.encryption().recover(recoveryKey)
|
||||||
_uiState.value = VerificationUiState.VerificationDone
|
_uiState.value = VerificationUiState.VerificationDone
|
||||||
}
|
} catch (e: Exception) {
|
||||||
.onFailure {
|
|
||||||
_uiState.value = VerificationUiState.Error(
|
_uiState.value = VerificationUiState.Error(
|
||||||
it.message ?: "Invalid recovery key"
|
e.message ?: "Invalid recovery key"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun verifyWithPassphrase(passphrase: String) {
|
fun verifyWithPassphrase(passphrase: String) {
|
||||||
val methods = selfVerificationMethods as? SelfVerificationMethods.CrossSigningEnabled ?: return
|
verifyWithRecoveryKey(passphrase)
|
||||||
val passphraseMethod = methods.methods
|
|
||||||
.filterIsInstance<SelfVerificationMethod.AesHmacSha2RecoveryKeyWithPbkdf2Passphrase>()
|
|
||||||
.firstOrNull() ?: return
|
|
||||||
|
|
||||||
viewModelScope.launch {
|
|
||||||
passphraseMethod.verify(passphrase)
|
|
||||||
.onSuccess {
|
|
||||||
_uiState.value = VerificationUiState.VerificationDone
|
|
||||||
}
|
|
||||||
.onFailure {
|
|
||||||
_uiState.value = VerificationUiState.Error(
|
|
||||||
it.message ?: "Invalid passphrase"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun goBack() {
|
fun goBack() {
|
||||||
loadVerificationMethods()
|
checkVerificationStatus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ lifecycleViewModel = "2.9.1"
|
|||||||
koin = "4.1.1"
|
koin = "4.1.1"
|
||||||
datastore = "1.1.7"
|
datastore = "1.1.7"
|
||||||
coroutines = "1.10.2"
|
coroutines = "1.10.2"
|
||||||
trixnity = "4.22.7"
|
matrixRustSdk = "26.02.19"
|
||||||
ktor = "3.3.0"
|
|
||||||
coil = "3.2.0"
|
coil = "3.2.0"
|
||||||
media3 = "1.6.0"
|
media3 = "1.6.0"
|
||||||
markdownRenderer = "0.37.0"
|
markdownRenderer = "0.37.0"
|
||||||
|
kotlinxSerialization = "1.8.1"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
@@ -55,14 +55,11 @@ datastore-preferences = { group = "androidx.datastore", name = "datastore-prefer
|
|||||||
coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
|
coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||||
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
|
|
||||||
# Trixnity (using -jvm variants for Android)
|
# Matrix Rust SDK
|
||||||
trixnity-client = { group = "net.folivo", name = "trixnity-client-jvm", version.ref = "trixnity" }
|
matrix-rust-sdk = { group = "org.matrix.rustcomponents", name = "sdk-android", version.ref = "matrixRustSdk" }
|
||||||
trixnity-clientserverapi-client = { group = "net.folivo", name = "trixnity-clientserverapi-client-jvm", version.ref = "trixnity" }
|
|
||||||
trixnity-olm = { group = "net.folivo", name = "trixnity-olm-android", version.ref = "trixnity" }
|
|
||||||
trixnity-client-repository-room = { group = "net.folivo", name = "trixnity-client-repository-room-jvm", version.ref = "trixnity" }
|
|
||||||
|
|
||||||
# Ktor (needed by Trixnity on Android)
|
# Kotlinx Serialization
|
||||||
ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" }
|
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" }
|
||||||
|
|
||||||
# Coil (image loading)
|
# Coil (image loading)
|
||||||
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
||||||
|
|||||||
Reference in New Issue
Block a user