android: add multi-account switching foundation in settings
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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 },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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") {}
|
||||||
|
|||||||
Reference in New Issue
Block a user