I like where we are now

This commit is contained in:
2026-02-24 12:55:10 +00:00
parent 3ca324d34f
commit b159bf6a56
16 changed files with 972 additions and 666 deletions

View File

@@ -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:*)"
] ]
} }
} }

View File

@@ -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

View File

@@ -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)

View File

@@ -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.** { *; }

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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
return try { val accessToken = preferencesManager.accessToken.firstOrNull() ?: return false
val client = MatrixClient.fromStore( val userId = preferencesManager.userId.firstOrNull() ?: return false
repositoriesModule = createRoomRepositoriesModule(createDatabaseBuilder()), val deviceId = preferencesManager.deviceId.firstOrNull() ?: return false
mediaStoreModule = createInMemoryMediaStoreModule(), val homeserverUrl = preferencesManager.homeserverUrl.firstOrNull() ?: return false
).getOrNull() val refreshToken = preferencesManager.refreshToken.firstOrNull()
val oidcData = preferencesManager.oidcData.firstOrNull()
val slidingSyncVersionStr = preferencesManager.slidingSyncVersion.firstOrNull() ?: "NATIVE"
if (client != null) { return try {
matrixClient = client val client = ClientBuilder()
try { .sessionPaths(sessionDataPath(), sessionCachePath())
val accountStore = client.di.get<AccountStore>() .homeserverUrl(homeserverUrl)
accessToken = accountStore.getAccount()?.accessToken .slidingSyncVersionBuilder(SlidingSyncVersionBuilder.DISCOVER_NATIVE)
} catch (_: Exception) { } .build()
client.startSync()
true val slidingSyncVersion = try {
} else { SlidingSyncVersion.valueOf(slidingSyncVersionStr)
// Store was empty or corrupt — clear saved state } catch (_: Exception) { SlidingSyncVersion.NATIVE }
preferencesManager.clearSession()
false client.restoreSession(Session(
} accessToken = accessToken,
} catch (_: Exception) { 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() 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() {
matrixClient?.logout() try {
matrixClient?.close() syncService?.stop()
} catch (_: Exception) { }
syncService = null
try {
matrixClient?.logout()
} catch (_: Exception) { }
matrixClient = null matrixClient = null
accessToken = null
preferencesManager.clearSession() preferencesManager.clearSession()
File(sessionDataPath()).deleteRecursively()
File(sessionCachePath()).deleteRecursively()
} }
} }

View File

@@ -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") },
) )
} }

View File

@@ -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,
) )

View File

@@ -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")

View File

@@ -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()

View File

@@ -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"))

View File

@@ -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,27 +69,41 @@ fun SpaceList(
// Home button // Home button
item { item {
Box( Box(modifier = Modifier.size(48.dp)) {
modifier = Modifier Box(
.size(48.dp) modifier = Modifier
.clip(if (selectedSpace == null) RoundedCornerShape(16.dp) else CircleShape) .size(48.dp)
.background( .clip(if (selectedSpace == null) RoundedCornerShape(16.dp) else CircleShape)
if (selectedSpace == null) MaterialTheme.colorScheme.primary .background(
else MaterialTheme.colorScheme.surface 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, if (homeUnreadStatus != UnreadStatus.NONE) {
) { Box(
Icon( modifier = Modifier
imageVector = Icons.Default.Home, .size(8.dp)
contentDescription = "Home", .align(Alignment.TopEnd)
tint = if (selectedSpace == null) MaterialTheme.colorScheme.onPrimary .background(
else MaterialTheme.colorScheme.onSurface, 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(

View File

@@ -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( e.message ?: "Invalid recovery key"
it.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()
} }
} }

View File

@@ -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" }