From 575c0ad709a41249e8d97c36cd4d0a4662c38977 Mon Sep 17 00:00:00 2001 From: Jens Reinemann Date: Mon, 18 May 2026 00:55:25 +0200 Subject: [PATCH] feat: automatic forced logout on expired session When both access and refresh token are invalid (401 on /auth/refresh), the app now automatically logs out and navigates to Settings (login form). No data loss - only auth tokens are cleared, local inventory data is intact. - AuthEventBus: singleton SharedFlow that signals session expiry - MainViewModel: observes bus, calls logout + disconnect, navigates to Settings - MainScreen: LaunchedEffect collects navigateToSettings event - MessageRepositoryImpl: emits session expired when refresh fails - SyncServiceImpl: emits session expired when refresh fails --- .../data/repository/MessageRepositoryImpl.kt | 3 ++ .../bollwerk/app/data/sync/SyncServiceImpl.kt | 6 +++- .../de/bollwerk/app/domain/AuthEventBus.kt | 22 ++++++++++++ .../java/de/bollwerk/app/ui/MainScreen.kt | 12 +++++++ .../java/de/bollwerk/app/ui/MainViewModel.kt | 36 +++++++++++++++++++ 5 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/de/bollwerk/app/domain/AuthEventBus.kt create mode 100644 app/src/main/java/de/bollwerk/app/ui/MainViewModel.kt diff --git a/app/src/main/java/de/bollwerk/app/data/repository/MessageRepositoryImpl.kt b/app/src/main/java/de/bollwerk/app/data/repository/MessageRepositoryImpl.kt index 0fd6ffa..d2d5452 100644 --- a/app/src/main/java/de/bollwerk/app/data/repository/MessageRepositoryImpl.kt +++ b/app/src/main/java/de/bollwerk/app/data/repository/MessageRepositoryImpl.kt @@ -9,6 +9,7 @@ import de.bollwerk.app.data.sync.RefreshRequest import de.bollwerk.app.data.sync.WebSocketClient import de.bollwerk.app.data.sync.WebSocketEvent import de.bollwerk.app.di.ApplicationScope +import de.bollwerk.app.domain.AuthEventBus import de.bollwerk.app.domain.model.SettingsKey.StringKey import de.bollwerk.app.domain.model.SyncError import de.bollwerk.app.domain.repository.MessageRepository @@ -54,6 +55,7 @@ internal class MessageRepositoryImpl @Inject constructor( private val settingsRepository: SettingsRepository, private val webSocketClient: WebSocketClient, private val e2eeKeyManager: E2EEKeyManager, + private val authEventBus: AuthEventBus, @ApplicationScope private val scope: CoroutineScope ) : MessageRepository { @@ -222,6 +224,7 @@ internal class MessageRepositoryImpl @Inject constructor( settingsRepository.setString(StringKey.AuthRefreshToken, loginResponse.refreshToken) true } else { + authEventBus.notifySessionExpired() false } } catch (_: Exception) { diff --git a/app/src/main/java/de/bollwerk/app/data/sync/SyncServiceImpl.kt b/app/src/main/java/de/bollwerk/app/data/sync/SyncServiceImpl.kt index 0817fde..a28007f 100644 --- a/app/src/main/java/de/bollwerk/app/data/sync/SyncServiceImpl.kt +++ b/app/src/main/java/de/bollwerk/app/data/sync/SyncServiceImpl.kt @@ -1,5 +1,6 @@ package de.bollwerk.app.data.sync +import de.bollwerk.app.domain.AuthEventBus import de.bollwerk.app.domain.model.SettingsKey.StringKey import de.bollwerk.app.domain.model.SyncError import de.bollwerk.app.domain.repository.SettingsRepository @@ -29,7 +30,8 @@ import javax.inject.Inject internal class SyncServiceImpl @Inject constructor( private val httpClient: HttpClient, - private val settingsRepository: SettingsRepository + private val settingsRepository: SettingsRepository, + private val authEventBus: AuthEventBus ) : SyncService { override suspend fun downloadInventory(since: Long?): Result = @@ -139,6 +141,7 @@ internal class SyncServiceImpl @Inject constructor( if (newToken.isBlank()) return@withContext result block(serverUrl.trimEnd('/'), newToken) } else { + authEventBus.notifySessionExpired() result } } else { @@ -189,6 +192,7 @@ internal class SyncServiceImpl @Inject constructor( settingsRepository.setString(StringKey.AuthRefreshToken, loginResponse.refreshToken) true } else { + authEventBus.notifySessionExpired() false } } catch (_: Exception) { diff --git a/app/src/main/java/de/bollwerk/app/domain/AuthEventBus.kt b/app/src/main/java/de/bollwerk/app/domain/AuthEventBus.kt new file mode 100644 index 0000000..5f07755 --- /dev/null +++ b/app/src/main/java/de/bollwerk/app/domain/AuthEventBus.kt @@ -0,0 +1,22 @@ +package de.bollwerk.app.domain + +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Singleton-Bus für auth-kritische Ereignisse. + * Emittiert wenn sowohl Access- als auch Refresh-Token ungültig sind, + * damit die App den User automatisch ausloggt ohne Datenverlust. + */ +@Singleton +internal class AuthEventBus @Inject constructor() { + private val _sessionExpired = MutableSharedFlow(extraBufferCapacity = 1) + val sessionExpired: SharedFlow = _sessionExpired.asSharedFlow() + + fun notifySessionExpired() { + _sessionExpired.tryEmit(Unit) + } +} diff --git a/app/src/main/java/de/bollwerk/app/ui/MainScreen.kt b/app/src/main/java/de/bollwerk/app/ui/MainScreen.kt index ced67f1..27bf176 100644 --- a/app/src/main/java/de/bollwerk/app/ui/MainScreen.kt +++ b/app/src/main/java/de/bollwerk/app/ui/MainScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -37,6 +38,7 @@ import androidx.navigation.compose.rememberNavController import de.bollwerk.app.ui.inventory.InventoryPickerSheet import de.bollwerk.app.ui.inventory.InventoryPickerViewModel import de.bollwerk.app.ui.navigation.BollwerkNavGraph +import de.bollwerk.app.ui.navigation.Screen import de.bollwerk.app.ui.navigation.TopLevelDestination import de.bollwerk.app.ui.update.UpdateBanner import de.bollwerk.app.ui.update.UpdateStatus @@ -53,6 +55,16 @@ internal fun MainScreen() { currentDestination?.hasRoute(topLevel.route::class) == true } + val mainViewModel: MainViewModel = hiltViewModel() + LaunchedEffect(Unit) { + mainViewModel.navigateToSettings.collect { + navController.navigate(Screen.Settings) { + popUpTo(navController.graph.startDestinationId) { inclusive = false } + launchSingleTop = true + } + } + } + val inventoryPickerViewModel: InventoryPickerViewModel = hiltViewModel() val inventoryState by inventoryPickerViewModel.uiState.collectAsStateWithLifecycle() var isInventoryPickerVisible by remember { mutableStateOf(false) } diff --git a/app/src/main/java/de/bollwerk/app/ui/MainViewModel.kt b/app/src/main/java/de/bollwerk/app/ui/MainViewModel.kt new file mode 100644 index 0000000..1a6343e --- /dev/null +++ b/app/src/main/java/de/bollwerk/app/ui/MainViewModel.kt @@ -0,0 +1,36 @@ +package de.bollwerk.app.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import de.bollwerk.app.data.sync.WebSocketClient +import de.bollwerk.app.domain.AuthEventBus +import de.bollwerk.app.domain.repository.SyncService +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +internal class MainViewModel @Inject constructor( + private val authEventBus: AuthEventBus, + private val syncService: SyncService, + private val webSocketClient: WebSocketClient +) : ViewModel() { + + private val _navigateToSettings = MutableSharedFlow(extraBufferCapacity = 1) + val navigateToSettings: SharedFlow = _navigateToSettings.asSharedFlow() + + init { + viewModelScope.launch { + authEventBus.sessionExpired.collect { + // Tokens löschen (kein Datenverlust – nur Auth-Daten) + syncService.logout() + webSocketClient.disconnect() + // Navigation zu Settings (Login-Formular) + _navigateToSettings.emit(Unit) + } + } + } +}