I like where we are now
This commit is contained in:
@@ -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)
|
||||
|
||||
24
app/proguard-rules.pro
vendored
24
app/proguard-rules.pro
vendored
@@ -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.** { *; }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<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 raw = prefs[KEY_CHANNEL_ORDER] ?: return@map emptyMap()
|
||||
try {
|
||||
|
||||
@@ -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<MatrixClient> {
|
||||
): Result<Client> {
|
||||
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<AccountStore>()
|
||||
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<AccountStore>()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -52,14 +52,13 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import 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<ChannelItem>,
|
||||
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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<MessageItem>,
|
||||
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"))
|
||||
|
||||
@@ -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<SpaceItem>,
|
||||
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(
|
||||
|
||||
@@ -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>(VerificationUiState.Loading)
|
||||
val uiState: StateFlow<VerificationUiState> = _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<SelfVerificationMethod.CrossSignedDeviceVerification>()
|
||||
.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<SelfVerificationMethod.AesHmacSha2RecoveryKey>()
|
||||
.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<SelfVerificationMethod.AesHmacSha2RecoveryKeyWithPbkdf2Passphrase>()
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user