From daddbfd2a05a7ed53bbe3a60c2c4183056bc3b70 Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 9 Mar 2026 23:44:53 +0300 Subject: [PATCH] android: add multi-account switching foundation in settings --- android/CHANGELOG.md | 16 ++ .../core/token/DataStoreTokenRepository.kt | 74 +++++ .../token/EncryptedPrefsTokenRepository.kt | 272 ++++++++++++++++-- .../messenger/core/token/StoredAccount.kt | 11 + .../messenger/core/token/TokenRepository.kt | 8 + .../auth/repository/NetworkAuthRepository.kt | 26 +- .../messenger/ui/account/AccountUiState.kt | 10 + .../messenger/ui/account/AccountViewModel.kt | 37 +++ .../messenger/ui/auth/AuthViewModel.kt | 4 + .../messenger/ui/navigation/AppNavGraph.kt | 7 + .../messenger/ui/settings/SettingsScreen.kt | 70 +++++ 11 files changed, 513 insertions(+), 22 deletions(-) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/core/token/StoredAccount.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 82c1a36..c45d05d 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -731,3 +731,19 @@ - primary profile info card, - tab-like section (`Posts/Archived/Gifts`) with placeholder content, - inline edit card (name/username/bio/avatar URL) with existing save/upload behavior preserved. + +### Step 110 - Multi-account foundation (switch active account) +- Extended `TokenRepository` to support account list and active-account switching: + - observe/list stored accounts, + - get active account id, + - switch/remove account, + - clear all tokens. +- Reworked `EncryptedPrefsTokenRepository` storage model: + - stores tokens per `userId` and account metadata list in encrypted prefs, + - migrates legacy single-account keys on first run, + - preserves active account pointer. +- `NetworkAuthRepository` now upserts account metadata after auth/me calls. +- Added `Settings` UI account section: + - shows saved accounts, + - allows switch/remove, + - triggers auth recheck + chats reload on switch. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/core/token/DataStoreTokenRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/core/token/DataStoreTokenRepository.kt index eef9bd7..9bdfea1 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/core/token/DataStoreTokenRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/core/token/DataStoreTokenRepository.kt @@ -20,10 +20,40 @@ class DataStoreTokenRepository @Inject constructor( preferences.toTokenBundleOrNull() } + override fun observeAccounts(): Flow> { + return observeTokens().map { tokens -> + if (tokens == null) emptyList() else { + val userId = tokens.accessToken.extractUserIdFromJwt() ?: return@map emptyList() + listOf( + StoredAccount( + userId = userId, + email = null, + name = "User #$userId", + username = null, + avatarUrl = null, + lastActiveAt = tokens.savedAtMillis, + ) + ) + } + } + } + + override fun observeActiveUserId(): Flow { + return observeTokens().map { it?.accessToken?.extractUserIdFromJwt() } + } + override suspend fun getTokens(): TokenBundle? { return observeTokens().first() } + override suspend fun getAccounts(): List { + return observeAccounts().first() + } + + override suspend fun getActiveUserId(): Long? { + return observeActiveUserId().first() + } + override suspend fun saveTokens(tokens: TokenBundle) { dataStore.edit { preferences -> preferences[ACCESS_TOKEN_KEY] = tokens.accessToken @@ -32,6 +62,20 @@ class DataStoreTokenRepository @Inject constructor( } } + override suspend fun upsertAccount(account: StoredAccount) { + // DataStoreTokenRepository is not used in production DI currently. + } + + override suspend fun switchAccount(userId: Long): Boolean { + return getActiveUserId() == userId + } + + override suspend fun removeAccount(userId: Long) { + if (getActiveUserId() == userId) { + clearTokens() + } + } + override suspend fun clearTokens() { dataStore.edit { preferences -> preferences.remove(ACCESS_TOKEN_KEY) @@ -40,6 +84,10 @@ class DataStoreTokenRepository @Inject constructor( } } + override suspend fun clearAllTokens() { + clearTokens() + } + private fun Preferences.toTokenBundleOrNull(): TokenBundle? { val access = this[ACCESS_TOKEN_KEY] val refresh = this[REFRESH_TOKEN_KEY] @@ -56,6 +104,32 @@ class DataStoreTokenRepository @Inject constructor( ) } + private fun String.extractUserIdFromJwt(): Long? { + val payload = split('.').getOrNull(1) ?: return null + val normalized = payload + .replace('-', '+') + .replace('_', '/') + .let { source -> + when (source.length % 4) { + 0 -> source + 2 -> source + "==" + 3 -> source + "=" + else -> return null + } + } + return runCatching { + val json = String(java.util.Base64.getDecoder().decode(normalized), Charsets.UTF_8) + val marker = "\"sub\":\"" + val start = json.indexOf(marker) + if (start < 0) null + else { + val valueStart = start + marker.length + val valueEnd = json.indexOf('"', valueStart) + if (valueEnd <= valueStart) null else json.substring(valueStart, valueEnd).toLongOrNull() + } + }.getOrNull() + } + private companion object { val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token") val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token") diff --git a/android/app/src/main/java/ru/daemonlord/messenger/core/token/EncryptedPrefsTokenRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/core/token/EncryptedPrefsTokenRepository.kt index 49c220f..c183d2b 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/core/token/EncryptedPrefsTokenRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/core/token/EncryptedPrefsTokenRepository.kt @@ -1,9 +1,12 @@ package ru.daemonlord.messenger.core.token import android.content.SharedPreferences +import android.util.Base64 import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import org.json.JSONArray +import org.json.JSONObject import ru.daemonlord.messenger.di.TokenPrefs import javax.inject.Inject import javax.inject.Singleton @@ -13,48 +16,277 @@ class EncryptedPrefsTokenRepository @Inject constructor( @TokenPrefs private val sharedPreferences: SharedPreferences, ) : TokenRepository { - private val tokensFlow = MutableStateFlow(readTokens()) + private val tokensFlow = MutableStateFlow(null) + private val accountsFlow = MutableStateFlow>(emptyList()) + private val activeUserIdFlow = MutableStateFlow(null) + + init { + migrateLegacyIfNeeded() + refreshFlows() + } override fun observeTokens(): Flow = tokensFlow.asStateFlow() + override fun observeAccounts(): Flow> = accountsFlow.asStateFlow() + + override fun observeActiveUserId(): Flow = activeUserIdFlow.asStateFlow() + override suspend fun getTokens(): TokenBundle? = tokensFlow.value + override suspend fun getAccounts(): List = accountsFlow.value + + override suspend fun getActiveUserId(): Long? = activeUserIdFlow.value + override suspend fun saveTokens(tokens: TokenBundle) { - sharedPreferences.edit() - .putString(ACCESS_TOKEN_KEY, tokens.accessToken) - .putString(REFRESH_TOKEN_KEY, tokens.refreshToken) - .putLong(SAVED_AT_KEY, tokens.savedAtMillis) - .apply() - tokensFlow.value = tokens + val userId = tokens.accessToken.extractUserIdFromJwt() + ?: activeUserIdFlow.value + ?: return + val allTokens = readAllTokenEntries().toMutableMap() + allTokens[userId] = tokens + writeAllTokenEntries(allTokens) + writeActiveUserId(userId) + ensureAccountPlaceholder(userId = userId, lastActiveAt = tokens.savedAtMillis) + refreshFlows() + } + + override suspend fun upsertAccount(account: StoredAccount) { + val accounts = readAccounts().associateBy { it.userId }.toMutableMap() + val existing = accounts[account.userId] + accounts[account.userId] = account.copy( + lastActiveAt = maxOf(existing?.lastActiveAt ?: 0L, account.lastActiveAt), + ) + writeAccounts(accounts.values.toList()) + refreshFlows() + } + + override suspend fun switchAccount(userId: Long): Boolean { + val allTokens = readAllTokenEntries() + if (!allTokens.containsKey(userId)) { + return false + } + writeActiveUserId(userId) + refreshFlows() + return true + } + + override suspend fun removeAccount(userId: Long) { + val allTokens = readAllTokenEntries().toMutableMap() + allTokens.remove(userId) + writeAllTokenEntries(allTokens) + + val accounts = readAccounts().filterNot { it.userId == userId } + writeAccounts(accounts) + + val active = readActiveUserId() + if (active == userId) { + val nextUserId = allTokens.entries + .maxByOrNull { it.value.savedAtMillis } + ?.key + writeActiveUserId(nextUserId) + } + refreshFlows() } override suspend fun clearTokens() { + val active = readActiveUserId() ?: return + removeAccount(active) + } + + override suspend fun clearAllTokens() { + sharedPreferences.edit() + .remove(TOKENS_JSON_KEY) + .remove(ACCOUNTS_JSON_KEY) + .remove(ACTIVE_USER_ID_KEY) + .remove(ACCESS_TOKEN_KEY) + .remove(REFRESH_TOKEN_KEY) + .remove(SAVED_AT_KEY) + .apply() + refreshFlows() + } + + private fun refreshFlows() { + val activeUserId = readActiveUserId() + activeUserIdFlow.value = activeUserId + tokensFlow.value = activeUserId?.let { readAllTokenEntries()[it] } + accountsFlow.value = readAccounts().sortedByDescending { it.lastActiveAt } + } + + private fun migrateLegacyIfNeeded() { + val hasModernStorage = sharedPreferences.contains(TOKENS_JSON_KEY) + if (hasModernStorage) return + + val legacyAccess = sharedPreferences.getString(ACCESS_TOKEN_KEY, null) + val legacyRefresh = sharedPreferences.getString(REFRESH_TOKEN_KEY, null) + val legacySavedAt = sharedPreferences.getLong(SAVED_AT_KEY, -1L) + if (legacyAccess.isNullOrBlank() || legacyRefresh.isNullOrBlank() || legacySavedAt <= 0L) { + return + } + + val userId = legacyAccess.extractUserIdFromJwt() ?: return + val token = TokenBundle( + accessToken = legacyAccess, + refreshToken = legacyRefresh, + savedAtMillis = legacySavedAt, + ) + writeAllTokenEntries(mapOf(userId to token)) + writeActiveUserId(userId) + writeAccounts( + listOf( + StoredAccount( + userId = userId, + email = null, + name = "User #$userId", + username = null, + avatarUrl = null, + lastActiveAt = legacySavedAt, + ) + ) + ) sharedPreferences.edit() .remove(ACCESS_TOKEN_KEY) .remove(REFRESH_TOKEN_KEY) .remove(SAVED_AT_KEY) .apply() - tokensFlow.value = null } - private fun readTokens(): TokenBundle? { - val access = sharedPreferences.getString(ACCESS_TOKEN_KEY, null) - val refresh = sharedPreferences.getString(REFRESH_TOKEN_KEY, null) - val savedAt = sharedPreferences.getLong(SAVED_AT_KEY, -1L) - if (access.isNullOrBlank() || refresh.isNullOrBlank() || savedAt <= 0L) { - return null + private fun ensureAccountPlaceholder(userId: Long, lastActiveAt: Long) { + val accounts = readAccounts().associateBy { it.userId }.toMutableMap() + val existing = accounts[userId] + if (existing == null) { + accounts[userId] = StoredAccount( + userId = userId, + email = null, + name = "User #$userId", + username = null, + avatarUrl = null, + lastActiveAt = lastActiveAt, + ) + } else { + accounts[userId] = existing.copy(lastActiveAt = maxOf(existing.lastActiveAt, lastActiveAt)) } - return TokenBundle( - accessToken = access, - refreshToken = refresh, - savedAtMillis = savedAt, - ) + writeAccounts(accounts.values.toList()) + } + + private fun readActiveUserId(): Long? { + val value = sharedPreferences.getLong(ACTIVE_USER_ID_KEY, -1L) + return value.takeIf { it > 0L } + } + + private fun writeActiveUserId(userId: Long?) { + sharedPreferences.edit().apply { + if (userId == null) { + remove(ACTIVE_USER_ID_KEY) + } else { + putLong(ACTIVE_USER_ID_KEY, userId) + } + }.apply() + } + + private fun readAllTokenEntries(): Map { + val raw = sharedPreferences.getString(TOKENS_JSON_KEY, null).orEmpty() + if (raw.isBlank()) return emptyMap() + return runCatching { + val root = JSONObject(raw) + root.keys().asSequence().mapNotNull { key -> + val userId = key.toLongOrNull() ?: return@mapNotNull null + val node = root.optJSONObject(key) ?: return@mapNotNull null + val access = node.optString("access", "") + val refresh = node.optString("refresh", "") + val savedAt = node.optLong("savedAt", -1L) + if (access.isBlank() || refresh.isBlank() || savedAt <= 0L) { + null + } else { + userId to TokenBundle(access, refresh, savedAt) + } + }.toMap() + }.getOrDefault(emptyMap()) + } + + private fun writeAllTokenEntries(tokens: Map) { + val root = JSONObject() + tokens.forEach { (userId, token) -> + root.put( + userId.toString(), + JSONObject().apply { + put("access", token.accessToken) + put("refresh", token.refreshToken) + put("savedAt", token.savedAtMillis) + } + ) + } + sharedPreferences.edit().putString(TOKENS_JSON_KEY, root.toString()).apply() + } + + private fun readAccounts(): List { + val raw = sharedPreferences.getString(ACCOUNTS_JSON_KEY, null).orEmpty() + if (raw.isBlank()) return emptyList() + return runCatching { + val array = JSONArray(raw) + buildList { + for (index in 0 until array.length()) { + val node = array.optJSONObject(index) ?: continue + val userId = node.optLong("userId", -1L) + if (userId <= 0L) continue + add( + StoredAccount( + userId = userId, + email = node.optString("email", "").ifBlank { null }, + name = node.optString("name", "User #$userId").ifBlank { "User #$userId" }, + username = node.optString("username", "").ifBlank { null }, + avatarUrl = node.optString("avatarUrl", "").ifBlank { null }, + lastActiveAt = node.optLong("lastActiveAt", 0L), + ) + ) + } + } + }.getOrDefault(emptyList()) + } + + private fun writeAccounts(accounts: List) { + val array = JSONArray() + accounts.forEach { account -> + array.put( + JSONObject().apply { + put("userId", account.userId) + put("email", account.email.orEmpty()) + put("name", account.name) + put("username", account.username.orEmpty()) + put("avatarUrl", account.avatarUrl.orEmpty()) + put("lastActiveAt", account.lastActiveAt) + } + ) + } + sharedPreferences.edit().putString(ACCOUNTS_JSON_KEY, array.toString()).apply() + } + + private fun String.extractUserIdFromJwt(): Long? { + val parts = split('.') + if (parts.size < 2) return null + val payload = parts[1] + val normalized = payload + .replace('-', '+') + .replace('_', '/') + .let { source -> + when (source.length % 4) { + 0 -> source + 2 -> source + "==" + 3 -> source + "=" + else -> return null + } + } + return runCatching { + val payloadJson = String(Base64.decode(normalized, Base64.DEFAULT), Charsets.UTF_8) + JSONObject(payloadJson).optString("sub").toLongOrNull() + }.getOrNull() } private companion object { const val ACCESS_TOKEN_KEY = "access_token" const val REFRESH_TOKEN_KEY = "refresh_token" const val SAVED_AT_KEY = "tokens_saved_at" + + const val TOKENS_JSON_KEY = "tokens_json" + const val ACCOUNTS_JSON_KEY = "accounts_json" + const val ACTIVE_USER_ID_KEY = "active_user_id" } } - diff --git a/android/app/src/main/java/ru/daemonlord/messenger/core/token/StoredAccount.kt b/android/app/src/main/java/ru/daemonlord/messenger/core/token/StoredAccount.kt new file mode 100644 index 0000000..de1ccff --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/core/token/StoredAccount.kt @@ -0,0 +1,11 @@ +package ru.daemonlord.messenger.core.token + +data class StoredAccount( + val userId: Long, + val email: String?, + val name: String, + val username: String?, + val avatarUrl: String?, + val lastActiveAt: Long, +) + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/core/token/TokenRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/core/token/TokenRepository.kt index 28a3d0c..b7cc92c 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/core/token/TokenRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/core/token/TokenRepository.kt @@ -4,7 +4,15 @@ import kotlinx.coroutines.flow.Flow interface TokenRepository { fun observeTokens(): Flow + fun observeAccounts(): Flow> + fun observeActiveUserId(): Flow suspend fun getTokens(): TokenBundle? + suspend fun getAccounts(): List + suspend fun getActiveUserId(): Long? suspend fun saveTokens(tokens: TokenBundle) + suspend fun upsertAccount(account: StoredAccount) + suspend fun switchAccount(userId: Long): Boolean + suspend fun removeAccount(userId: Long) suspend fun clearTokens() + suspend fun clearAllTokens() } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt index f7fbc19..8be0a0b 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt @@ -3,6 +3,7 @@ package ru.daemonlord.messenger.data.auth.repository import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import ru.daemonlord.messenger.core.token.TokenBundle +import ru.daemonlord.messenger.core.token.StoredAccount import ru.daemonlord.messenger.core.token.TokenRepository import ru.daemonlord.messenger.data.auth.api.AuthApiService import ru.daemonlord.messenger.data.auth.dto.AuthUserDto @@ -45,7 +46,13 @@ class NetworkAuthRepository @Inject constructor( ) ) pushTokenSyncManager.triggerBestEffortSync() - getMe() + when (val meResult = getMe()) { + is AppResult.Success -> { + tokenRepository.upsertAccount(meResult.data.toStoredAccount()) + meResult + } + is AppResult.Error -> meResult + } } catch (error: Throwable) { AppResult.Error(error.toAppError(mode = ApiErrorMode.LOGIN)) } @@ -76,6 +83,7 @@ class NetworkAuthRepository @Inject constructor( override suspend fun getMe(): AppResult = withContext(ioDispatcher) { try { val user = authApiService.me().toDomain() + tokenRepository.upsertAccount(user.toStoredAccount()) AppResult.Success(user) } catch (error: Throwable) { AppResult.Error(error.toAppError()) @@ -92,7 +100,10 @@ class NetworkAuthRepository @Inject constructor( } when (val meResult = getMe()) { - is AppResult.Success -> meResult + is AppResult.Success -> { + pushTokenSyncManager.triggerBestEffortSync() + meResult + } is AppResult.Error -> { if (meResult.reason is AppError.Unauthorized) { tokenRepository.clearTokens() @@ -150,6 +161,17 @@ class NetworkAuthRepository @Inject constructor( ) } + private fun AuthUser.toStoredAccount(): StoredAccount { + return StoredAccount( + userId = id, + email = email, + name = name, + username = username, + avatarUrl = avatarUrl, + lastActiveAt = System.currentTimeMillis(), + ) + } + private fun AuthSessionDto.toDomain(): AuthSession { return AuthSession( jti = jti, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountUiState.kt index ec2ea35..1431f98 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountUiState.kt @@ -14,6 +14,16 @@ data class AccountUiState( val twoFactorOtpAuthUrl: String? = null, val recoveryCodes: List = emptyList(), val recoveryCodesRemaining: Int? = null, + val activeUserId: Long? = null, + val storedAccounts: List = emptyList(), val message: String? = null, val errorMessage: String? = null, ) + +data class StoredAccountUi( + val userId: Long, + val title: String, + val subtitle: String, + val avatarUrl: String?, + val isActive: Boolean, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountViewModel.kt index 04e8c74..ad61272 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountViewModel.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import ru.daemonlord.messenger.core.token.TokenRepository import ru.daemonlord.messenger.domain.account.repository.AccountRepository import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppResult @@ -16,6 +17,7 @@ import javax.inject.Inject @HiltViewModel class AccountViewModel @Inject constructor( private val accountRepository: AccountRepository, + private val tokenRepository: TokenRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(AccountUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -30,12 +32,27 @@ class AccountViewModel @Inject constructor( val me = accountRepository.getMe() val sessions = accountRepository.listSessions() val blocked = accountRepository.listBlockedUsers() + val activeUserId = tokenRepository.getActiveUserId() + val storedAccounts = tokenRepository.getAccounts() _uiState.update { state -> state.copy( isLoading = false, profile = (me as? AppResult.Success)?.data ?: state.profile, sessions = (sessions as? AppResult.Success)?.data ?: state.sessions, blockedUsers = (blocked as? AppResult.Success)?.data ?: state.blockedUsers, + activeUserId = activeUserId, + storedAccounts = storedAccounts.map { account -> + StoredAccountUi( + userId = account.userId, + title = account.name.ifBlank { "User #${account.userId}" }, + subtitle = listOfNotNull( + account.username?.takeIf { it.isNotBlank() }?.let { "@$it" }, + account.email?.takeIf { it.isNotBlank() }, + ).joinToString(" • ").ifBlank { "User #${account.userId}" }, + avatarUrl = account.avatarUrl, + isActive = activeUserId == account.userId, + ) + }, errorMessage = listOf(me, sessions, blocked) .filterIsInstance() .firstOrNull() @@ -46,6 +63,26 @@ class AccountViewModel @Inject constructor( } } + fun switchStoredAccount(userId: Long, onSwitched: (Boolean) -> Unit = {}) { + viewModelScope.launch { + val switched = tokenRepository.switchAccount(userId) + if (!switched) { + _uiState.update { it.copy(errorMessage = "No saved session for this account.") } + onSwitched(false) + return@launch + } + refresh() + onSwitched(true) + } + } + + fun removeStoredAccount(userId: Long) { + viewModelScope.launch { + tokenRepository.removeAccount(userId) + refresh() + } + } + fun updateProfile( name: String, username: String, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthViewModel.kt index 7c03ce4..ce2fa00 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthViewModel.kt @@ -86,6 +86,10 @@ class AuthViewModel @Inject constructor( } } + fun recheckSession() { + restoreSession() + } + private fun restoreSession() { viewModelScope.launch { _uiState.update { it.copy(isCheckingSession = true) } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt index cb6cf88..1755baa 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt @@ -241,6 +241,13 @@ fun MessengerNavHost( SettingsRoute( onBackToChats = { navController.navigate(Routes.Chats) }, onOpenProfile = { navController.navigate(Routes.Profile) }, + onSwitchAccount = { + viewModel.recheckSession() + navController.navigate(Routes.Chats) { + popUpTo(Routes.Chats) { inclusive = true } + launchSingleTop = true + } + }, onLogout = viewModel::logout, onMainBarVisibilityChanged = { isMainBarVisible = it }, ) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt index 7cdff04..2cc343f 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt @@ -71,6 +71,7 @@ private val SettingsMuted = Color(0xFF9EA3B0) fun SettingsRoute( onBackToChats: () -> Unit, onOpenProfile: () -> Unit, + onSwitchAccount: () -> Unit, onLogout: () -> Unit, onMainBarVisibilityChanged: (Boolean) -> Unit, viewModel: AccountViewModel = hiltViewModel(), @@ -78,6 +79,7 @@ fun SettingsRoute( SettingsScreen( onBackToChats = onBackToChats, onOpenProfile = onOpenProfile, + onSwitchAccount = onSwitchAccount, onLogout = onLogout, onMainBarVisibilityChanged = onMainBarVisibilityChanged, viewModel = viewModel, @@ -88,6 +90,7 @@ fun SettingsRoute( fun SettingsScreen( onBackToChats: () -> Unit, onOpenProfile: () -> Unit, + onSwitchAccount: () -> Unit, onLogout: () -> Unit, onMainBarVisibilityChanged: (Boolean) -> Unit, viewModel: AccountViewModel, @@ -203,6 +206,73 @@ fun SettingsScreen( } } + SettingsSectionCard { + Text("Accounts", style = MaterialTheme.typography.titleSmall, color = Color.White) + state.storedAccounts.forEach { account -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(Color(0x141F232B)) + .padding(horizontal = 10.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (!account.avatarUrl.isNullOrBlank()) { + AsyncImage( + model = account.avatarUrl, + contentDescription = null, + modifier = Modifier.size(34.dp).clip(CircleShape), + ) + } else { + Box( + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .background(Color(0xFF2A2F38)), + contentAlignment = Alignment.Center, + ) { + Text( + text = account.title.firstOrNull()?.uppercase() ?: "?", + color = Color.White, + style = MaterialTheme.typography.labelMedium, + ) + } + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = account.title, + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = account.subtitle, + color = SettingsMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (account.isActive) { + Text("Active", color = Color(0xFF9EDB9D), style = MaterialTheme.typography.labelSmall) + } else { + OutlinedButton( + onClick = { + viewModel.switchStoredAccount(account.userId) { switched -> + if (switched) onSwitchAccount() + } + }, + ) { + Text("Switch") + } + } + OutlinedButton(onClick = { viewModel.removeStoredAccount(account.userId) }) { + Text("Remove") + } + } + } + } + SettingsSectionCard { SettingsRow(Icons.Filled.Person, "Account", "Name, username, bio") { onOpenProfile() } SettingsRow(Icons.Filled.ChatBubbleOutline, "Chat settings", "Wallpaper, media, interactions") {}