diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 70977dc..22c38bf 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -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. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/ActiveChatTracker.kt b/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/ActiveChatTracker.kt index 5d63ade..00160e4 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/ActiveChatTracker.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/core/notifications/ActiveChatTracker.kt @@ -20,4 +20,8 @@ class ActiveChatTracker @Inject constructor() { _activeChatId.value = null } } + + fun clear() { + _activeChatId.value = null + } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/DefaultSessionCleanupRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/DefaultSessionCleanupRepository.kt new file mode 100644 index 0000000..9c6cebe --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/DefaultSessionCleanupRepository.kt @@ -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() + } +} + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/notifications/repository/DataStoreNotificationSettingsRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/notifications/repository/DataStoreNotificationSettingsRepository.kt index 1b7b1ae..24d09e0 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/notifications/repository/DataStoreNotificationSettingsRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/notifications/repository/DataStoreNotificationSettingsRepository.kt @@ -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") } } - diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt index 66dab50..bb8924b 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/RepositoryModule.kt @@ -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( diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/repository/SessionCleanupRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/repository/SessionCleanupRepository.kt new file mode 100644 index 0000000..189d8f2 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/repository/SessionCleanupRepository.kt @@ -0,0 +1,6 @@ +package ru.daemonlord.messenger.domain.auth.repository + +interface SessionCleanupRepository { + suspend fun clearLocalSessionData() +} + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/LogoutUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/LogoutUseCase.kt new file mode 100644 index 0000000..c905bde --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/auth/usecase/LogoutUseCase.kt @@ -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() + } +} + diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/notifications/repository/NotificationSettingsRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/notifications/repository/NotificationSettingsRepository.kt index abb7720..14b068a 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/notifications/repository/NotificationSettingsRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/notifications/repository/NotificationSettingsRepository.kt @@ -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() } - diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthViewModel.kt index 2dc4e93..b061400 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/AuthViewModel.kt @@ -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) } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt index d5cf038..1ff3e34 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt @@ -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() diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt index 7faff66..5ce8c2f 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/navigation/AppNavGraph.kt @@ -125,6 +125,7 @@ fun MessengerNavHost( ChatListRoute( inviteToken = inviteToken, onInviteTokenConsumed = onInviteTokenConsumed, + onLogout = viewModel::logout, onOpenChat = { chatId -> navController.navigate("${Routes.Chat}/$chatId") } diff --git a/android/app/src/test/java/ru/daemonlord/messenger/data/notifications/repository/DataStoreNotificationSettingsRepositoryTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/data/notifications/repository/DataStoreNotificationSettingsRepositoryTest.kt index 5f1dab2..8f2156c 100644 --- a/android/app/src/test/java/ru/daemonlord/messenger/data/notifications/repository/DataStoreNotificationSettingsRepositoryTest.kt +++ b/android/app/src/test/java/ru/daemonlord/messenger/data/notifications/repository/DataStoreNotificationSettingsRepositoryTest.kt @@ -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 { return InMemoryPreferencesDataStore() } diff --git a/android/app/src/test/java/ru/daemonlord/messenger/domain/auth/usecase/LogoutUseCaseTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/domain/auth/usecase/LogoutUseCaseTest.kt new file mode 100644 index 0000000..2c9fc26 --- /dev/null +++ b/android/app/src/test/java/ru/daemonlord/messenger/domain/auth/usecase/LogoutUseCaseTest.kt @@ -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 { + return AppResult.Error(ru.daemonlord.messenger.domain.common.AppError.Unknown(null)) + } + + override suspend fun refreshTokens(): AppResult { + return AppResult.Success(Unit) + } + + override suspend fun getMe(): AppResult { + return AppResult.Error(ru.daemonlord.messenger.domain.common.AppError.Unknown(null)) + } + + override suspend fun restoreSession(): AppResult { + return AppResult.Error(ru.daemonlord.messenger.domain.common.AppError.Unauthorized) + } + + override suspend fun listSessions(): AppResult> { + return AppResult.Success(emptyList()) + } + + override suspend fun revokeSession(jti: String): AppResult { + return AppResult.Success(Unit) + } + + override suspend fun revokeAllSessions(): AppResult { + 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.Ignored) + + override val events: Flow = eventsFlow + + override fun connect() = Unit + + override fun disconnect() { + disconnected = true + } + } +} + diff --git a/android/app/src/test/java/ru/daemonlord/messenger/domain/notifications/usecase/ShouldShowMessageNotificationUseCaseTest.kt b/android/app/src/test/java/ru/daemonlord/messenger/domain/notifications/usecase/ShouldShowMessageNotificationUseCaseTest.kt index d02424c..6863a4e 100644 --- a/android/app/src/test/java/ru/daemonlord/messenger/domain/notifications/usecase/ShouldShowMessageNotificationUseCaseTest.kt +++ b/android/app/src/test/java/ru/daemonlord/messenger/domain/notifications/usecase/ShouldShowMessageNotificationUseCaseTest.kt @@ -87,6 +87,9 @@ class ShouldShowMessageNotificationUseCaseTest { override suspend fun clearChatOverride(chatId: Long) { chatOverrides.remove(chatId) } + + override suspend fun clearChatOverrides() { + chatOverrides.clear() + } } } - diff --git a/docs/android-checklist.md b/docs/android-checklist.md index 7ae3393..895da2c 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -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. Профиль и приватность - [ ] Просмотр/редактирование профиля