diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 895b48d..26e75a4 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,9 @@ "Bash(JAVA_HOME=/nix/store/3xf2cjni3xqn10xnsa0cyvjmnd8sqg7b-openjdk-17.0.18+8 jar tf:*)", "Bash(unzip:*)", "WebFetch(domain:raw.githubusercontent.com)", - "Bash(export TERM=dumb:*)" + "Bash(export TERM=dumb:*)", + "Bash(grep:*)", + "Bash(jar tf:*)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 8093080..7b5f7df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ Fluffytrix is an Android Matrix chat client with a Discord-like UI. Built with K - **Package**: `com.example.fluffytrix` - **Build system**: Gradle with Kotlin DSL, version catalog at `gradle/libs.versions.toml` - **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`) ## 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 - Static channel ordering (never auto-sort by recency) - 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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c9dc6d4..95d1dc0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -22,9 +22,6 @@ android { buildTypes { 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 isShrinkResources = true isDebuggable = true @@ -85,20 +82,11 @@ dependencies { implementation(libs.coroutines.core) implementation(libs.coroutines.android) - // Trixnity - implementation(libs.trixnity.client) { - 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") - } + // Matrix Rust SDK + implementation(libs.matrix.rust.sdk) - // Ktor engine for Trixnity - implementation(libs.ktor.client.okhttp) + // Kotlinx Serialization + implementation(libs.kotlinx.serialization.json) // Coil (image loading) implementation(libs.coil.compose) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index ee46bb5..5d435e3 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -17,13 +17,17 @@ kotlinx.serialization.KSerializer serializer(...); } -# Trixnity — keep all SDK classes (uses reflection/serialization heavily) --keep class net.folivo.trixnity.** { *; } --dontwarn net.folivo.trixnity.** +# Matrix Rust SDK (native JNI bindings) +-keep class org.matrix.rustcomponents.sdk.** { *; } +-keep class uniffi.** { *; } -# Ktor --keep class io.ktor.** { *; } --dontwarn io.ktor.** +# JNA (required by Matrix Rust SDK) +-keep class com.sun.jna.** { *; } +-keep class * implements com.sun.jna.** { *; } +-dontwarn java.awt.Component +-dontwarn java.awt.GraphicsEnvironment +-dontwarn java.awt.HeadlessException +-dontwarn java.awt.Window # OkHttp -dontwarn okhttp3.** @@ -35,11 +39,6 @@ # Coil -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 -keep class androidx.media3.** { *; } -dontwarn androidx.media3.** @@ -48,6 +47,3 @@ -keep class com.mikepenz.markdown.** { *; } -keep class dev.snipme.highlights.** { *; } -dontwarn dev.snipme.highlights.** - -# Olm native library --keep class org.matrix.olm.** { *; } diff --git a/app/src/main/java/com/example/fluffytrix/FluffytrixApplication.kt b/app/src/main/java/com/example/fluffytrix/FluffytrixApplication.kt index fa1ddae..c014cdf 100644 --- a/app/src/main/java/com/example/fluffytrix/FluffytrixApplication.kt +++ b/app/src/main/java/com/example/fluffytrix/FluffytrixApplication.kt @@ -31,7 +31,8 @@ class FluffytrixApplication : Application(), SingletonImageLoader.Factory { val okHttpClient = OkHttpClient.Builder() .addInterceptor { chain -> 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/")) { chain.proceed( request.newBuilder() diff --git a/app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt b/app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt index c304c16..d3603ed 100644 --- a/app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt +++ b/app/src/main/java/com/example/fluffytrix/data/local/PreferencesManager.kt @@ -18,9 +18,12 @@ class PreferencesManager(private val context: Context) { companion object { 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_DEVICE_ID = stringPreferencesKey("device_id") 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_PASSWORD = stringPreferencesKey("password") private val KEY_IS_LOGGED_IN = booleanPreferencesKey("is_logged_in") @@ -52,18 +55,31 @@ class PreferencesManager(private val context: Context) { } suspend fun saveSession( + accessToken: String, + refreshToken: String?, userId: String, deviceId: String, homeserverUrl: String, + oidcData: String?, + slidingSyncVersion: String, ) { context.dataStore.edit { prefs -> + prefs[KEY_ACCESS_TOKEN] = accessToken + if (refreshToken != null) prefs[KEY_REFRESH_TOKEN] = refreshToken prefs[KEY_USER_ID] = userId prefs[KEY_DEVICE_ID] = deviceId 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 } } + val refreshToken: Flow = context.dataStore.data.map { prefs -> prefs[KEY_REFRESH_TOKEN] } + val deviceId: Flow = context.dataStore.data.map { prefs -> prefs[KEY_DEVICE_ID] } + val oidcData: Flow = context.dataStore.data.map { prefs -> prefs[KEY_OIDC_DATA] } + val slidingSyncVersion: Flow = context.dataStore.data.map { prefs -> prefs[KEY_SLIDING_SYNC_VERSION] } + val channelOrder: Flow>> = context.dataStore.data.map { prefs -> val raw = prefs[KEY_CHANNEL_ORDER] ?: return@map emptyMap() try { diff --git a/app/src/main/java/com/example/fluffytrix/data/repository/AuthRepository.kt b/app/src/main/java/com/example/fluffytrix/data/repository/AuthRepository.kt index 2a649c2..5506746 100644 --- a/app/src/main/java/com/example/fluffytrix/data/repository/AuthRepository.kt +++ b/app/src/main/java/com/example/fluffytrix/data/repository/AuthRepository.kt @@ -1,64 +1,91 @@ package com.example.fluffytrix.data.repository import android.content.Context -import androidx.room.Room import com.example.fluffytrix.data.local.PreferencesManager -import io.ktor.http.Url import kotlinx.coroutines.flow.firstOrNull -import net.folivo.trixnity.client.MatrixClient -import net.folivo.trixnity.client.fromStore -import net.folivo.trixnity.client.loginWithPassword -import net.folivo.trixnity.client.media.createInMemoryMediaStoreModule -import net.folivo.trixnity.client.store.AccountStore -import net.folivo.trixnity.client.store.repository.room.TrixnityRoomDatabase -import net.folivo.trixnity.client.store.repository.room.createRoomRepositoriesModule -import net.folivo.trixnity.clientserverapi.model.authentication.IdentifierType +import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.ClientBuilder +import org.matrix.rustcomponents.sdk.Session +import org.matrix.rustcomponents.sdk.SlidingSyncVersion +import org.matrix.rustcomponents.sdk.SlidingSyncVersionBuilder +import org.matrix.rustcomponents.sdk.SyncService +import java.io.File class AuthRepository( private val preferencesManager: PreferencesManager, private val context: Context, ) { - private var matrixClient: MatrixClient? = null - private var accessToken: String? = null + private var matrixClient: Client? = null + private var syncService: SyncService? = null - private fun createDatabaseBuilder() = - Room.databaseBuilder(context, TrixnityRoomDatabase::class.java, "trixnity") - .fallbackToDestructiveMigration(false) + suspend fun getOrStartSync(): SyncService? { + syncService?.let { return it } + 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( homeserverUrl: String, username: String, password: String, - ): Result { + ): Result { val normalizedUrl = homeserverUrl.let { if (!it.startsWith("http")) "https://$it" else it } - val baseUrl = Url(normalizedUrl) - val result = MatrixClient.loginWithPassword( - baseUrl = baseUrl, - identifier = IdentifierType.User(username), - password = password, - initialDeviceDisplayName = "Fluffytrix Android", - repositoriesModule = createRoomRepositoriesModule(createDatabaseBuilder()), - mediaStoreModule = createInMemoryMediaStoreModule(), - ) + return try { + val client = ClientBuilder() + .sessionPaths(sessionDataPath(), sessionCachePath()) + .serverNameOrHomeserverUrl(normalizedUrl) + .slidingSyncVersionBuilder(SlidingSyncVersionBuilder.DISCOVER_NATIVE) + .build() + + client.login(username, password, "Fluffytrix Android", null) - result.onSuccess { client -> matrixClient = client - try { - val accountStore = client.di.get() - accessToken = accountStore.getAccount()?.accessToken - } catch (_: Exception) { } + val session = client.session() preferencesManager.saveSession( - userId = client.userId.full, - deviceId = client.deviceId, - homeserverUrl = homeserverUrl, + accessToken = session.accessToken, + refreshToken = session.refreshToken, + 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 { @@ -67,40 +94,58 @@ class AuthRepository( val isLoggedIn = preferencesManager.isLoggedIn.firstOrNull() ?: false if (!isLoggedIn) return false - return try { - val client = MatrixClient.fromStore( - repositoriesModule = createRoomRepositoriesModule(createDatabaseBuilder()), - mediaStoreModule = createInMemoryMediaStoreModule(), - ).getOrNull() + 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" - if (client != null) { - matrixClient = client - try { - val accountStore = client.di.get() - accessToken = accountStore.getAccount()?.accessToken - } catch (_: Exception) { } - client.startSync() - true - } else { - // Store was empty or corrupt — clear saved state - preferencesManager.clearSession() - false - } - } catch (_: Exception) { + return try { + val client = ClientBuilder() + .sessionPaths(sessionDataPath(), sessionCachePath()) + .homeserverUrl(homeserverUrl) + .slidingSyncVersionBuilder(SlidingSyncVersionBuilder.DISCOVER_NATIVE) + .build() + + 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 + true + } catch (e: Exception) { + android.util.Log.e("AuthRepo", "restoreSession failed", e) preferencesManager.clearSession() false } } - fun getClient(): MatrixClient? = matrixClient - fun getAccessToken(): String? = accessToken - fun getBaseUrl(): String? = matrixClient?.baseUrl?.toString()?.trimEnd('/') + fun getClient(): Client? = matrixClient suspend fun logout() { - matrixClient?.logout() - matrixClient?.close() + try { + syncService?.stop() + } catch (_: Exception) { } + syncService = null + try { + matrixClient?.logout() + } catch (_: Exception) { } matrixClient = null - accessToken = null preferencesManager.clearSession() + File(sessionDataPath()).deleteRecursively() + File(sessionCachePath()).deleteRecursively() } } diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/login/LoginViewModel.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/login/LoginViewModel.kt index e7621a5..d499385 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/login/LoginViewModel.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/login/LoginViewModel.kt @@ -34,7 +34,7 @@ class LoginViewModel( password = password.value, ) _authState.value = result.fold( - onSuccess = { AuthState.Success(it.userId.full) }, + onSuccess = { AuthState.Success(it.userId()) }, onFailure = { AuthState.Error(it.message ?: "Login failed") }, ) } diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt index 92b2a2a..fa2ce68 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainScreen.kt @@ -36,6 +36,7 @@ fun MainScreen( val members by viewModel.members.collectAsState() val channelName by viewModel.channelName.collectAsState() val isReorderMode by viewModel.isReorderMode.collectAsState() + val homeUnreadStatus by viewModel.homeUnreadStatus.collectAsState() Scaffold { padding -> Box(modifier = Modifier.fillMaxSize()) { @@ -44,7 +45,9 @@ fun MainScreen( SpaceList( spaces = spaces, selectedSpace = selectedSpace, + homeUnreadStatus = homeUnreadStatus, onSpaceClick = { viewModel.selectSpace(it) }, + onHomeClick = { viewModel.selectHome() }, onToggleChannelList = { viewModel.toggleChannelList() }, contentPadding = padding, ) @@ -56,6 +59,7 @@ fun MainScreen( onToggleMemberList = { viewModel.toggleMemberList() }, onSendMessage = { viewModel.sendMessage(it) }, onSendFile = { viewModel.sendFile(it) }, + onLoadMore = { viewModel.loadMoreMessages() }, modifier = Modifier.weight(1f), contentPadding = padding, ) diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt index d49efd3..6c623fe 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/MainViewModel.kt @@ -6,51 +6,43 @@ import android.provider.OpenableColumns import androidx.compose.runtime.Immutable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.example.fluffytrix.data.MxcUrlHelper.mxcToDownloadUrl -import com.example.fluffytrix.data.MxcUrlHelper.mxcToThumbnailUrl +import com.example.fluffytrix.data.MxcUrlHelper import com.example.fluffytrix.data.local.PreferencesManager import com.example.fluffytrix.data.repository.AuthRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import net.folivo.trixnity.client.room -import net.folivo.trixnity.client.room.message.file -import net.folivo.trixnity.client.room.message.image -import net.folivo.trixnity.client.room.message.text -import net.folivo.trixnity.client.room.message.video -import io.ktor.http.ContentType -import kotlinx.coroutines.flow.flowOf -import net.folivo.trixnity.client.store.Room -import net.folivo.trixnity.client.store.isEncrypted -import net.folivo.trixnity.client.user -import net.folivo.trixnity.core.model.RoomId -import net.folivo.trixnity.core.model.UserId -import net.folivo.trixnity.core.model.events.m.room.CreateEventContent.RoomType -import net.folivo.trixnity.core.model.events.m.room.Membership -import net.folivo.trixnity.core.model.events.m.room.RoomMessageEventContent -import net.folivo.trixnity.core.model.events.m.space.ChildEventContent +import org.matrix.rustcomponents.sdk.EventOrTransactionId +import org.matrix.rustcomponents.sdk.Membership +import org.matrix.rustcomponents.sdk.MembershipState +import org.matrix.rustcomponents.sdk.MessageType +import org.matrix.rustcomponents.sdk.MsgLikeKind +import org.matrix.rustcomponents.sdk.ProfileDetails +import org.matrix.rustcomponents.sdk.SyncService +import org.matrix.rustcomponents.sdk.TimelineDiff +import org.matrix.rustcomponents.sdk.TimelineItemContent +import org.matrix.rustcomponents.sdk.TimelineListener +import org.matrix.rustcomponents.sdk.UploadParameters +import org.matrix.rustcomponents.sdk.UploadSource +import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown enum class UnreadStatus { NONE, UNREAD, MENTIONED } data class SpaceItem( - val id: RoomId, + val id: String, val name: String, val avatarUrl: String?, val unreadStatus: UnreadStatus = UnreadStatus.NONE, ) data class ChannelItem( - val id: RoomId, + val id: String, val name: String, val isEncrypted: Boolean, val avatarUrl: String? = null, @@ -66,7 +58,6 @@ sealed interface MessageContent { } private val urlRegex = Regex("""https?://[^\s<>"{}|\\^`\[\]]+""") -private val gifUrlRegex = Regex("""https?://(tenor\.com|giphy\.com|gfycat\.com|media\d*\.giphy\.com)/""", RegexOption.IGNORE_CASE) @Immutable data class MessageItem( @@ -79,7 +70,7 @@ data class MessageItem( ) data class MemberItem( - val userId: UserId, + val userId: String, val displayName: String, val avatarUrl: String? = null, ) @@ -96,11 +87,11 @@ class MainViewModel( private val _channels = MutableStateFlow>(emptyList()) val channels: StateFlow> = _channels - private val _selectedSpace = MutableStateFlow(null) - val selectedSpace: StateFlow = _selectedSpace + private val _selectedSpace = MutableStateFlow(null) + val selectedSpace: StateFlow = _selectedSpace - private val _selectedChannel = MutableStateFlow(null) - val selectedChannel: StateFlow = _selectedChannel + private val _selectedChannel = MutableStateFlow(null) + val selectedChannel: StateFlow = _selectedChannel private val _showChannelList = MutableStateFlow(true) val showChannelList: StateFlow = _showChannelList @@ -123,28 +114,46 @@ class MainViewModel( private val _channelOrderMap = MutableStateFlow>>(emptyMap()) private val _allChannelRooms = MutableStateFlow>(emptyList()) - private val _spaceChildren = MutableStateFlow?>(null) + private val _spaceChildren = MutableStateFlow?>(emptySet()) + private var orphanRoomsLoaded = false + private var spaceChildrenPreloaded = false + private var cachedOrphanIds: Set? = null - private val _roomUnreadStatus = MutableStateFlow>(emptyMap()) - private val _spaceChildrenMap = MutableStateFlow>>(emptyMap()) + private val _roomUnreadStatus = MutableStateFlow>(emptyMap()) + + private val _homeUnreadStatus = MutableStateFlow(UnreadStatus.NONE) + val homeUnreadStatus: StateFlow = _homeUnreadStatus + private val _spaceChildrenMap = MutableStateFlow>>(emptyMap()) // Per-room caches - private val messageCache = mutableMapOf>() - private val messageIds = mutableMapOf>() - private val memberCache = mutableMapOf>() - private val channelNameCache = mutableMapOf() + private val messageCache = mutableMapOf>() + private val messageIds = mutableMapOf>() + private val memberCache = mutableMapOf>() + private val channelNameCache = mutableMapOf() private val senderAvatarCache = mutableMapOf() private val senderNameCache = mutableMapOf() - // Room data cache — avoid re-resolving unchanged rooms - private var cachedRoomData = mapOf() - private var timelineJob: Job? = null private var membersJob: Job? = null - private var spaceChildrenJob: Job? = null + private var syncService: SyncService? = null + private var activeTimeline: org.matrix.rustcomponents.sdk.Timeline? = null + private var timelineListenerHandle: org.matrix.rustcomponents.sdk.TaskHandle? = null + private var roomPollJob: Job? = null + private var isPaginating = false + private var hitTimelineStart = false + private val baseUrl: String by lazy { + try { authRepository.getClient()?.session()?.homeserverUrl?.trimEnd('/') ?: "" } + catch (_: Exception) { "" } + } + + private fun avatarUrl(mxcUri: String?, size: Int = 64): String? = + MxcUrlHelper.mxcToThumbnailUrl(baseUrl, mxcUri, size) init { - loadRooms() + viewModelScope.launch { + syncService = authRepository.getOrStartSync() + loadRooms() + } observeSelectedChannel() observeSpaceFiltering() viewModelScope.launch { @@ -152,129 +161,128 @@ class MainViewModel( } } - @OptIn(kotlinx.coroutines.FlowPreview::class) private fun loadRooms() { val client = authRepository.getClient() ?: return - val baseUrl = authRepository.getBaseUrl() ?: return + roomPollJob?.cancel() + roomPollJob = viewModelScope.launch { + // Poll until rooms appear, then keep polling for updates + while (true) { + try { + val rooms = client.rooms() + if (rooms.isNotEmpty()) { + android.util.Log.d("MainVM", "Got ${rooms.size} rooms") + processRooms(rooms) + } + } catch (e: Exception) { + android.util.Log.e("MainVM", "Error polling rooms", e) + } + kotlinx.coroutines.delay(3000) + } + } + } - viewModelScope.launch { - client.room.getAll() - .debounce(1000) - .collect { roomMap -> - withContext(Dispatchers.Default) { - // Only resolve rooms whose keys changed - val currentKeys = roomMap.keys - val newRoomIds = currentKeys - cachedRoomData.keys - val removedRoomIds = cachedRoomData.keys - currentKeys + private suspend fun processRooms(rooms: List) { + withContext(Dispatchers.Default) { + val joinedRooms = rooms.filter { + try { it.membership() == Membership.JOINED } catch (_: Exception) { false } + } - // Resolve only new/changed rooms - val newlyResolved = if (newRoomIds.isNotEmpty()) { - coroutineScope { - newRoomIds.mapNotNull { roomId -> - roomMap[roomId]?.let { flow -> - async { roomId to flow.firstOrNull() } - } - }.awaitAll() - .filter { it.second != null } - .associate { it.first to it.second!! } - } - } else emptyMap() + val syncUnread = mutableMapOf() + for (room in joinedRooms) { + val roomId = room.id() + if (roomId == _selectedChannel.value) continue + try { + val info = room.roomInfo() + val highlight = info.highlightCount + val notification = info.notificationCount + val unread = info.numUnreadMessages + val unreadNotif = info.numUnreadNotifications + val unreadMentions = info.numUnreadMentions + if (highlight > 0uL || notification > 0uL || unread > 0uL || unreadNotif > 0uL || unreadMentions > 0uL) { + android.util.Log.d("MainVM", "Room ${room.displayName()}: highlight=$highlight notif=$notification unread=$unread unreadNotif=$unreadNotif unreadMentions=$unreadMentions") + } + if (highlight > 0uL || unreadMentions > 0uL) syncUnread[roomId] = UnreadStatus.MENTIONED + else if (notification > 0uL || unreadNotif > 0uL || unread > 0uL) syncUnread[roomId] = UnreadStatus.UNREAD + } catch (_: Exception) { } + } + _roomUnreadStatus.value = syncUnread - // On first load, resolve everything; after that, only new ones - val allResolved = if (cachedRoomData.isEmpty()) { - coroutineScope { - roomMap.entries.map { (roomId, flow) -> - async { roomId to flow.firstOrNull() } - }.awaitAll() - .filter { it.second != null } - .associate { it.first to it.second!! } - } - } else { - (cachedRoomData - removedRoomIds) + newlyResolved + // Rebuild spaces list only when the set of space IDs changes + try { + val topSpaces = authRepository.getClient()?.spaceService()?.topLevelJoinedSpaces() + if (topSpaces != null) { + val newIds = topSpaces.map { it.roomId }.toSet() + val oldIds = _spaces.value.map { it.id }.toSet() + if (newIds != oldIds) { + _spaces.value = topSpaces.map { space -> + SpaceItem( + id = space.roomId, + name = space.displayName, + avatarUrl = avatarUrl(space.avatarUrl), + ) } - - cachedRoomData = allResolved - val joinedRooms = allResolved.values.filter { it.membership == Membership.JOIN } - - // Derive per-room unread status from server-provided counts - val syncUnread = mutableMapOf() - for (room in joinedRooms) { - if (room.roomId == _selectedChannel.value) continue - val count = room.unreadMessageCount ?: 0 - if (count > 0) syncUnread[room.roomId] = UnreadStatus.UNREAD - } - _roomUnreadStatus.value = syncUnread - - val allSpaces = joinedRooms - .filter { it.createEventContent?.type is RoomType.Space } - - // Collect child space IDs so we only show top-level spaces - val childSpaceIds = mutableSetOf() - val allSpaceIds = allSpaces.map { it.roomId }.toSet() - val spaceChildMap = mutableMapOf>() - coroutineScope { - allSpaces.map { space -> - async { - try { - val children = client.room.getAllState(space.roomId, ChildEventContent::class) - .firstOrNull()?.keys?.map { RoomId(it) } ?: emptyList() - space.roomId to children - } catch (_: Exception) { space.roomId to emptyList() } - } - }.awaitAll().forEach { (spaceId, children) -> - spaceChildMap[spaceId] = children.toSet() - childSpaceIds.addAll(children.filter { it in allSpaceIds }) - } - } - _spaceChildrenMap.value = spaceChildMap - - _spaces.value = allSpaces - .filter { it.roomId !in childSpaceIds } - .map { room -> - val childRooms = spaceChildMap[room.roomId] ?: emptySet() - val spaceUnread = childRooms.mapNotNull { syncUnread[it] } - .maxByOrNull { it.ordinal } ?: UnreadStatus.NONE - SpaceItem( - id = room.roomId, - name = room.name?.explicitName ?: room.roomId.full, - avatarUrl = mxcToThumbnailUrl(baseUrl, room.avatarUrl, 96), - unreadStatus = spaceUnread, - ) - } - - _allChannelRooms.value = joinedRooms - .filter { it.createEventContent?.type !is RoomType.Space } - .map { room -> - ChannelItem( - id = room.roomId, - name = room.name?.explicitName ?: room.roomId.full, - isEncrypted = room.encrypted, - avatarUrl = mxcToThumbnailUrl(baseUrl, room.avatarUrl, 64), - ) - } } } + } catch (e: Exception) { + android.util.Log.e("MainVM", "Failed to get top-level spaces", e) + if (_spaces.value.isEmpty()) { + val allSpaces = joinedRooms.filter { + try { it.isSpace() } catch (_: Exception) { false } + } + _spaces.value = allSpaces.map { room -> + SpaceItem( + id = room.id(), + name = room.displayName() ?: room.id(), + avatarUrl = avatarUrl(room.avatarUrl()), + ) + } + } + } + + _allChannelRooms.value = joinedRooms + .filter { try { !it.isSpace() } catch (_: Exception) { true } } + .map { room -> + ChannelItem( + id = room.id(), + name = room.displayName() ?: room.id(), + isEncrypted = try { room.isEncrypted() } catch (_: Exception) { false }, + avatarUrl = avatarUrl(room.avatarUrl()), + ) + } + + updateSpaceUnreadStatus() + + // On first load, compute orphan rooms and preload space children for unread dots + if (!orphanRoomsLoaded && _selectedSpace.value == null) { + orphanRoomsLoaded = true + loadOrphanRooms() + } + if (!spaceChildrenPreloaded && _spaces.value.isNotEmpty()) { + spaceChildrenPreloaded = true + preloadAllSpaceChildren() + } } } private fun observeSpaceFiltering() { viewModelScope.launch { combine(_allChannelRooms, _spaceChildren, _selectedSpace, _channelOrderMap, _roomUnreadStatus) { args -> + @Suppress("UNCHECKED_CAST") val allChannels = args[0] as List - val children = args[1] as Set? - val spaceId = args[2] as RoomId? + val children = args[1] as Set? + val spaceId = args[2] as String? val orderMap = args[3] as Map> - val unreadMap = args[4] as Map + val unreadMap = args[4] as Map val filtered = if (children == null) allChannels else allChannels.filter { it.id in children } val withUnread = filtered.map { ch -> val status = unreadMap[ch.id] ?: UnreadStatus.NONE if (status != ch.unreadStatus) ch.copy(unreadStatus = status) else ch } - val savedOrder = spaceId?.let { orderMap[it.full] } + val savedOrder = spaceId?.let { orderMap[it] } if (savedOrder != null) { val indexMap = savedOrder.withIndex().associate { (i, id) -> id to i } - withUnread.sortedBy { indexMap[it.id.full] ?: Int.MAX_VALUE } + withUnread.sortedBy { indexMap[it.id] ?: Int.MAX_VALUE } } else withUnread }.collect { _channels.value = it } } @@ -284,6 +292,11 @@ class MainViewModel( viewModelScope.launch { _selectedChannel.collect { roomId -> timelineJob?.cancel() + timelineListenerHandle?.cancel() + timelineListenerHandle = null + activeTimeline = null + isPaginating = false + hitTimelineStart = false membersJob?.cancel() if (roomId == null) { @@ -293,7 +306,6 @@ class MainViewModel( return@collect } - // Restore from cache instantly _messages.value = messageCache[roomId]?.toList() ?: emptyList() _members.value = memberCache[roomId] ?: emptyList() _channelName.value = channelNameCache[roomId] @@ -305,232 +317,288 @@ class MainViewModel( } } - private fun loadChannelName(roomId: RoomId) { + private fun loadChannelName(roomId: String) { val client = authRepository.getClient() ?: return viewModelScope.launch { - val room = client.room.getById(roomId).firstOrNull() - val name = room?.name?.explicitName ?: roomId.full - channelNameCache[roomId] = name - _channelName.value = name - } - } - - private fun loadTimeline(roomId: RoomId): Job { - val client = authRepository.getClient() ?: return Job() - val baseUrl = authRepository.getBaseUrl() ?: return Job() - return viewModelScope.launch { try { - val cached = messageCache.getOrPut(roomId) { mutableListOf() } - val ids = messageIds.getOrPut(roomId) { mutableSetOf() } - var dirty = false - var lastEmitTime = 0L - - client.room.getLastTimelineEvents(roomId).collectLatest { outerFlow -> - if (outerFlow == null) return@collectLatest - coroutineScope { - outerFlow.collect { innerFlow -> - val firstEvent = innerFlow.firstOrNull() ?: return@collect - val eventId = firstEvent.event.id.full - if (eventId in ids) { - // Already have this event — but if encrypted, watch for decryption updates - if (firstEvent.event.isEncrypted && firstEvent.content?.getOrNull() == null) { - launch { - innerFlow.collect { updated -> - val decrypted = updated.content?.getOrNull() ?: return@collect - val msgContent = resolveContent(decrypted, baseUrl) ?: return@collect - updateCachedMessage(roomId, eventId, cached, msgContent) - } - } - } - return@collect - } - - val contentResult = firstEvent.content - val content = contentResult?.getOrNull() - val msgContent: MessageContent = when { - content == null && firstEvent.event.isEncrypted -> MessageContent.Text( - body = if (contentResult?.isFailure == true) "\uD83D\uDD12 Unable to decrypt message" - else "\uD83D\uDD12 Waiting for decryption keys..." - ) - content == null -> return@collect - else -> resolveContent(content, baseUrl) ?: return@collect - } - - val senderId = firstEvent.event.sender.localpart - val senderName = senderNameCache[senderId] ?: senderId - val msg = MessageItem( - eventId = eventId, - senderId = senderId, - senderName = senderName, - senderAvatarUrl = senderAvatarCache[senderId], - content = msgContent, - timestamp = firstEvent.event.originTimestamp, - ) - - ids.add(eventId) - val insertIdx = cached.binarySearch { other -> - msg.timestamp.compareTo(other.timestamp) - }.let { if (it < 0) -(it + 1) else it } - cached.add(insertIdx, msg) - dirty = true - - // For encrypted events still waiting, watch for decryption - if (firstEvent.event.isEncrypted && content == null) { - launch { - innerFlow.collect { updated -> - val decrypted = updated.content?.getOrNull() ?: return@collect - val resolved = resolveContent(decrypted, baseUrl) ?: return@collect - updateCachedMessage(roomId, eventId, cached, resolved) - } - } - } - - // Throttle UI updates: max once per 200ms - val now = System.currentTimeMillis() - if (now - lastEmitTime > 200) { - if (_selectedChannel.value == roomId) { - _messages.value = ArrayList(cached) - } - dirty = false - lastEmitTime = now - } - } - if (dirty && _selectedChannel.value == roomId) { - _messages.value = ArrayList(cached) - } - } - } + val room = client.getRoom(roomId) + val name = room?.displayName() ?: roomId + channelNameCache[roomId] = name + _channelName.value = name } catch (_: Exception) { } } } - private fun resolveContent(content: net.folivo.trixnity.core.model.events.RoomEventContent, baseUrl: String): MessageContent? { - return when (content) { - is RoomMessageEventContent.FileBased.Image -> { - val isGif = content.info?.mimeType == "image/gif" - val url = mxcToDownloadUrl(baseUrl, content.url) ?: return null - if (isGif) MessageContent.Gif( - body = content.body, url = url, - width = content.info?.width?.toInt(), height = content.info?.height?.toInt(), - ) else MessageContent.Image( - body = content.body, url = url, - width = content.info?.width?.toInt(), height = content.info?.height?.toInt(), - ) - } - is RoomMessageEventContent.FileBased.Video -> { - val isGif = content.info?.mimeType == "image/gif" || gifUrlRegex.containsMatchIn(content.body) - val url = mxcToDownloadUrl(baseUrl, content.url) - if (isGif && url != null) MessageContent.Gif( - body = content.body, url = url, - width = content.info?.width?.toInt(), height = content.info?.height?.toInt(), - ) else MessageContent.Video( - body = content.body, url = url, - thumbnailUrl = mxcToThumbnailUrl(baseUrl, content.info?.thumbnailUrl, 300) ?: url, - width = content.info?.width?.toInt(), height = content.info?.height?.toInt(), - ) - } - is RoomMessageEventContent.FileBased.Audio -> MessageContent.File( - body = content.body, - fileName = content.fileName ?: content.body, - size = content.info?.size, - ) - is RoomMessageEventContent.FileBased.File -> MessageContent.File( - body = content.body, - fileName = content.fileName ?: content.body, - size = content.info?.size, - ) - is RoomMessageEventContent -> { - val body = content.body - MessageContent.Text( - body = body, - urls = urlRegex.findAll(body).map { it.value }.toList(), - ) - } - else -> null - } - } - - private fun updateCachedMessage(roomId: RoomId, eventId: String, cached: MutableList, newContent: MessageContent) { - val idx = cached.indexOfFirst { it.eventId == eventId } - if (idx >= 0) { - cached[idx] = cached[idx].copy(content = newContent) - if (_selectedChannel.value == roomId) { - _messages.value = ArrayList(cached) - } - } - } - - @OptIn(kotlinx.coroutines.FlowPreview::class) - private fun loadMembers(roomId: RoomId): Job { + private fun loadTimeline(roomId: String): Job { val client = authRepository.getClient() ?: return Job() - val baseUrl = authRepository.getBaseUrl() return viewModelScope.launch { try { - client.user.loadMembers(roomId) - client.user.getAll(roomId) - .debounce(1000) - .collect { userMap -> - val memberList = withContext(Dispatchers.Default) { - coroutineScope { - userMap.values.map { userFlow -> - async { userFlow.firstOrNull() } - }.awaitAll().filterNotNull().map { user -> - MemberItem( - userId = user.userId, - displayName = user.name, - avatarUrl = baseUrl?.let { - mxcToThumbnailUrl(it, user.event.content.avatarUrl, 64) - }, - ) - } - }.sortedBy { it.displayName.lowercase() } - } - memberCache[roomId] = memberList - memberList.forEach { m -> - val localpart = m.userId.localpart - senderAvatarCache[localpart] = m.avatarUrl - senderNameCache[localpart] = m.displayName - } - // Backfill avatars and display names into already-cached messages - messageCache[roomId]?.let { cached -> - var patched = false - for (i in cached.indices) { - val msg = cached[i] - val newAvatar = if (msg.senderAvatarUrl == null) senderAvatarCache[msg.senderId] else null - val newName = senderNameCache[msg.senderId] - val nameChanged = newName != null && newName != msg.senderName - if (newAvatar != null || nameChanged) { - cached[i] = msg.copy( - senderAvatarUrl = newAvatar ?: msg.senderAvatarUrl, - senderName = newName ?: msg.senderName, - ) - patched = true + val room = client.getRoom(roomId) ?: return@launch + val timeline = room.timeline() + activeTimeline = timeline + + val cached = messageCache.getOrPut(roomId) { mutableListOf() } + val ids = messageIds.getOrPut(roomId) { mutableSetOf() } + + // Register listener for live updates — must keep handle to prevent GC + timelineListenerHandle = timeline.addListener(object : TimelineListener { + override fun onUpdate(diff: List) { + viewModelScope.launch(Dispatchers.Default) { + var dirty = false + for (d in diff) { + when (d) { + is TimelineDiff.Reset -> { + // Incrementally merge rather than full replace to avoid UI flash + val newIds = mutableSetOf() + for (item in d.values) { + val eventItem = item.asEvent() ?: continue + val eid = when (val eot = eventItem.eventOrTransactionId) { + is EventOrTransactionId.EventId -> eot.eventId + is EventOrTransactionId.TransactionId -> eot.transactionId + } + newIds.add(eid) + if (eid !in ids) { + processEventItem(roomId, eventItem, cached, ids) + dirty = true + } + } + // Remove messages no longer in timeline + val removed = ids.filter { it !in newIds } + if (removed.isNotEmpty()) { + for (rid in removed) ids.remove(rid) + cached.removeAll { it.eventId in removed.toSet() } + dirty = true + } + } + is TimelineDiff.Clear -> { + cached.clear() + ids.clear() + dirty = true + } + else -> { + val items = when (d) { + is TimelineDiff.Append -> d.values + is TimelineDiff.PushBack -> listOf(d.value) + is TimelineDiff.PushFront -> listOf(d.value) + is TimelineDiff.Insert -> listOf(d.value) + is TimelineDiff.Set -> listOf(d.value) + else -> emptyList() + } + for (item in items) { + val eventItem = item.asEvent() ?: continue + if (processEventItem(roomId, eventItem, cached, ids)) dirty = true + } + } } } - if (patched && _selectedChannel.value == roomId) { + if (dirty && _selectedChannel.value == roomId) { _messages.value = ArrayList(cached) } } - if (_selectedChannel.value == roomId) { - _members.value = memberList - } } - } catch (_: Exception) { - _members.value = emptyList() + }) + + // Paginate to get initial messages + timeline.paginateBackwards(50u.toUShort()) + } catch (_: Exception) { } + } + } + + private fun processEventItem( + roomId: String, + eventItem: org.matrix.rustcomponents.sdk.EventTimelineItem, + cached: MutableList, + ids: MutableSet, + ): Boolean { + val eventId = when (val eot = eventItem.eventOrTransactionId) { + is EventOrTransactionId.EventId -> eot.eventId + is EventOrTransactionId.TransactionId -> eot.transactionId + } + if (eventId in ids) return false + + val content = eventItem.content + val msgContent: MessageContent = when (content) { + is TimelineItemContent.MsgLike -> { + when (val kind = content.content.kind) { + is MsgLikeKind.Message -> { + resolveMessageType(kind.content.msgType, kind.content.body) + ?: return false + } + is MsgLikeKind.UnableToDecrypt -> { + MessageContent.Text(body = "\uD83D\uDD12 Unable to decrypt message") + } + is MsgLikeKind.Redacted -> return false + else -> return false + } + } + else -> return false + } + + val sender = eventItem.sender + val localpart = sender.removePrefix("@").substringBefore(":") + + val profile = eventItem.senderProfile + val senderName: String + val senderAvatar: String? + when (profile) { + is ProfileDetails.Ready -> { + senderName = profile.displayName ?: localpart + senderAvatar = avatarUrl(profile.avatarUrl) + } + else -> { + senderName = senderNameCache[localpart] ?: localpart + senderAvatar = senderAvatarCache[localpart] + } + } + senderNameCache[localpart] = senderName + if (senderAvatar != null) senderAvatarCache[localpart] = senderAvatar + + val msg = MessageItem( + eventId = eventId, + senderId = localpart, + senderName = senderName, + senderAvatarUrl = senderAvatar, + content = msgContent, + timestamp = eventItem.timestamp.toLong(), + ) + + ids.add(eventId) + // Descending order (newest first at index 0) for reverse layout + val insertIdx = cached.binarySearch { + msg.timestamp.compareTo(it.timestamp) + }.let { if (it < 0) -(it + 1) else it } + cached.add(insertIdx, msg) + return true + } + + private fun resolveMessageType(msgType: MessageType, body: String): MessageContent? { + return when (msgType) { + is MessageType.Text -> { + val text = msgType.content.body + MessageContent.Text( + body = text, + urls = urlRegex.findAll(text).map { it.value }.toList(), + ) + } + is MessageType.Notice -> { + val text = msgType.content.body + MessageContent.Text( + body = text, + urls = urlRegex.findAll(text).map { it.value }.toList(), + ) + } + is MessageType.Emote -> { + MessageContent.Text(body = "* ${msgType.content.body}") + } + is MessageType.Image -> { + val c = msgType.content + val mxcUrl = c.source.url() + val url = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl + val info = c.info + val isGif = info?.mimetype == "image/gif" || info?.isAnimated == true + if (isGif) MessageContent.Gif( + body = c.filename, + url = url, + width = info?.width?.toInt(), + height = info?.height?.toInt(), + ) else MessageContent.Image( + body = c.filename, + url = url, + width = info?.width?.toInt(), + height = info?.height?.toInt(), + ) + } + is MessageType.Video -> { + val c = msgType.content + val mxcUrl = c.source.url() + val url = MxcUrlHelper.mxcToDownloadUrl(baseUrl, mxcUrl) ?: mxcUrl + val info = c.info + val thumbMxc = info?.thumbnailSource?.url() + val thumbnailUrl = MxcUrlHelper.mxcToThumbnailUrl(baseUrl, thumbMxc, 300) ?: url + MessageContent.Video( + body = c.filename, + url = url, + thumbnailUrl = thumbnailUrl, + width = info?.width?.toInt(), + height = info?.height?.toInt(), + ) + } + is MessageType.File -> { + val c = msgType.content + MessageContent.File( + body = c.filename, + fileName = c.filename, + size = c.info?.size?.toLong(), + ) + } + is MessageType.Audio -> { + val c = msgType.content + MessageContent.File( + body = c.filename, + fileName = c.filename, + size = c.info?.size?.toLong(), + ) + } + else -> { + MessageContent.Text(body = body) } } } - private fun loadSpaceChildren(spaceId: RoomId) { - val client = authRepository.getClient() ?: return - spaceChildrenJob?.cancel() - spaceChildrenJob = viewModelScope.launch { + private fun loadMembers(roomId: String): Job { + val client = authRepository.getClient() ?: return Job() + return viewModelScope.launch { try { - client.room.getAllState(spaceId, ChildEventContent::class).collect { stateMap -> - _spaceChildren.value = stateMap.keys.map { RoomId(it) }.toSet() + val room = client.getRoom(roomId) ?: return@launch + val iterator = room.members() + val allMembers = mutableListOf() + while (true) { + val chunk = iterator.nextChunk(500u) ?: break + allMembers.addAll(chunk) + } + val memberList = withContext(Dispatchers.Default) { + allMembers + .filter { it.membership is MembershipState.Join } + .map { member -> + MemberItem( + userId = member.userId, + displayName = member.displayName ?: member.userId.removePrefix("@").substringBefore(":"), + avatarUrl = avatarUrl(member.avatarUrl), + ) + } + .sortedBy { it.displayName.lowercase() } + } + memberCache[roomId] = memberList + memberList.forEach { m -> + val localpart = m.userId.removePrefix("@").substringBefore(":") + senderAvatarCache[localpart] = avatarUrl(m.avatarUrl) + senderNameCache[localpart] = m.displayName + } + // Backfill avatars into cached messages + messageCache[roomId]?.let { cached -> + var patched = false + for (i in cached.indices) { + val msg = cached[i] + val newAvatar = if (msg.senderAvatarUrl == null) senderAvatarCache[msg.senderId] else null + val newName = senderNameCache[msg.senderId] + val nameChanged = newName != null && newName != msg.senderName + if (newAvatar != null || nameChanged) { + cached[i] = msg.copy( + senderAvatarUrl = newAvatar ?: msg.senderAvatarUrl, + senderName = newName ?: msg.senderName, + ) + patched = true + } + } + if (patched && _selectedChannel.value == roomId) { + _messages.value = ArrayList(cached) + } + } + if (_selectedChannel.value == roomId) { + _members.value = memberList } } catch (_: Exception) { - _spaceChildren.value = emptySet() + _members.value = emptyList() } } } @@ -540,7 +608,9 @@ class MainViewModel( val client = authRepository.getClient() ?: return viewModelScope.launch { try { - client.room.sendMessage(roomId) { text(body) } + val room = client.getRoom(roomId) ?: return@launch + val timeline = room.timeline() + timeline.send(messageEventContentFromMarkdown(body)) } catch (_: Exception) { } } } @@ -558,41 +628,92 @@ class MainViewModel( if (idx >= 0) cursor.getString(idx) else null } else null } ?: "file" - val bytes = contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return@launch - val byteArrayFlow = flowOf(bytes) - val contentType = ContentType.parse(mimeType) - val size = bytes.size.toLong() - client.room.sendMessage(roomId) { - when { - mimeType.startsWith("image/") -> image( - body = fileName, - image = byteArrayFlow, - type = contentType, - size = size, - fileName = fileName, + val room = client.getRoom(roomId) ?: return@launch + val timeline = room.timeline() + + // Copy file to a temp path + val tempFile = java.io.File(application.cacheDir, "upload_$fileName") + contentResolver.openInputStream(uri)?.use { input -> + tempFile.outputStream().use { output -> input.copyTo(output) } + } + + val params = UploadParameters( + source = UploadSource.File(tempFile.absolutePath), + caption = null, + formattedCaption = null, + mentions = null, + inReplyTo = null, + ) + + when { + mimeType.startsWith("image/") -> { + timeline.sendImage( + params = params, + thumbnailSource = null, + imageInfo = org.matrix.rustcomponents.sdk.ImageInfo( + width = null, + height = null, + mimetype = mimeType, + size = tempFile.length().toULong(), + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + isAnimated = null, + ), ) - mimeType.startsWith("video/") -> video( - body = fileName, - video = byteArrayFlow, - type = contentType, - size = size, - fileName = fileName, + } + mimeType.startsWith("video/") -> { + timeline.sendVideo( + params = params, + thumbnailSource = null, + videoInfo = org.matrix.rustcomponents.sdk.VideoInfo( + duration = null, + width = null, + height = null, + mimetype = mimeType, + size = tempFile.length().toULong(), + thumbnailInfo = null, + thumbnailSource = null, + blurhash = null, + ), ) - else -> file( - body = fileName, - file = byteArrayFlow, - type = contentType, - size = size, - fileName = fileName, + } + else -> { + timeline.sendFile( + params = params, + fileInfo = org.matrix.rustcomponents.sdk.FileInfo( + mimetype = mimeType, + size = tempFile.length().toULong(), + thumbnailInfo = null, + thumbnailSource = null, + ), ) } } + tempFile.delete() } catch (_: Exception) { } } } - fun selectSpace(spaceId: RoomId) { + fun selectHome() { + if (_selectedSpace.value == null) { + _showChannelList.value = !_showChannelList.value + } else { + _selectedSpace.value = null + _showChannelList.value = true + // Use cached orphan IDs immediately if available + val cached = cachedOrphanIds + if (cached != null) { + _spaceChildren.value = cached + } else { + _spaceChildren.value = emptySet() + loadOrphanRooms() + } + } + } + + fun selectSpace(spaceId: String) { if (_selectedSpace.value == spaceId) { _showChannelList.value = !_showChannelList.value } else { @@ -602,9 +723,150 @@ class MainViewModel( } } - fun selectChannel(channelId: RoomId) { + private fun preloadAllSpaceChildren() { + val client = authRepository.getClient() ?: return + viewModelScope.launch { + try { + val spaceService = client.spaceService() + val map = mutableMapOf>() + for (space in _spaces.value) { + if (space.id in _spaceChildrenMap.value) continue + val allRoomIds = mutableSetOf() + val childSpaceIds = mutableSetOf() + val visited = mutableSetOf(space.id) + var currentLevel = listOf(space.id) + while (currentLevel.isNotEmpty()) { + val results = currentLevel.map { sid -> + async { paginateSpaceFully(spaceService, sid) } + }.awaitAll() + val nextLevel = mutableListOf() + for (children in results) { + for (child in children) { + allRoomIds.add(child.roomId) + if (child.roomType is org.matrix.rustcomponents.sdk.RoomType.Space) { + childSpaceIds.add(child.roomId) + if (visited.add(child.roomId)) nextLevel.add(child.roomId) + } + } + } + currentLevel = nextLevel + } + map[space.id] = allRoomIds - childSpaceIds + } + _spaceChildrenMap.value = _spaceChildrenMap.value + map + updateSpaceUnreadStatus() + // Recompute orphan rooms now that we have all space children + computeOrphanRoomsFromCache() + } catch (e: Exception) { + android.util.Log.e("MainVM", "Failed to preload space children", e) + } + } + } + + private fun computeOrphanRoomsFromCache() { + val allSpacedRoomIds = _spaceChildrenMap.value.values.flatten().toSet() + val allChannelIds = _allChannelRooms.value.map { it.id }.toSet() + val orphanIds = allChannelIds - allSpacedRoomIds + cachedOrphanIds = orphanIds + _spaceChildren.value = orphanIds + } + + private fun loadOrphanRooms() { + // If space children are already preloaded, compute from cache + if (_spaceChildrenMap.value.isNotEmpty()) { + computeOrphanRoomsFromCache() + return + } + val client = authRepository.getClient() ?: return + viewModelScope.launch { + try { + val spaceService = client.spaceService() + val allSpacedRoomIds = mutableSetOf() + val topSpaces = spaceService.topLevelJoinedSpaces() + val visited = mutableSetOf() + val queue = ArrayDeque() + for (s in topSpaces) queue.add(s.roomId) + while (queue.isNotEmpty()) { + val sid = queue.removeFirst() + if (!visited.add(sid)) continue + val children = paginateSpaceFully(spaceService, sid) + for (child in children) { + allSpacedRoomIds.add(child.roomId) + if (child.roomType is org.matrix.rustcomponents.sdk.RoomType.Space) { + queue.add(child.roomId) + } + } + } + val allChannelIds = _allChannelRooms.value.map { it.id }.toSet() + val orphanIds = allChannelIds - allSpacedRoomIds + cachedOrphanIds = orphanIds + _spaceChildren.value = orphanIds + } catch (e: Exception) { + android.util.Log.e("MainVM", "Failed to compute orphan rooms", e) + _spaceChildren.value = null + } + } + } + + private suspend fun paginateSpaceFully(spaceService: org.matrix.rustcomponents.sdk.SpaceService, spaceId: String): List { + val spaceRoomList = spaceService.spaceRoomList(spaceId) + while (true) { + val state = spaceRoomList.paginationState() + if (state is uniffi.matrix_sdk_ui.SpaceRoomListPaginationState.Idle && state.endReached) break + if (state is uniffi.matrix_sdk_ui.SpaceRoomListPaginationState.Loading) { + kotlinx.coroutines.delay(100) + continue + } + spaceRoomList.paginate() + } + return spaceRoomList.rooms() + } + + private fun loadSpaceChildren(spaceId: String) { + // Use cache if available + _spaceChildrenMap.value[spaceId]?.let { cached -> + _spaceChildren.value = cached + return + } + val client = authRepository.getClient() ?: return + viewModelScope.launch { + try { + val spaceService = client.spaceService() + val allRoomIds = mutableSetOf() + val childSpaceIds = mutableSetOf() + // BFS with parallel loading of sibling spaces + val visited = mutableSetOf(spaceId) + var currentLevel = listOf(spaceId) + while (currentLevel.isNotEmpty()) { + val results = currentLevel.map { sid -> + async { paginateSpaceFully(spaceService, sid) } + }.awaitAll() + val nextLevel = mutableListOf() + for (children in results) { + for (child in children) { + allRoomIds.add(child.roomId) + if (child.roomType is org.matrix.rustcomponents.sdk.RoomType.Space) { + childSpaceIds.add(child.roomId) + if (visited.add(child.roomId)) nextLevel.add(child.roomId) + } + } + } + currentLevel = nextLevel + } + // Only keep actual rooms, not child spaces + val roomIds = allRoomIds - childSpaceIds + _spaceChildren.value = roomIds + _spaceChildrenMap.value = _spaceChildrenMap.value + (spaceId to roomIds) + updateSpaceUnreadStatus() + } catch (e: Exception) { + android.util.Log.e("MainVM", "Failed to load space children for $spaceId", e) + _spaceChildren.value = null + } + } + } + + fun selectChannel(channelId: String) { _selectedChannel.value = channelId - // Clear unread status for the opened room if (_roomUnreadStatus.value.containsKey(channelId)) { _roomUnreadStatus.value = _roomUnreadStatus.value - channelId updateSpaceUnreadStatus() @@ -614,13 +876,36 @@ class MainViewModel( private fun updateSpaceUnreadStatus() { val unreadMap = _roomUnreadStatus.value val childrenMap = _spaceChildrenMap.value - val baseUrl = authRepository.getBaseUrl() ?: return _spaces.value = _spaces.value.map { space -> val childRooms = childrenMap[space.id] ?: emptySet() val spaceUnread = childRooms.mapNotNull { unreadMap[it] } .maxByOrNull { it.ordinal } ?: UnreadStatus.NONE if (spaceUnread != space.unreadStatus) space.copy(unreadStatus = spaceUnread) else space } + // Compute home (orphan rooms) unread + val allSpacedRoomIds = childrenMap.values.flatten().toSet() + val orphanUnread = unreadMap.filter { it.key !in allSpacedRoomIds }.values + .maxByOrNull { it.ordinal } ?: UnreadStatus.NONE + _homeUnreadStatus.value = orphanUnread + } + + fun loadMoreMessages() { + val timeline = activeTimeline ?: return + if (isPaginating || hitTimelineStart) return + isPaginating = true + viewModelScope.launch { + try { + val reachedStart = timeline.paginateBackwards(50u.toUShort()) + if (reachedStart) { + hitTimelineStart = true + android.util.Log.d("MainVM", "Hit timeline start") + } + } catch (e: Exception) { + android.util.Log.e("MainVM", "paginateBackwards failed", e) + } finally { + isPaginating = false + } + } } fun toggleChannelList() { @@ -641,8 +926,8 @@ class MainViewModel( val item = current.removeAt(from) current.add(to, item) _channels.value = current - val spaceId = _selectedSpace.value?.full ?: return - val roomIds = current.map { it.id.full } + val spaceId = _selectedSpace.value ?: return + val roomIds = current.map { it.id } _channelOrderMap.value = _channelOrderMap.value + (spaceId to roomIds) viewModelScope.launch { preferencesManager.saveChannelOrder(spaceId, roomIds) @@ -651,6 +936,7 @@ class MainViewModel( fun logout() { viewModelScope.launch { + try { syncService?.stop() } catch (_: Exception) { } authRepository.logout() } } diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt index 94545de..4256e9c 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/ChannelList.kt @@ -52,14 +52,13 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import com.example.fluffytrix.ui.screens.main.ChannelItem import com.example.fluffytrix.ui.screens.main.UnreadStatus -import net.folivo.trixnity.core.model.RoomId import kotlin.math.roundToInt @Composable fun ChannelList( channels: List, - selectedChannel: RoomId?, - onChannelClick: (RoomId) -> Unit, + selectedChannel: String?, + onChannelClick: (String) -> Unit, onLogout: () -> Unit, contentPadding: PaddingValues, isReorderMode: Boolean = false, @@ -136,7 +135,7 @@ fun ChannelList( contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.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 isDragging = draggingIndex == index val elevation by animateDpAsState(if (isDragging) 8.dp else 0.dp, label = "elevation") diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MemberList.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MemberList.kt index 127dde2..411fb3c 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MemberList.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MemberList.kt @@ -52,7 +52,7 @@ fun MemberList( contentPadding = PaddingValues(horizontal = 8.dp), verticalArrangement = Arrangement.spacedBy(2.dp), ) { - items(members, key = { it.userId.full }) { member -> + items(members, key = { it.userId }) { member -> Row( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt index 99d7882..56b3b59 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/MessageTimeline.kt @@ -65,7 +65,6 @@ import kotlinx.coroutines.launch import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -79,7 +78,6 @@ import org.koin.compose.koinInject import coil3.compose.AsyncImage import com.example.fluffytrix.ui.screens.main.MessageContent import com.example.fluffytrix.ui.screens.main.MessageItem -import net.folivo.trixnity.core.model.RoomId import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -114,12 +112,13 @@ private fun formatTimestamp(timestamp: Long): String { @Composable fun MessageTimeline( - selectedChannel: RoomId?, + selectedChannel: String?, channelName: String?, messages: List, onToggleMemberList: () -> Unit, onSendMessage: (String) -> Unit, onSendFile: (Uri) -> Unit, + onLoadMore: () -> Unit = {}, modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues(), ) { @@ -151,7 +150,7 @@ fun MessageTimeline( .padding(top = contentPadding.calculateTopPadding()), ) { if (selectedChannel != null) { - TopBar(channelName ?: selectedChannel.full, onToggleMemberList) + TopBar(channelName ?: selectedChannel, onToggleMemberList) 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 LaunchedEffect(messages.size) { if (listState.firstVisibleItemIndex <= 2) { @@ -360,7 +370,7 @@ private fun GifContent(content: MessageContent.Gif) { content.width.toFloat() / content.height.toFloat() else 16f / 9f val exoPlayer = remember(content.url) { - val token = authRepository.getAccessToken() + val token = try { authRepository.getClient()?.session()?.accessToken } catch (_: Exception) { null } val dataSourceFactory = DefaultHttpDataSource.Factory().apply { if (token != null) { setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token")) @@ -490,7 +500,7 @@ private fun FullscreenVideoPlayer(url: String, onDismiss: () -> Unit) { val context = LocalContext.current val authRepository: AuthRepository = koinInject() val exoPlayer = remember { - val token = authRepository.getAccessToken() + val token = try { authRepository.getClient()?.session()?.accessToken } catch (_: Exception) { null } val dataSourceFactory = DefaultHttpDataSource.Factory().apply { if (token != null) { setDefaultRequestProperties(mapOf("Authorization" to "Bearer $token")) diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/SpaceList.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/SpaceList.kt index 40784bf..5344cd2 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/SpaceList.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/main/components/SpaceList.kt @@ -35,13 +35,14 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.example.fluffytrix.ui.screens.main.SpaceItem import com.example.fluffytrix.ui.screens.main.UnreadStatus -import net.folivo.trixnity.core.model.RoomId @Composable fun SpaceList( spaces: List, - selectedSpace: RoomId?, - onSpaceClick: (RoomId) -> Unit, + selectedSpace: String?, + homeUnreadStatus: UnreadStatus = UnreadStatus.NONE, + onSpaceClick: (String) -> Unit, + onHomeClick: () -> Unit, onToggleChannelList: () -> Unit, contentPadding: PaddingValues, ) { @@ -68,27 +69,41 @@ fun SpaceList( // Home button item { - Box( - modifier = Modifier - .size(48.dp) - .clip(if (selectedSpace == null) RoundedCornerShape(16.dp) else CircleShape) - .background( - if (selectedSpace == null) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.surface + Box(modifier = Modifier.size(48.dp)) { + Box( + modifier = Modifier + .size(48.dp) + .clip(if (selectedSpace == null) RoundedCornerShape(16.dp) else CircleShape) + .background( + if (selectedSpace == null) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.surface + ) + .clickable { onHomeClick() }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Home, + contentDescription = "Home", + tint = if (selectedSpace == null) MaterialTheme.colorScheme.onPrimary + else MaterialTheme.colorScheme.onSurface, ) - .clickable { /* home/all rooms */ }, - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = Icons.Default.Home, - contentDescription = "Home", - tint = if (selectedSpace == null) MaterialTheme.colorScheme.onPrimary - 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 Box(modifier = Modifier.size(48.dp)) { Box( diff --git a/app/src/main/java/com/example/fluffytrix/ui/screens/verification/VerificationViewModel.kt b/app/src/main/java/com/example/fluffytrix/ui/screens/verification/VerificationViewModel.kt index b30c770..f2dffdd 100644 --- a/app/src/main/java/com/example/fluffytrix/ui/screens/verification/VerificationViewModel.kt +++ b/app/src/main/java/com/example/fluffytrix/ui/screens/verification/VerificationViewModel.kt @@ -5,14 +5,12 @@ import androidx.lifecycle.viewModelScope import com.example.fluffytrix.data.repository.AuthRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import net.folivo.trixnity.client.verification -import net.folivo.trixnity.client.verification.ActiveDeviceVerification -import net.folivo.trixnity.client.verification.ActiveSasVerificationState -import net.folivo.trixnity.client.verification.ActiveVerificationState -import net.folivo.trixnity.client.verification.SelfVerificationMethod -import net.folivo.trixnity.client.verification.VerificationService.SelfVerificationMethods +import org.matrix.rustcomponents.sdk.SessionVerificationController +import org.matrix.rustcomponents.sdk.SessionVerificationControllerDelegate +import org.matrix.rustcomponents.sdk.SessionVerificationData +import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails +import org.matrix.rustcomponents.sdk.VerificationState sealed class VerificationUiState { data object Loading : VerificationUiState() @@ -41,158 +39,126 @@ class VerificationViewModel( private val _uiState = MutableStateFlow(VerificationUiState.Loading) val uiState: StateFlow = _uiState - private var selfVerificationMethods: SelfVerificationMethods? = null - private var activeDeviceVerification: ActiveDeviceVerification? = null + private var verificationController: SessionVerificationController? = null init { - loadVerificationMethods() + checkVerificationStatus() } - private fun loadVerificationMethods() { + private fun checkVerificationStatus() { val client = authRepository.getClient() ?: run { _uiState.value = VerificationUiState.Error("Not logged in") return } viewModelScope.launch { - client.verification.getSelfVerificationMethods().collectLatest { methods -> - selfVerificationMethods = methods - when (methods) { - is SelfVerificationMethods.PreconditionsNotMet -> - _uiState.value = VerificationUiState.Loading + try { + // Sync must be running before encryption/verification APIs work + authRepository.getOrStartSync() + // Give sync a moment to initialize encryption state + kotlinx.coroutines.delay(2000) - is SelfVerificationMethods.NoCrossSigningEnabled -> - _uiState.value = VerificationUiState.NoCrossSigning + val verState = try { + client.encryption().verificationState() + } catch (_: Exception) { VerificationState.UNKNOWN } - is SelfVerificationMethods.AlreadyCrossSigned -> - _uiState.value = VerificationUiState.AlreadyVerified + if (verState == VerificationState.VERIFIED) { + _uiState.value = VerificationUiState.AlreadyVerified + return@launch + } - is SelfVerificationMethods.CrossSigningEnabled -> { - if (methods.methods.isEmpty()) { - _uiState.value = VerificationUiState.NoCrossSigning - } else { - _uiState.value = VerificationUiState.MethodSelection( - hasDeviceVerification = methods.methods.any { - it is SelfVerificationMethod.CrossSignedDeviceVerification - }, - hasRecoveryKey = methods.methods.any { - it is SelfVerificationMethod.AesHmacSha2RecoveryKey - }, - hasPassphrase = methods.methods.any { - it is SelfVerificationMethod.AesHmacSha2RecoveryKeyWithPbkdf2Passphrase - }, - ) + val controller = try { + client.getSessionVerificationController() + } catch (_: Exception) { null } + verificationController = controller + + controller?.setDelegate(object : SessionVerificationControllerDelegate { + override fun didReceiveVerificationRequest(details: SessionVerificationRequestDetails) { + // Another device requested verification with us + } + + override fun didAcceptVerificationRequest() { + _uiState.value = VerificationUiState.WaitingForDevice + } + + 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() { - val methods = selfVerificationMethods as? SelfVerificationMethods.CrossSigningEnabled ?: return - val deviceMethod = methods.methods.filterIsInstance() - .firstOrNull() ?: return - + val controller = verificationController ?: return _uiState.value = VerificationUiState.WaitingForDevice viewModelScope.launch { - deviceMethod.createDeviceVerification() - .onSuccess { verification -> - activeDeviceVerification = verification - observeVerificationState(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 */ } - } + try { + controller.requestDeviceVerification() + } catch (e: Exception) { + _uiState.value = VerificationUiState.Error(e.message ?: "Failed to start verification") } } } fun confirmEmojiMatch() { - val state = (_uiState.value as? VerificationUiState.EmojiComparison) ?: return + val controller = verificationController ?: return viewModelScope.launch { - val verification = activeDeviceVerification ?: return@launch - val verState = verification.state.value - if (verState is ActiveVerificationState.Start) { - val method = verState.method - if (method is net.folivo.trixnity.client.verification.ActiveSasVerificationMethod) { - val sasState = method.state.value - if (sasState is ActiveSasVerificationState.ComparisonByUser) { - sasState.match() - } - } + try { + controller.approveVerification() + } catch (e: Exception) { + _uiState.value = VerificationUiState.Error(e.message ?: "Failed to confirm verification") } } } fun rejectEmojiMatch() { + val controller = verificationController ?: return viewModelScope.launch { - val verification = activeDeviceVerification ?: return@launch - val verState = verification.state.value - if (verState is ActiveVerificationState.Start) { - val method = verState.method - if (method is net.folivo.trixnity.client.verification.ActiveSasVerificationMethod) { - val sasState = method.state.value - if (sasState is ActiveSasVerificationState.ComparisonByUser) { - sasState.noMatch() - } - } + try { + controller.cancelVerification() + } catch (e: Exception) { + _uiState.value = VerificationUiState.Error(e.message ?: "Failed to cancel verification") } } } @@ -206,43 +172,24 @@ class VerificationViewModel( } fun verifyWithRecoveryKey(recoveryKey: String) { - val methods = selfVerificationMethods as? SelfVerificationMethods.CrossSigningEnabled ?: return - val keyMethod = methods.methods.filterIsInstance() - .firstOrNull() ?: return - + val client = authRepository.getClient() ?: return viewModelScope.launch { - keyMethod.verify(recoveryKey) - .onSuccess { - _uiState.value = VerificationUiState.VerificationDone - } - .onFailure { - _uiState.value = VerificationUiState.Error( - it.message ?: "Invalid recovery key" - ) - } + try { + client.encryption().recover(recoveryKey) + _uiState.value = VerificationUiState.VerificationDone + } catch (e: Exception) { + _uiState.value = VerificationUiState.Error( + e.message ?: "Invalid recovery key" + ) + } } } fun verifyWithPassphrase(passphrase: String) { - val methods = selfVerificationMethods as? SelfVerificationMethods.CrossSigningEnabled ?: return - val passphraseMethod = methods.methods - .filterIsInstance() - .firstOrNull() ?: return - - viewModelScope.launch { - passphraseMethod.verify(passphrase) - .onSuccess { - _uiState.value = VerificationUiState.VerificationDone - } - .onFailure { - _uiState.value = VerificationUiState.Error( - it.message ?: "Invalid passphrase" - ) - } - } + verifyWithRecoveryKey(passphrase) } fun goBack() { - loadVerificationMethods() + checkVerificationStatus() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a9611ff..e933305 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,11 +15,11 @@ lifecycleViewModel = "2.9.1" koin = "4.1.1" datastore = "1.1.7" coroutines = "1.10.2" -trixnity = "4.22.7" -ktor = "3.3.0" +matrixRustSdk = "26.02.19" coil = "3.2.0" media3 = "1.6.0" markdownRenderer = "0.37.0" +kotlinxSerialization = "1.8.1" [libraries] 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-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" } -# Trixnity (using -jvm variants for Android) -trixnity-client = { group = "net.folivo", name = "trixnity-client-jvm", version.ref = "trixnity" } -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" } +# Matrix Rust SDK +matrix-rust-sdk = { group = "org.matrix.rustcomponents", name = "sdk-android", version.ref = "matrixRustSdk" } -# Ktor (needed by Trixnity on Android) -ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } +# Kotlinx Serialization +kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerialization" } # Coil (image loading) coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }