android: add multi-account switching foundation in settings
Some checks failed
Android CI / android (push) Failing after 4m43s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-09 23:44:53 +03:00
parent 19471ac736
commit daddbfd2a0
11 changed files with 513 additions and 22 deletions

View File

@@ -731,3 +731,19 @@
- primary profile info card, - primary profile info card,
- tab-like section (`Posts/Archived/Gifts`) with placeholder content, - tab-like section (`Posts/Archived/Gifts`) with placeholder content,
- inline edit card (name/username/bio/avatar URL) with existing save/upload behavior preserved. - 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.

View File

@@ -20,10 +20,40 @@ class DataStoreTokenRepository @Inject constructor(
preferences.toTokenBundleOrNull() preferences.toTokenBundleOrNull()
} }
override fun observeAccounts(): Flow<List<StoredAccount>> {
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<Long?> {
return observeTokens().map { it?.accessToken?.extractUserIdFromJwt() }
}
override suspend fun getTokens(): TokenBundle? { override suspend fun getTokens(): TokenBundle? {
return observeTokens().first() return observeTokens().first()
} }
override suspend fun getAccounts(): List<StoredAccount> {
return observeAccounts().first()
}
override suspend fun getActiveUserId(): Long? {
return observeActiveUserId().first()
}
override suspend fun saveTokens(tokens: TokenBundle) { override suspend fun saveTokens(tokens: TokenBundle) {
dataStore.edit { preferences -> dataStore.edit { preferences ->
preferences[ACCESS_TOKEN_KEY] = tokens.accessToken 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() { override suspend fun clearTokens() {
dataStore.edit { preferences -> dataStore.edit { preferences ->
preferences.remove(ACCESS_TOKEN_KEY) preferences.remove(ACCESS_TOKEN_KEY)
@@ -40,6 +84,10 @@ class DataStoreTokenRepository @Inject constructor(
} }
} }
override suspend fun clearAllTokens() {
clearTokens()
}
private fun Preferences.toTokenBundleOrNull(): TokenBundle? { private fun Preferences.toTokenBundleOrNull(): TokenBundle? {
val access = this[ACCESS_TOKEN_KEY] val access = this[ACCESS_TOKEN_KEY]
val refresh = this[REFRESH_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 { private companion object {
val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token") val ACCESS_TOKEN_KEY = stringPreferencesKey("access_token")
val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token") val REFRESH_TOKEN_KEY = stringPreferencesKey("refresh_token")

View File

@@ -1,9 +1,12 @@
package ru.daemonlord.messenger.core.token package ru.daemonlord.messenger.core.token
import android.content.SharedPreferences import android.content.SharedPreferences
import android.util.Base64
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONArray
import org.json.JSONObject
import ru.daemonlord.messenger.di.TokenPrefs import ru.daemonlord.messenger.di.TokenPrefs
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -13,48 +16,277 @@ class EncryptedPrefsTokenRepository @Inject constructor(
@TokenPrefs private val sharedPreferences: SharedPreferences, @TokenPrefs private val sharedPreferences: SharedPreferences,
) : TokenRepository { ) : TokenRepository {
private val tokensFlow = MutableStateFlow(readTokens()) private val tokensFlow = MutableStateFlow<TokenBundle?>(null)
private val accountsFlow = MutableStateFlow<List<StoredAccount>>(emptyList())
private val activeUserIdFlow = MutableStateFlow<Long?>(null)
init {
migrateLegacyIfNeeded()
refreshFlows()
}
override fun observeTokens(): Flow<TokenBundle?> = tokensFlow.asStateFlow() override fun observeTokens(): Flow<TokenBundle?> = tokensFlow.asStateFlow()
override fun observeAccounts(): Flow<List<StoredAccount>> = accountsFlow.asStateFlow()
override fun observeActiveUserId(): Flow<Long?> = activeUserIdFlow.asStateFlow()
override suspend fun getTokens(): TokenBundle? = tokensFlow.value override suspend fun getTokens(): TokenBundle? = tokensFlow.value
override suspend fun getAccounts(): List<StoredAccount> = accountsFlow.value
override suspend fun getActiveUserId(): Long? = activeUserIdFlow.value
override suspend fun saveTokens(tokens: TokenBundle) { override suspend fun saveTokens(tokens: TokenBundle) {
sharedPreferences.edit() val userId = tokens.accessToken.extractUserIdFromJwt()
.putString(ACCESS_TOKEN_KEY, tokens.accessToken) ?: activeUserIdFlow.value
.putString(REFRESH_TOKEN_KEY, tokens.refreshToken) ?: return
.putLong(SAVED_AT_KEY, tokens.savedAtMillis) val allTokens = readAllTokenEntries().toMutableMap()
.apply() allTokens[userId] = tokens
tokensFlow.value = 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() { 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() sharedPreferences.edit()
.remove(ACCESS_TOKEN_KEY) .remove(ACCESS_TOKEN_KEY)
.remove(REFRESH_TOKEN_KEY) .remove(REFRESH_TOKEN_KEY)
.remove(SAVED_AT_KEY) .remove(SAVED_AT_KEY)
.apply() .apply()
tokensFlow.value = null
} }
private fun readTokens(): TokenBundle? { private fun ensureAccountPlaceholder(userId: Long, lastActiveAt: Long) {
val access = sharedPreferences.getString(ACCESS_TOKEN_KEY, null) val accounts = readAccounts().associateBy { it.userId }.toMutableMap()
val refresh = sharedPreferences.getString(REFRESH_TOKEN_KEY, null) val existing = accounts[userId]
val savedAt = sharedPreferences.getLong(SAVED_AT_KEY, -1L) if (existing == null) {
if (access.isNullOrBlank() || refresh.isNullOrBlank() || savedAt <= 0L) { accounts[userId] = StoredAccount(
return null 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( writeAccounts(accounts.values.toList())
accessToken = access, }
refreshToken = refresh,
savedAtMillis = savedAt, 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<Long, TokenBundle> {
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<Long, TokenBundle>) {
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<StoredAccount> {
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<StoredAccount>) {
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 { private companion object {
const val ACCESS_TOKEN_KEY = "access_token" const val ACCESS_TOKEN_KEY = "access_token"
const val REFRESH_TOKEN_KEY = "refresh_token" const val REFRESH_TOKEN_KEY = "refresh_token"
const val SAVED_AT_KEY = "tokens_saved_at" 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"
} }
} }

View File

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

View File

@@ -4,7 +4,15 @@ import kotlinx.coroutines.flow.Flow
interface TokenRepository { interface TokenRepository {
fun observeTokens(): Flow<TokenBundle?> fun observeTokens(): Flow<TokenBundle?>
fun observeAccounts(): Flow<List<StoredAccount>>
fun observeActiveUserId(): Flow<Long?>
suspend fun getTokens(): TokenBundle? suspend fun getTokens(): TokenBundle?
suspend fun getAccounts(): List<StoredAccount>
suspend fun getActiveUserId(): Long?
suspend fun saveTokens(tokens: TokenBundle) 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 clearTokens()
suspend fun clearAllTokens()
} }

View File

@@ -3,6 +3,7 @@ package ru.daemonlord.messenger.data.auth.repository
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.daemonlord.messenger.core.token.TokenBundle 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.core.token.TokenRepository
import ru.daemonlord.messenger.data.auth.api.AuthApiService import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
@@ -45,7 +46,13 @@ class NetworkAuthRepository @Inject constructor(
) )
) )
pushTokenSyncManager.triggerBestEffortSync() pushTokenSyncManager.triggerBestEffortSync()
getMe() when (val meResult = getMe()) {
is AppResult.Success -> {
tokenRepository.upsertAccount(meResult.data.toStoredAccount())
meResult
}
is AppResult.Error -> meResult
}
} catch (error: Throwable) { } catch (error: Throwable) {
AppResult.Error(error.toAppError(mode = ApiErrorMode.LOGIN)) AppResult.Error(error.toAppError(mode = ApiErrorMode.LOGIN))
} }
@@ -76,6 +83,7 @@ class NetworkAuthRepository @Inject constructor(
override suspend fun getMe(): AppResult<AuthUser> = withContext(ioDispatcher) { override suspend fun getMe(): AppResult<AuthUser> = withContext(ioDispatcher) {
try { try {
val user = authApiService.me().toDomain() val user = authApiService.me().toDomain()
tokenRepository.upsertAccount(user.toStoredAccount())
AppResult.Success(user) AppResult.Success(user)
} catch (error: Throwable) { } catch (error: Throwable) {
AppResult.Error(error.toAppError()) AppResult.Error(error.toAppError())
@@ -92,7 +100,10 @@ class NetworkAuthRepository @Inject constructor(
} }
when (val meResult = getMe()) { when (val meResult = getMe()) {
is AppResult.Success -> meResult is AppResult.Success -> {
pushTokenSyncManager.triggerBestEffortSync()
meResult
}
is AppResult.Error -> { is AppResult.Error -> {
if (meResult.reason is AppError.Unauthorized) { if (meResult.reason is AppError.Unauthorized) {
tokenRepository.clearTokens() 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 { private fun AuthSessionDto.toDomain(): AuthSession {
return AuthSession( return AuthSession(
jti = jti, jti = jti,

View File

@@ -14,6 +14,16 @@ data class AccountUiState(
val twoFactorOtpAuthUrl: String? = null, val twoFactorOtpAuthUrl: String? = null,
val recoveryCodes: List<String> = emptyList(), val recoveryCodes: List<String> = emptyList(),
val recoveryCodesRemaining: Int? = null, val recoveryCodesRemaining: Int? = null,
val activeUserId: Long? = null,
val storedAccounts: List<StoredAccountUi> = emptyList(),
val message: String? = null, val message: String? = null,
val errorMessage: String? = null, val errorMessage: String? = null,
) )
data class StoredAccountUi(
val userId: Long,
val title: String,
val subtitle: String,
val avatarUrl: String?,
val isActive: Boolean,
)

View File

@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.domain.account.repository.AccountRepository import ru.daemonlord.messenger.domain.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.common.AppResult
@@ -16,6 +17,7 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class AccountViewModel @Inject constructor( class AccountViewModel @Inject constructor(
private val accountRepository: AccountRepository, private val accountRepository: AccountRepository,
private val tokenRepository: TokenRepository,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(AccountUiState()) private val _uiState = MutableStateFlow(AccountUiState())
val uiState: StateFlow<AccountUiState> = _uiState.asStateFlow() val uiState: StateFlow<AccountUiState> = _uiState.asStateFlow()
@@ -30,12 +32,27 @@ class AccountViewModel @Inject constructor(
val me = accountRepository.getMe() val me = accountRepository.getMe()
val sessions = accountRepository.listSessions() val sessions = accountRepository.listSessions()
val blocked = accountRepository.listBlockedUsers() val blocked = accountRepository.listBlockedUsers()
val activeUserId = tokenRepository.getActiveUserId()
val storedAccounts = tokenRepository.getAccounts()
_uiState.update { state -> _uiState.update { state ->
state.copy( state.copy(
isLoading = false, isLoading = false,
profile = (me as? AppResult.Success)?.data ?: state.profile, profile = (me as? AppResult.Success)?.data ?: state.profile,
sessions = (sessions as? AppResult.Success)?.data ?: state.sessions, sessions = (sessions as? AppResult.Success)?.data ?: state.sessions,
blockedUsers = (blocked as? AppResult.Success)?.data ?: state.blockedUsers, 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) errorMessage = listOf(me, sessions, blocked)
.filterIsInstance<AppResult.Error>() .filterIsInstance<AppResult.Error>()
.firstOrNull() .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( fun updateProfile(
name: String, name: String,
username: String, username: String,

View File

@@ -86,6 +86,10 @@ class AuthViewModel @Inject constructor(
} }
} }
fun recheckSession() {
restoreSession()
}
private fun restoreSession() { private fun restoreSession() {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isCheckingSession = true) } _uiState.update { it.copy(isCheckingSession = true) }

View File

@@ -241,6 +241,13 @@ fun MessengerNavHost(
SettingsRoute( SettingsRoute(
onBackToChats = { navController.navigate(Routes.Chats) }, onBackToChats = { navController.navigate(Routes.Chats) },
onOpenProfile = { navController.navigate(Routes.Profile) }, onOpenProfile = { navController.navigate(Routes.Profile) },
onSwitchAccount = {
viewModel.recheckSession()
navController.navigate(Routes.Chats) {
popUpTo(Routes.Chats) { inclusive = true }
launchSingleTop = true
}
},
onLogout = viewModel::logout, onLogout = viewModel::logout,
onMainBarVisibilityChanged = { isMainBarVisible = it }, onMainBarVisibilityChanged = { isMainBarVisible = it },
) )

View File

@@ -71,6 +71,7 @@ private val SettingsMuted = Color(0xFF9EA3B0)
fun SettingsRoute( fun SettingsRoute(
onBackToChats: () -> Unit, onBackToChats: () -> Unit,
onOpenProfile: () -> Unit, onOpenProfile: () -> Unit,
onSwitchAccount: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onMainBarVisibilityChanged: (Boolean) -> Unit, onMainBarVisibilityChanged: (Boolean) -> Unit,
viewModel: AccountViewModel = hiltViewModel(), viewModel: AccountViewModel = hiltViewModel(),
@@ -78,6 +79,7 @@ fun SettingsRoute(
SettingsScreen( SettingsScreen(
onBackToChats = onBackToChats, onBackToChats = onBackToChats,
onOpenProfile = onOpenProfile, onOpenProfile = onOpenProfile,
onSwitchAccount = onSwitchAccount,
onLogout = onLogout, onLogout = onLogout,
onMainBarVisibilityChanged = onMainBarVisibilityChanged, onMainBarVisibilityChanged = onMainBarVisibilityChanged,
viewModel = viewModel, viewModel = viewModel,
@@ -88,6 +90,7 @@ fun SettingsRoute(
fun SettingsScreen( fun SettingsScreen(
onBackToChats: () -> Unit, onBackToChats: () -> Unit,
onOpenProfile: () -> Unit, onOpenProfile: () -> Unit,
onSwitchAccount: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onMainBarVisibilityChanged: (Boolean) -> Unit, onMainBarVisibilityChanged: (Boolean) -> Unit,
viewModel: AccountViewModel, 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 { SettingsSectionCard {
SettingsRow(Icons.Filled.Person, "Account", "Name, username, bio") { onOpenProfile() } SettingsRow(Icons.Filled.Person, "Account", "Name, username, bio") { onOpenProfile() }
SettingsRow(Icons.Filled.ChatBubbleOutline, "Chat settings", "Wallpaper, media, interactions") {} SettingsRow(Icons.Filled.ChatBubbleOutline, "Chat settings", "Wallpaper, media, interactions") {}