Fix session expiration handling

This commit is contained in:
2026-04-23 01:10:15 -05:00
parent da4b06094e
commit 820e335d9a
22 changed files with 308 additions and 51 deletions
+2 -2
View File
@@ -18,8 +18,8 @@ android {
applicationId "net.linkedcorp.linkedsign"
minSdk 26
targetSdk 35
versionCode 10002
versionName "1.0.2"
versionCode 10003
versionName "1.0.3"
buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\""
}
@@ -214,8 +214,13 @@ class LinkedSignRepository(private val context: Context) {
} catch (error: HttpException) {
if (error.code() != 401) throw mapHttpException(error)
val refreshToken = session.refreshToken ?: throw AppException("Tu sesión expiró. Ingresa nuevamente.")
val refresh = unwrap(service.refresh(RefreshRequest(refreshToken)))
val refreshToken = session.refreshToken ?: throwSessionExpired()
val refresh = try {
unwrap(service.refresh(RefreshRequest(refreshToken)))
} catch (refreshError: Exception) {
sessionStore.clearSession()
throwSessionExpired()
}
val updated = session.copy(
accessToken = refresh.accessToken,
refreshToken = refresh.refreshToken
@@ -226,6 +231,10 @@ class LinkedSignRepository(private val context: Context) {
}
}
private fun throwSessionExpired(): Nothing {
throw AppException("Tu sesión expiró. Vuelve a iniciar sesión.", "SESSION_EXPIRED")
}
private suspend fun <T> execute(block: suspend () -> T): T {
return try {
block()
@@ -156,7 +156,8 @@ data class SignData(
data class SessionSnapshot(
val accessToken: String? = null,
val refreshToken: String? = null,
val user: SessionUser? = null
val user: SessionUser? = null,
val expiresAt: Long? = null
)
data class BiometricAccessState(
@@ -3,8 +3,10 @@ package net.linkedcorp.linkedsign.data
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import android.util.Base64
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import org.json.JSONObject
class SessionStore(context: Context) {
private val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
@@ -26,15 +28,27 @@ class SessionStore(context: Context) {
val accessToken = preferences.getString(KEY_ACCESS, null)
val refreshToken = preferences.getString(KEY_REFRESH, null)
val userJson = preferences.getString(KEY_USER, null)
val expiresAt = preferences.getLong(KEY_EXPIRES_AT, 0L).takeIf { it > 0L }
if (expiresAt != null && expiresAt <= System.currentTimeMillis()) {
clearSession()
return SessionSnapshot()
}
val user = userJson?.let { userAdapter.fromJson(it) }
return SessionSnapshot(accessToken, refreshToken, user)
return SessionSnapshot(accessToken, refreshToken, user, expiresAt)
}
fun save(session: SessionSnapshot) {
val expiresAt = session.expiresAt
?: session.accessToken?.let { readAccessTokenExpiry(it) }
?: (System.currentTimeMillis() + DEFAULT_SESSION_MS)
preferences.edit()
.putString(KEY_ACCESS, session.accessToken)
.putString(KEY_REFRESH, session.refreshToken)
.putString(KEY_USER, session.user?.let { userAdapter.toJson(it) })
.putLong(KEY_EXPIRES_AT, expiresAt)
.apply()
}
@@ -47,9 +61,21 @@ class SessionStore(context: Context) {
.remove(KEY_ACCESS)
.remove(KEY_REFRESH)
.remove(KEY_USER)
.remove(KEY_EXPIRES_AT)
.apply()
}
private fun readAccessTokenExpiry(accessToken: String): Long? {
return try {
val payload = accessToken.split(".").getOrNull(1) ?: return null
val decoded = String(Base64.decode(payload, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING))
val exp = JSONObject(decoded).optLong("exp", 0L)
if (exp > 0L) exp * 1000L else null
} catch (_: Exception) {
null
}
}
fun loadBiometricState(): BiometricAccessState {
val enabled = preferences.getBoolean(KEY_BIO_ENABLED, false)
val configured = preferences.getBoolean(KEY_BIO_CONFIGURED, false)
@@ -106,9 +132,11 @@ class SessionStore(context: Context) {
private const val KEY_ACCESS = "access_token"
private const val KEY_REFRESH = "refresh_token"
private const val KEY_USER = "user"
private const val KEY_EXPIRES_AT = "expires_at"
private const val KEY_BIO_ENABLED = "bio_enabled"
private const val KEY_BIO_CONFIGURED = "bio_configured"
private const val KEY_BIO_EMAIL = "bio_email"
private const val KEY_BIO_REFRESH = "bio_refresh_token"
private const val DEFAULT_SESSION_MS = 15 * 60 * 1000L
}
}
@@ -189,6 +189,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
uiState = buildState(session, cache, loading = false, syncing = uiState.syncing)
}
private fun normalize(error: Exception): AppException =
if (error is AppException) error else AppException(error.message ?: "Ocurrió un error inesperado.")
private fun normalize(error: Exception): AppException {
if (error is AppException && error.code == "SESSION_EXPIRED") {
refreshState()
}
return if (error is AppException) error else AppException(error.message ?: "Ocurrió un error inesperado.")
}
}