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.
This commit is contained in:
2026-02-27 19:04:28 +01:00
parent 8ad212ee76
commit ca1ad57f7e
5 changed files with 136 additions and 38 deletions

View File

@@ -24,6 +24,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextField 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.TokenStorage
import de.miaurizius.shap_planner.UserPreferences import de.miaurizius.shap_planner.UserPreferences
import de.miaurizius.shap_planner.entities.Account 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.room.AppDatabase
import de.miaurizius.shap_planner.ui.theme.ShapPlannerTheme import de.miaurizius.shap_planner.ui.theme.ShapPlannerTheme
import de.miaurizius.shap_planner.viewmodels.LoginViewModel import de.miaurizius.shap_planner.viewmodels.LoginViewModel
@@ -97,7 +99,10 @@ class MainActivity : ComponentActivity() {
DashboardScreen( DashboardScreen(
account = selectedAccount, account = selectedAccount,
onBack = { mainViewModel.logoutFromAccount() }, onBack = { mainViewModel.logoutFromAccount() },
onDelete = { mainViewModel.deleteAccount(selectedAccount) } onDelete = { mainViewModel.deleteAccount(selectedAccount) },
sessionState = mainViewModel.sessionState,
onValidate = { mainViewModel.validateSession(selectedAccount) },
onSessionInvalid = { mainViewModel.logoutFromAccount() }
) )
} }
@@ -208,12 +213,26 @@ fun LoginScreen(onLogin: (String, String, String) -> Unit, onBack: (() -> Unit)?
} }
@Composable @Composable
fun DashboardScreen(account: Account, onBack: () -> Unit, onDelete: () -> Unit) { fun DashboardScreen(
account: Account,
onBack: () -> Unit,
onDelete: () -> Unit,
sessionState: SessionState,
onValidate: () -> Unit,
onSessionInvalid: () -> Unit) {
LaunchedEffect(Unit) { onValidate() }
when (sessionState) {
SessionState.Loading -> {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
SessionState.Valid -> {
BackHandler { BackHandler {
onBack() onBack()
} }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -252,4 +271,15 @@ fun DashboardScreen(account: Account, onBack: () -> Unit, onDelete: () -> Unit)
Text("Hier kommen bald deine WG-Kosten hin 🚀") Text("Hier kommen bald deine WG-Kosten hin 🚀")
} }
} }
}
SessionState.Invalid -> {
LaunchedEffect(Unit) {
onSessionInvalid()
}
}
is SessionState.Error -> {
Text("Server error")
}
}
} }

View File

@@ -2,16 +2,25 @@ package de.miaurizius.shap_planner.network
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.Headers
import retrofit2.http.POST import retrofit2.http.POST
data class LoginRequest(val username: String, val password: String) 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 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 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 { interface APIService {
@POST("api/login") @POST("api/login")
suspend fun login(@Body req: LoginRequest): Response<LoginResponse> suspend fun login(@Body req: LoginRequest): Response<LoginResponse>
@POST("api/refresh") @POST("api/refresh")
suspend fun refresh(@Body req: Map<String, String>): Response<Map<String, String>> suspend fun refresh(@Body req: RefreshRequest): Response<RefreshResponse>
@GET("api/ping")
suspend fun ping(@Header("Authorization") token: String): Response<Map<String, String>>
} }

View File

@@ -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()
}

View File

@@ -7,7 +7,7 @@ import androidx.room.RoomDatabase
import de.miaurizius.shap_planner.entities.Account import de.miaurizius.shap_planner.entities.Account
import de.miaurizius.shap_planner.entities.AccountDao import de.miaurizius.shap_planner.entities.AccountDao
@Database(entities = [Account::class], version = 2) @Database(entities = [Account::class], version = 3)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun accountDao(): AccountDao abstract fun accountDao(): AccountDao
companion object { companion object {

View File

@@ -8,6 +8,9 @@ import androidx.lifecycle.viewModelScope
import de.miaurizius.shap_planner.TokenStorage import de.miaurizius.shap_planner.TokenStorage
import de.miaurizius.shap_planner.entities.Account import de.miaurizius.shap_planner.entities.Account
import de.miaurizius.shap_planner.entities.AccountDao 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.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -19,6 +22,54 @@ class MainViewModel(private val accountDao: AccountDao, private val tokenStorage
val accounts: StateFlow<List<Account>> = accountDao.getAllAccounts() val accounts: StateFlow<List<Account>> = accountDao.getAllAccounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
var sessionState by mutableStateOf<SessionState>(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) { fun addAccount(account: Account) {
viewModelScope.launch { viewModelScope.launch {
accountDao.insertAccount(account) accountDao.insertAccount(account)