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 `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.
|
||||
|
||||
@@ -20,4 +20,8 @@ class ActiveChatTracker @Inject constructor() {
|
||||
_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 {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 setChatOverride(chatId: Long, mode: ChatNotificationOverride)
|
||||
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.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) }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -125,6 +125,7 @@ fun MessengerNavHost(
|
||||
ChatListRoute(
|
||||
inviteToken = inviteToken,
|
||||
onInviteTokenConsumed = onInviteTokenConsumed,
|
||||
onLogout = viewModel::logout,
|
||||
onOpenChat = { chatId ->
|
||||
navController.navigate("${Routes.Chat}/$chatId")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
chatOverrides.remove(chatId)
|
||||
}
|
||||
|
||||
override suspend fun clearChatOverrides() {
|
||||
chatOverrides.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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. Профиль и приватность
|
||||
- [ ] Просмотр/редактирование профиля
|
||||
|
||||
Reference in New Issue
Block a user