android: add logout flow with full local session cleanup
Some checks are pending
CI / test (push) Has started running
Some checks are pending
CI / test (push) Has started running
This commit is contained in:
@@ -307,3 +307,9 @@
|
|||||||
- Added `DataStoreNotificationSettingsRepository` with persistence for global flags and per-chat override mode.
|
- Added `DataStoreNotificationSettingsRepository` with persistence for global flags and per-chat override mode.
|
||||||
- Added `ShouldShowMessageNotificationUseCase` and wired realtime notifications through it.
|
- Added `ShouldShowMessageNotificationUseCase` and wired realtime notifications through it.
|
||||||
- Added unit tests for DataStore notification settings repository and notification visibility use case.
|
- Added unit tests for DataStore notification settings repository and notification visibility use case.
|
||||||
|
|
||||||
|
### Step 51 - Logout with full local cleanup
|
||||||
|
- Added `LogoutUseCase` with centralized sign-out flow: disconnect realtime, clear active chat, clear auth session, and clear local cached data.
|
||||||
|
- Added `SessionCleanupRepository` + `DefaultSessionCleanupRepository` to wipe Room tables and clear per-chat notification overrides.
|
||||||
|
- Added logout action in chat list UI and wired it to `AuthViewModel`, with automatic navigation back to login via auth state.
|
||||||
|
- Added unit tests for logout use case orchestration and notification override cleanup.
|
||||||
|
|||||||
@@ -20,4 +20,8 @@ class ActiveChatTracker @Inject constructor() {
|
|||||||
_activeChatId.value = null
|
_activeChatId.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
_activeChatId.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package ru.daemonlord.messenger.data.auth.repository
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.daemonlord.messenger.data.chat.local.db.MessengerDatabase
|
||||||
|
import ru.daemonlord.messenger.di.IoDispatcher
|
||||||
|
import ru.daemonlord.messenger.domain.auth.repository.SessionCleanupRepository
|
||||||
|
import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class DefaultSessionCleanupRepository @Inject constructor(
|
||||||
|
private val database: MessengerDatabase,
|
||||||
|
private val notificationSettingsRepository: NotificationSettingsRepository,
|
||||||
|
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||||
|
) : SessionCleanupRepository {
|
||||||
|
|
||||||
|
override suspend fun clearLocalSessionData() = withContext(ioDispatcher) {
|
||||||
|
database.clearAllTables()
|
||||||
|
notificationSettingsRepository.clearChatOverrides()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -66,17 +66,25 @@ class DataStoreNotificationSettingsRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun clearChatOverrides() {
|
||||||
|
dataStore.edit { preferences ->
|
||||||
|
val keysToRemove = preferences.asMap().keys
|
||||||
|
.filter { key -> key.name.startsWith(CHAT_OVERRIDE_PREFIX) }
|
||||||
|
keysToRemove.forEach { key -> preferences.remove(key) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun Preferences.chatOverride(chatId: Long): ChatNotificationOverride {
|
private fun Preferences.chatOverride(chatId: Long): ChatNotificationOverride {
|
||||||
return this[chatOverrideKey(chatId)]
|
return this[chatOverrideKey(chatId)]
|
||||||
?.let { runCatching { ChatNotificationOverride.valueOf(it) }.getOrNull() }
|
?.let { runCatching { ChatNotificationOverride.valueOf(it) }.getOrNull() }
|
||||||
?: ChatNotificationOverride.DEFAULT
|
?: ChatNotificationOverride.DEFAULT
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun chatOverrideKey(chatId: Long) = stringPreferencesKey("notification_chat_override_$chatId")
|
private fun chatOverrideKey(chatId: Long) = stringPreferencesKey("$CHAT_OVERRIDE_PREFIX$chatId")
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
|
const val CHAT_OVERRIDE_PREFIX = "notification_chat_override_"
|
||||||
val GLOBAL_ENABLED_KEY = booleanPreferencesKey("notification_global_enabled")
|
val GLOBAL_ENABLED_KEY = booleanPreferencesKey("notification_global_enabled")
|
||||||
val PREVIEW_ENABLED_KEY = booleanPreferencesKey("notification_preview_enabled")
|
val PREVIEW_ENABLED_KEY = booleanPreferencesKey("notification_preview_enabled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import dagger.Module
|
|||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import ru.daemonlord.messenger.data.auth.repository.NetworkAuthRepository
|
import ru.daemonlord.messenger.data.auth.repository.NetworkAuthRepository
|
||||||
|
import ru.daemonlord.messenger.data.auth.repository.DefaultSessionCleanupRepository
|
||||||
import ru.daemonlord.messenger.data.chat.repository.NetworkChatRepository
|
import ru.daemonlord.messenger.data.chat.repository.NetworkChatRepository
|
||||||
import ru.daemonlord.messenger.data.media.repository.NetworkMediaRepository
|
import ru.daemonlord.messenger.data.media.repository.NetworkMediaRepository
|
||||||
import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository
|
import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository
|
||||||
import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository
|
import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository
|
||||||
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||||
|
import ru.daemonlord.messenger.domain.auth.repository.SessionCleanupRepository
|
||||||
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
|
||||||
import ru.daemonlord.messenger.domain.media.repository.MediaRepository
|
import ru.daemonlord.messenger.domain.media.repository.MediaRepository
|
||||||
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
|
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
|
||||||
@@ -26,6 +28,12 @@ abstract class RepositoryModule {
|
|||||||
repository: NetworkAuthRepository,
|
repository: NetworkAuthRepository,
|
||||||
): AuthRepository
|
): AuthRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindSessionCleanupRepository(
|
||||||
|
repository: DefaultSessionCleanupRepository,
|
||||||
|
): SessionCleanupRepository
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindChatRepository(
|
abstract fun bindChatRepository(
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.auth.repository
|
||||||
|
|
||||||
|
interface SessionCleanupRepository {
|
||||||
|
suspend fun clearLocalSessionData()
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.auth.usecase
|
||||||
|
|
||||||
|
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
|
||||||
|
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||||
|
import ru.daemonlord.messenger.domain.auth.repository.SessionCleanupRepository
|
||||||
|
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class LogoutUseCase @Inject constructor(
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
|
private val sessionCleanupRepository: SessionCleanupRepository,
|
||||||
|
private val realtimeManager: RealtimeManager,
|
||||||
|
private val activeChatTracker: ActiveChatTracker,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke() {
|
||||||
|
realtimeManager.disconnect()
|
||||||
|
activeChatTracker.clear()
|
||||||
|
authRepository.logout()
|
||||||
|
sessionCleanupRepository.clearLocalSessionData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -14,5 +14,5 @@ interface NotificationSettingsRepository {
|
|||||||
suspend fun getChatOverride(chatId: Long): ChatNotificationOverride
|
suspend fun getChatOverride(chatId: Long): ChatNotificationOverride
|
||||||
suspend fun setChatOverride(chatId: Long, mode: ChatNotificationOverride)
|
suspend fun setChatOverride(chatId: Long, mode: ChatNotificationOverride)
|
||||||
suspend fun clearChatOverride(chatId: Long)
|
suspend fun clearChatOverride(chatId: Long)
|
||||||
|
suspend fun clearChatOverrides()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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.domain.auth.usecase.LoginUseCase
|
import ru.daemonlord.messenger.domain.auth.usecase.LoginUseCase
|
||||||
|
import ru.daemonlord.messenger.domain.auth.usecase.LogoutUseCase
|
||||||
import ru.daemonlord.messenger.domain.auth.usecase.RestoreSessionUseCase
|
import ru.daemonlord.messenger.domain.auth.usecase.RestoreSessionUseCase
|
||||||
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
|
||||||
@@ -18,6 +19,7 @@ import javax.inject.Inject
|
|||||||
class AuthViewModel @Inject constructor(
|
class AuthViewModel @Inject constructor(
|
||||||
private val restoreSessionUseCase: RestoreSessionUseCase,
|
private val restoreSessionUseCase: RestoreSessionUseCase,
|
||||||
private val loginUseCase: LoginUseCase,
|
private val loginUseCase: LoginUseCase,
|
||||||
|
private val logoutUseCase: LogoutUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow(AuthUiState())
|
private val _uiState = MutableStateFlow(AuthUiState())
|
||||||
@@ -68,6 +70,22 @@ class AuthViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
||||||
|
runCatching { logoutUseCase() }
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
email = "",
|
||||||
|
password = "",
|
||||||
|
isLoading = false,
|
||||||
|
isAuthenticated = false,
|
||||||
|
errorMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun restoreSession() {
|
private fun restoreSession() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(isCheckingSession = true) }
|
_uiState.update { it.copy(isCheckingSession = true) }
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.material3.Tab
|
import androidx.compose.material3.Tab
|
||||||
import androidx.compose.material3.TabRow
|
import androidx.compose.material3.TabRow
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -50,6 +51,7 @@ import java.time.format.DateTimeFormatter
|
|||||||
@Composable
|
@Composable
|
||||||
fun ChatListRoute(
|
fun ChatListRoute(
|
||||||
onOpenChat: (Long) -> Unit,
|
onOpenChat: (Long) -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
inviteToken: String?,
|
inviteToken: String?,
|
||||||
onInviteTokenConsumed: () -> Unit,
|
onInviteTokenConsumed: () -> Unit,
|
||||||
viewModel: ChatListViewModel = hiltViewModel(),
|
viewModel: ChatListViewModel = hiltViewModel(),
|
||||||
@@ -72,6 +74,7 @@ fun ChatListRoute(
|
|||||||
onFilterSelected = viewModel::onFilterSelected,
|
onFilterSelected = viewModel::onFilterSelected,
|
||||||
onSearchChanged = viewModel::onSearchChanged,
|
onSearchChanged = viewModel::onSearchChanged,
|
||||||
onRefresh = viewModel::onPullToRefresh,
|
onRefresh = viewModel::onPullToRefresh,
|
||||||
|
onLogout = onLogout,
|
||||||
onOpenChat = onOpenChat,
|
onOpenChat = onOpenChat,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -84,6 +87,7 @@ fun ChatListScreen(
|
|||||||
onFilterSelected: (ChatListFilter) -> Unit,
|
onFilterSelected: (ChatListFilter) -> Unit,
|
||||||
onSearchChanged: (String) -> Unit,
|
onSearchChanged: (String) -> Unit,
|
||||||
onRefresh: () -> Unit,
|
onRefresh: () -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
onOpenChat: (Long) -> Unit,
|
onOpenChat: (Long) -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -115,6 +119,16 @@ fun ChatListScreen(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
)
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
) {
|
||||||
|
TextButton(onClick = onLogout) {
|
||||||
|
Text("Logout")
|
||||||
|
}
|
||||||
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ fun MessengerNavHost(
|
|||||||
ChatListRoute(
|
ChatListRoute(
|
||||||
inviteToken = inviteToken,
|
inviteToken = inviteToken,
|
||||||
onInviteTokenConsumed = onInviteTokenConsumed,
|
onInviteTokenConsumed = onInviteTokenConsumed,
|
||||||
|
onLogout = viewModel::logout,
|
||||||
onOpenChat = { chatId ->
|
onOpenChat = { chatId ->
|
||||||
navController.navigate("${Routes.Chat}/$chatId")
|
navController.navigate("${Routes.Chat}/$chatId")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ class DataStoreNotificationSettingsRepositoryTest {
|
|||||||
assertTrue(mode == ChatNotificationOverride.DEFAULT)
|
assertTrue(mode == ChatNotificationOverride.DEFAULT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clearChatOverrides_removesAllPerChatModes() = runTest {
|
||||||
|
val repository = DataStoreNotificationSettingsRepository(createTestDataStore())
|
||||||
|
repository.setChatOverride(chatId = 1L, mode = ChatNotificationOverride.MUTED)
|
||||||
|
repository.setChatOverride(chatId = 2L, mode = ChatNotificationOverride.ENABLED)
|
||||||
|
|
||||||
|
repository.clearChatOverrides()
|
||||||
|
|
||||||
|
assertEquals(ChatNotificationOverride.DEFAULT, repository.getChatOverride(chatId = 1L))
|
||||||
|
assertEquals(ChatNotificationOverride.DEFAULT, repository.getChatOverride(chatId = 2L))
|
||||||
|
}
|
||||||
|
|
||||||
private fun createTestDataStore(): DataStore<Preferences> {
|
private fun createTestDataStore(): DataStore<Preferences> {
|
||||||
return InMemoryPreferencesDataStore()
|
return InMemoryPreferencesDataStore()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package ru.daemonlord.messenger.domain.auth.usecase
|
||||||
|
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
|
||||||
|
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
|
||||||
|
import ru.daemonlord.messenger.domain.auth.repository.SessionCleanupRepository
|
||||||
|
import ru.daemonlord.messenger.domain.common.AppResult
|
||||||
|
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import ru.daemonlord.messenger.domain.auth.model.AuthSession
|
||||||
|
import ru.daemonlord.messenger.domain.auth.model.AuthUser
|
||||||
|
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class LogoutUseCaseTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun invoke_disconnectsRealtime_clearsSessionAndAuth() = runTest {
|
||||||
|
val authRepository = FakeAuthRepository()
|
||||||
|
val cleanupRepository = FakeSessionCleanupRepository()
|
||||||
|
val realtimeManager = FakeRealtimeManager()
|
||||||
|
val activeChatTracker = ActiveChatTracker().apply { setActiveChat(42L) }
|
||||||
|
val useCase = LogoutUseCase(
|
||||||
|
authRepository = authRepository,
|
||||||
|
sessionCleanupRepository = cleanupRepository,
|
||||||
|
realtimeManager = realtimeManager,
|
||||||
|
activeChatTracker = activeChatTracker,
|
||||||
|
)
|
||||||
|
|
||||||
|
useCase()
|
||||||
|
|
||||||
|
assertEquals(true, realtimeManager.disconnected)
|
||||||
|
assertEquals(true, authRepository.loggedOut)
|
||||||
|
assertEquals(true, cleanupRepository.cleaned)
|
||||||
|
assertEquals(null, activeChatTracker.activeChatId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeAuthRepository : AuthRepository {
|
||||||
|
var loggedOut: Boolean = false
|
||||||
|
|
||||||
|
override suspend fun login(email: String, password: String): AppResult<AuthUser> {
|
||||||
|
return AppResult.Error(ru.daemonlord.messenger.domain.common.AppError.Unknown(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun refreshTokens(): AppResult<Unit> {
|
||||||
|
return AppResult.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getMe(): AppResult<AuthUser> {
|
||||||
|
return AppResult.Error(ru.daemonlord.messenger.domain.common.AppError.Unknown(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun restoreSession(): AppResult<AuthUser> {
|
||||||
|
return AppResult.Error(ru.daemonlord.messenger.domain.common.AppError.Unauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun listSessions(): AppResult<List<AuthSession>> {
|
||||||
|
return AppResult.Success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun revokeSession(jti: String): AppResult<Unit> {
|
||||||
|
return AppResult.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun revokeAllSessions(): AppResult<Unit> {
|
||||||
|
return AppResult.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun logout() {
|
||||||
|
loggedOut = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeSessionCleanupRepository : SessionCleanupRepository {
|
||||||
|
var cleaned: Boolean = false
|
||||||
|
|
||||||
|
override suspend fun clearLocalSessionData() {
|
||||||
|
cleaned = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class FakeRealtimeManager : RealtimeManager {
|
||||||
|
var disconnected: Boolean = false
|
||||||
|
private val eventsFlow = MutableStateFlow<RealtimeEvent>(RealtimeEvent.Ignored)
|
||||||
|
|
||||||
|
override val events: Flow<RealtimeEvent> = eventsFlow
|
||||||
|
|
||||||
|
override fun connect() = Unit
|
||||||
|
|
||||||
|
override fun disconnect() {
|
||||||
|
disconnected = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -87,6 +87,9 @@ class ShouldShowMessageNotificationUseCaseTest {
|
|||||||
override suspend fun clearChatOverride(chatId: Long) {
|
override suspend fun clearChatOverride(chatId: Long) {
|
||||||
chatOverrides.remove(chatId)
|
chatOverrides.remove(chatId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun clearChatOverrides() {
|
||||||
|
chatOverrides.clear()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
- [ ] Reset password flow
|
- [ ] Reset password flow
|
||||||
- [ ] Sessions list + revoke one/all
|
- [ ] Sessions list + revoke one/all
|
||||||
- [ ] 2FA TOTP + recovery codes
|
- [ ] 2FA TOTP + recovery codes
|
||||||
- [ ] Logout с полным cleanup local state
|
- [x] Logout с полным cleanup local state
|
||||||
|
|
||||||
## 5. Профиль и приватность
|
## 5. Профиль и приватность
|
||||||
- [ ] Просмотр/редактирование профиля
|
- [ ] Просмотр/редактирование профиля
|
||||||
|
|||||||
Reference in New Issue
Block a user