android: add multi-account switching foundation in settings
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -20,10 +20,40 @@ class DataStoreTokenRepository @Inject constructor(
|
||||
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? {
|
||||
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) {
|
||||
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")
|
||||
|
||||
@@ -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<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 observeAccounts(): Flow<List<StoredAccount>> = accountsFlow.asStateFlow()
|
||||
|
||||
override fun observeActiveUserId(): Flow<Long?> = activeUserIdFlow.asStateFlow()
|
||||
|
||||
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) {
|
||||
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
|
||||
}
|
||||
return TokenBundle(
|
||||
accessToken = access,
|
||||
refreshToken = refresh,
|
||||
savedAtMillis = savedAt,
|
||||
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))
|
||||
}
|
||||
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<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 {
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,15 @@ import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface TokenRepository {
|
||||
fun observeTokens(): Flow<TokenBundle?>
|
||||
fun observeAccounts(): Flow<List<StoredAccount>>
|
||||
fun observeActiveUserId(): Flow<Long?>
|
||||
suspend fun getTokens(): TokenBundle?
|
||||
suspend fun getAccounts(): List<StoredAccount>
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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<AuthUser> = 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,
|
||||
|
||||
@@ -14,6 +14,16 @@ data class AccountUiState(
|
||||
val twoFactorOtpAuthUrl: String? = null,
|
||||
val recoveryCodes: List<String> = emptyList(),
|
||||
val recoveryCodesRemaining: Int? = null,
|
||||
val activeUserId: Long? = null,
|
||||
val storedAccounts: List<StoredAccountUi> = 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,
|
||||
)
|
||||
|
||||
@@ -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<AccountUiState> = _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<AppResult.Error>()
|
||||
.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,
|
||||
|
||||
@@ -86,6 +86,10 @@ class AuthViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun recheckSession() {
|
||||
restoreSession()
|
||||
}
|
||||
|
||||
private fun restoreSession() {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isCheckingSession = true) }
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
|
||||
@@ -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") {}
|
||||
|
||||
Reference in New Issue
Block a user