From ca1ad57f7e3f9bccde75058dbf3fc1a5001dcec2 Mon Sep 17 00:00:00 2001 From: "Maurice L." Date: Fri, 27 Feb 2026 19:04:28 +0100 Subject: [PATCH] Implemented session validation and token refreshing Added a `SessionState` sealed class to track the connection status and integrated it into the `DashboardScreen`. The dashboard now triggers a session validation on launch, showing a loading indicator during the check. The `MainViewModel` has been updated with a `validateSession` function that: - Pings the server using the stored access token. - Attempts to refresh the token if a 401 Unauthorized response is received. - Updates the `TokenStorage` with new tokens upon successful refresh. - Redirects to logout if both tokens are invalid. Additionally, the `APIService` was expanded with `ping` and `refresh` endpoints, and the `AppDatabase` version was incremented. --- .../shap_planner/activities/MainActivity.kt | 102 +++++++++++------- .../shap_planner/network/APIService.kt | 11 +- .../shap_planner/network/SessionState.kt | 8 ++ .../shap_planner/room/AppDatabase.kt | 2 +- .../shap_planner/viewmodels/MainViewModel.kt | 51 +++++++++ 5 files changed, 136 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/de/miaurizius/shap_planner/network/SessionState.kt diff --git a/app/src/main/java/de/miaurizius/shap_planner/activities/MainActivity.kt b/app/src/main/java/de/miaurizius/shap_planner/activities/MainActivity.kt index 5078260..7b4141f 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/activities/MainActivity.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/activities/MainActivity.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextField @@ -43,6 +44,7 @@ import androidx.compose.ui.unit.dp import de.miaurizius.shap_planner.TokenStorage import de.miaurizius.shap_planner.UserPreferences import de.miaurizius.shap_planner.entities.Account +import de.miaurizius.shap_planner.network.SessionState import de.miaurizius.shap_planner.room.AppDatabase import de.miaurizius.shap_planner.ui.theme.ShapPlannerTheme import de.miaurizius.shap_planner.viewmodels.LoginViewModel @@ -97,7 +99,10 @@ class MainActivity : ComponentActivity() { DashboardScreen( account = selectedAccount, onBack = { mainViewModel.logoutFromAccount() }, - onDelete = { mainViewModel.deleteAccount(selectedAccount) } + onDelete = { mainViewModel.deleteAccount(selectedAccount) }, + sessionState = mainViewModel.sessionState, + onValidate = { mainViewModel.validateSession(selectedAccount) }, + onSessionInvalid = { mainViewModel.logoutFromAccount() } ) } @@ -208,48 +213,73 @@ fun LoginScreen(onLogin: (String, String, String) -> Unit, onBack: (() -> Unit)? } @Composable -fun DashboardScreen(account: Account, onBack: () -> Unit, onDelete: () -> Unit) { +fun DashboardScreen( + account: Account, + onBack: () -> Unit, + onDelete: () -> Unit, + sessionState: SessionState, + onValidate: () -> Unit, + onSessionInvalid: () -> Unit) { - BackHandler { - onBack() - } + LaunchedEffect(Unit) { onValidate() } - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp) - .statusBarsPadding() - .navigationBarsPadding() - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column { - Text(text = "Hallo, ${account.name}!", style = MaterialTheme.typography.headlineMedium) - Text(text = "WG: ${account.wgName}", style = MaterialTheme.typography.bodyLarge, color = Color.Gray) - } - Button(onClick = onBack) { - Text("Wechseln") + when (sessionState) { + SessionState.Loading -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() } } + SessionState.Valid -> { + BackHandler { + onBack() + } + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + .statusBarsPadding() + .navigationBarsPadding() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text(text = "Hallo, ${account.name}!", style = MaterialTheme.typography.headlineMedium) + Text(text = "WG: ${account.wgName}", style = MaterialTheme.typography.bodyLarge, color = Color.Gray) + } + Button(onClick = onBack) { + Text("Wechseln") + } + } - Spacer(modifier = Modifier.height(5.dp)) + Spacer(modifier = Modifier.height(5.dp)) - Button(onClick = onDelete) { - Text("Löschen") + Button(onClick = onDelete) { + Text("Löschen") + } + + Spacer(modifier = Modifier.height(10.dp)) + + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.medium), + contentAlignment = Alignment.Center + ) { + Text("Hier kommen bald deine WG-Kosten hin 🚀") + } + } } - - Spacer(modifier = Modifier.height(10.dp)) - - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surfaceVariant, shape = MaterialTheme.shapes.medium), - contentAlignment = Alignment.Center - ) { - Text("Hier kommen bald deine WG-Kosten hin 🚀") + SessionState.Invalid -> { + LaunchedEffect(Unit) { + onSessionInvalid() + } + } + is SessionState.Error -> { + Text("Server error") } } + } \ No newline at end of file diff --git a/app/src/main/java/de/miaurizius/shap_planner/network/APIService.kt b/app/src/main/java/de/miaurizius/shap_planner/network/APIService.kt index 4fbafc4..f292955 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/network/APIService.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/network/APIService.kt @@ -2,16 +2,25 @@ package de.miaurizius.shap_planner.network import retrofit2.Response import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Headers import retrofit2.http.POST data class LoginRequest(val username: String, val password: String) data class LoginUser(val id: String, val username: String, val role: String, val avatarUrl: String?) data class LoginResponse(val access_token: String, val refresh_token: String, val user: LoginUser, val wgName: String) +data class RefreshRequest(val refreshToken: String) +data class RefreshResponse(val accessToken: String, val refreshToken: String) + interface APIService { @POST("api/login") suspend fun login(@Body req: LoginRequest): Response @POST("api/refresh") - suspend fun refresh(@Body req: Map): Response> + suspend fun refresh(@Body req: RefreshRequest): Response + + @GET("api/ping") + suspend fun ping(@Header("Authorization") token: String): Response> } \ No newline at end of file diff --git a/app/src/main/java/de/miaurizius/shap_planner/network/SessionState.kt b/app/src/main/java/de/miaurizius/shap_planner/network/SessionState.kt new file mode 100644 index 0000000..fa5b658 --- /dev/null +++ b/app/src/main/java/de/miaurizius/shap_planner/network/SessionState.kt @@ -0,0 +1,8 @@ +package de.miaurizius.shap_planner.network + +sealed class SessionState { + object Loading : SessionState() + object Valid : SessionState() + object Invalid : SessionState() + data class Error(val message: String) : SessionState() +} \ No newline at end of file diff --git a/app/src/main/java/de/miaurizius/shap_planner/room/AppDatabase.kt b/app/src/main/java/de/miaurizius/shap_planner/room/AppDatabase.kt index f3c3852..dbb3f7c 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/room/AppDatabase.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/room/AppDatabase.kt @@ -7,7 +7,7 @@ import androidx.room.RoomDatabase import de.miaurizius.shap_planner.entities.Account import de.miaurizius.shap_planner.entities.AccountDao -@Database(entities = [Account::class], version = 2) +@Database(entities = [Account::class], version = 3) abstract class AppDatabase : RoomDatabase() { abstract fun accountDao(): AccountDao companion object { diff --git a/app/src/main/java/de/miaurizius/shap_planner/viewmodels/MainViewModel.kt b/app/src/main/java/de/miaurizius/shap_planner/viewmodels/MainViewModel.kt index 6f42a0e..a7e5dcc 100644 --- a/app/src/main/java/de/miaurizius/shap_planner/viewmodels/MainViewModel.kt +++ b/app/src/main/java/de/miaurizius/shap_planner/viewmodels/MainViewModel.kt @@ -8,6 +8,9 @@ import androidx.lifecycle.viewModelScope import de.miaurizius.shap_planner.TokenStorage import de.miaurizius.shap_planner.entities.Account import de.miaurizius.shap_planner.entities.AccountDao +import de.miaurizius.shap_planner.network.RefreshRequest +import de.miaurizius.shap_planner.network.RetrofitProvider +import de.miaurizius.shap_planner.network.SessionState import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.stateIn @@ -19,6 +22,54 @@ class MainViewModel(private val accountDao: AccountDao, private val tokenStorage val accounts: StateFlow> = accountDao.getAllAccounts() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + var sessionState by mutableStateOf(SessionState.Loading) + private set + + fun validateSession(account: Account) { + viewModelScope.launch { + sessionState = SessionState.Loading + val api = RetrofitProvider.create(account.serverUrl) + + val accessToken = tokenStorage.getAccess(account.id.toString()) + val refreshToken = tokenStorage.getRefresh(account.id.toString()) + + if(accessToken == null || refreshToken == null) { + sessionState = SessionState.Invalid + return@launch + } + + println("Testing with AT $accessToken") + val pingResponse = api.ping("Bearer $accessToken") + + if(pingResponse.isSuccessful) { + sessionState = SessionState.Valid + return@launch + } + + if(pingResponse.code() == 401) { + println("Testing with RT $refreshToken") + val refreshResponse = api.refresh(RefreshRequest(refreshToken)) + + if(refreshResponse.isSuccessful) { + val newTokens = refreshResponse.body()!! + + tokenStorage.saveTokens( + account.id.toString(), + newTokens.accessToken, + newTokens.accessToken + ) + + sessionState = SessionState.Valid + return@launch + } else { + sessionState = SessionState.Invalid + return@launch + } + } + sessionState = SessionState.Error("Server error") + } + } + fun addAccount(account: Account) { viewModelScope.launch { accountDao.insertAccount(account)