android: add logout flow with full local session cleanup
Some checks are pending
CI / test (push) Has started running

This commit is contained in:
Codex
2026-03-09 15:09:10 +03:00
parent 33514265e3
commit d7dfda1d31
15 changed files with 230 additions and 5 deletions

View File

@@ -307,3 +307,9 @@
- Added `DataStoreNotificationSettingsRepository` with persistence for global flags and per-chat override mode.
- Added `ShouldShowMessageNotificationUseCase` and wired realtime notifications through it.
- 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.

View File

@@ -20,4 +20,8 @@ class ActiveChatTracker @Inject constructor() {
_activeChatId.value = null
}
}
fun clear() {
_activeChatId.value = null
}
}

View File

@@ -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()
}
}

View File

@@ -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 {
return this[chatOverrideKey(chatId)]
?.let { runCatching { ChatNotificationOverride.valueOf(it) }.getOrNull() }
?: 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 {
const val CHAT_OVERRIDE_PREFIX = "notification_chat_override_"
val GLOBAL_ENABLED_KEY = booleanPreferencesKey("notification_global_enabled")
val PREVIEW_ENABLED_KEY = booleanPreferencesKey("notification_preview_enabled")
}
}

View File

@@ -5,11 +5,13 @@ import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
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.media.repository.NetworkMediaRepository
import ru.daemonlord.messenger.data.message.repository.NetworkMessageRepository
import ru.daemonlord.messenger.data.notifications.repository.DataStoreNotificationSettingsRepository
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.media.repository.MediaRepository
import ru.daemonlord.messenger.domain.message.repository.MessageRepository
@@ -26,6 +28,12 @@ abstract class RepositoryModule {
repository: NetworkAuthRepository,
): AuthRepository
@Binds
@Singleton
abstract fun bindSessionCleanupRepository(
repository: DefaultSessionCleanupRepository,
): SessionCleanupRepository
@Binds
@Singleton
abstract fun bindChatRepository(

View File

@@ -0,0 +1,6 @@
package ru.daemonlord.messenger.domain.auth.repository
interface SessionCleanupRepository {
suspend fun clearLocalSessionData()
}

View File

@@ -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()
}
}

View File

@@ -14,5 +14,5 @@ interface NotificationSettingsRepository {
suspend fun getChatOverride(chatId: Long): ChatNotificationOverride
suspend fun setChatOverride(chatId: Long, mode: ChatNotificationOverride)
suspend fun clearChatOverride(chatId: Long)
suspend fun clearChatOverrides()
}

View File

@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
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.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
@@ -18,6 +19,7 @@ import javax.inject.Inject
class AuthViewModel @Inject constructor(
private val restoreSessionUseCase: RestoreSessionUseCase,
private val loginUseCase: LoginUseCase,
private val logoutUseCase: LogoutUseCase,
) : ViewModel() {
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() {
viewModelScope.launch {
_uiState.update { it.copy(isCheckingSession = true) }

View File

@@ -29,6 +29,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@@ -50,6 +51,7 @@ import java.time.format.DateTimeFormatter
@Composable
fun ChatListRoute(
onOpenChat: (Long) -> Unit,
onLogout: () -> Unit,
inviteToken: String?,
onInviteTokenConsumed: () -> Unit,
viewModel: ChatListViewModel = hiltViewModel(),
@@ -72,6 +74,7 @@ fun ChatListRoute(
onFilterSelected = viewModel::onFilterSelected,
onSearchChanged = viewModel::onSearchChanged,
onRefresh = viewModel::onPullToRefresh,
onLogout = onLogout,
onOpenChat = onOpenChat,
)
}
@@ -84,6 +87,7 @@ fun ChatListScreen(
onFilterSelected: (ChatListFilter) -> Unit,
onSearchChanged: (String) -> Unit,
onRefresh: () -> Unit,
onLogout: () -> Unit,
onOpenChat: (Long) -> Unit,
) {
Column(
@@ -115,6 +119,16 @@ fun ChatListScreen(
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.End,
) {
TextButton(onClick = onLogout) {
Text("Logout")
}
}
Row(
modifier = Modifier
.fillMaxWidth()

View File

@@ -125,6 +125,7 @@ fun MessengerNavHost(
ChatListRoute(
inviteToken = inviteToken,
onInviteTokenConsumed = onInviteTokenConsumed,
onLogout = viewModel::logout,
onOpenChat = { chatId ->
navController.navigate("${Routes.Chat}/$chatId")
}

View File

@@ -49,6 +49,18 @@ class DataStoreNotificationSettingsRepositoryTest {
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> {
return InMemoryPreferencesDataStore()
}

View File

@@ -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
}
}
}

View File

@@ -87,6 +87,9 @@ class ShouldShowMessageNotificationUseCaseTest {
override suspend fun clearChatOverride(chatId: Long) {
chatOverrides.remove(chatId)
}
override suspend fun clearChatOverrides() {
chatOverrides.clear()
}
}
}

View File

@@ -31,7 +31,7 @@
- [ ] Reset password flow
- [ ] Sessions list + revoke one/all
- [ ] 2FA TOTP + recovery codes
- [ ] Logout с полным cleanup local state
- [x] Logout с полным cleanup local state
## 5. Профиль и приватность
- [ ] Просмотр/редактирование профиля