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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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