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
This commit is contained in:
Jens Reinemann 2026-05-18 00:55:25 +02:00
parent a14c40d756
commit 575c0ad709
5 changed files with 78 additions and 1 deletions

View file

@ -9,6 +9,7 @@ import de.bollwerk.app.data.sync.RefreshRequest
import de.bollwerk.app.data.sync.WebSocketClient import de.bollwerk.app.data.sync.WebSocketClient
import de.bollwerk.app.data.sync.WebSocketEvent import de.bollwerk.app.data.sync.WebSocketEvent
import de.bollwerk.app.di.ApplicationScope 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.SettingsKey.StringKey
import de.bollwerk.app.domain.model.SyncError import de.bollwerk.app.domain.model.SyncError
import de.bollwerk.app.domain.repository.MessageRepository import de.bollwerk.app.domain.repository.MessageRepository
@ -54,6 +55,7 @@ internal class MessageRepositoryImpl @Inject constructor(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val webSocketClient: WebSocketClient, private val webSocketClient: WebSocketClient,
private val e2eeKeyManager: E2EEKeyManager, private val e2eeKeyManager: E2EEKeyManager,
private val authEventBus: AuthEventBus,
@ApplicationScope private val scope: CoroutineScope @ApplicationScope private val scope: CoroutineScope
) : MessageRepository { ) : MessageRepository {
@ -222,6 +224,7 @@ internal class MessageRepositoryImpl @Inject constructor(
settingsRepository.setString(StringKey.AuthRefreshToken, loginResponse.refreshToken) settingsRepository.setString(StringKey.AuthRefreshToken, loginResponse.refreshToken)
true true
} else { } else {
authEventBus.notifySessionExpired()
false false
} }
} catch (_: Exception) { } catch (_: Exception) {

View file

@ -1,5 +1,6 @@
package de.bollwerk.app.data.sync 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.SettingsKey.StringKey
import de.bollwerk.app.domain.model.SyncError import de.bollwerk.app.domain.model.SyncError
import de.bollwerk.app.domain.repository.SettingsRepository import de.bollwerk.app.domain.repository.SettingsRepository
@ -29,7 +30,8 @@ import javax.inject.Inject
internal class SyncServiceImpl @Inject constructor( internal class SyncServiceImpl @Inject constructor(
private val httpClient: HttpClient, private val httpClient: HttpClient,
private val settingsRepository: SettingsRepository private val settingsRepository: SettingsRepository,
private val authEventBus: AuthEventBus
) : SyncService { ) : SyncService {
override suspend fun downloadInventory(since: Long?): Result<InventoryDto> = override suspend fun downloadInventory(since: Long?): Result<InventoryDto> =
@ -139,6 +141,7 @@ internal class SyncServiceImpl @Inject constructor(
if (newToken.isBlank()) return@withContext result if (newToken.isBlank()) return@withContext result
block(serverUrl.trimEnd('/'), newToken) block(serverUrl.trimEnd('/'), newToken)
} else { } else {
authEventBus.notifySessionExpired()
result result
} }
} else { } else {
@ -189,6 +192,7 @@ internal class SyncServiceImpl @Inject constructor(
settingsRepository.setString(StringKey.AuthRefreshToken, loginResponse.refreshToken) settingsRepository.setString(StringKey.AuthRefreshToken, loginResponse.refreshToken)
true true
} else { } else {
authEventBus.notifySessionExpired()
false false
} }
} catch (_: Exception) { } catch (_: Exception) {

View file

@ -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<Unit>(extraBufferCapacity = 1)
val sessionExpired: SharedFlow<Unit> = _sessionExpired.asSharedFlow()
fun notifySessionExpired() {
_sessionExpired.tryEmit(Unit)
}
}

View file

@ -21,6 +21,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember 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.InventoryPickerSheet
import de.bollwerk.app.ui.inventory.InventoryPickerViewModel import de.bollwerk.app.ui.inventory.InventoryPickerViewModel
import de.bollwerk.app.ui.navigation.BollwerkNavGraph 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.navigation.TopLevelDestination
import de.bollwerk.app.ui.update.UpdateBanner import de.bollwerk.app.ui.update.UpdateBanner
import de.bollwerk.app.ui.update.UpdateStatus import de.bollwerk.app.ui.update.UpdateStatus
@ -53,6 +55,16 @@ internal fun MainScreen() {
currentDestination?.hasRoute(topLevel.route::class) == true 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 inventoryPickerViewModel: InventoryPickerViewModel = hiltViewModel()
val inventoryState by inventoryPickerViewModel.uiState.collectAsStateWithLifecycle() val inventoryState by inventoryPickerViewModel.uiState.collectAsStateWithLifecycle()
var isInventoryPickerVisible by remember { mutableStateOf(false) } var isInventoryPickerVisible by remember { mutableStateOf(false) }

View file

@ -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<Unit>(extraBufferCapacity = 1)
val navigateToSettings: SharedFlow<Unit> = _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)
}
}
}
}