Persist biometric access

This commit is contained in:
2026-04-23 10:54:19 -05:00
parent ae5649f408
commit 8194c4dee4
18 changed files with 269 additions and 55 deletions
+2 -2
View File
@@ -18,8 +18,8 @@ android {
applicationId "net.linkedcorp.linkedsign"
minSdk 26
targetSdk 35
versionCode 10103
versionName "1.1.3"
versionCode 10200
versionName "1.2.0"
buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\""
}
@@ -29,12 +29,21 @@ interface LinkedSignApiService {
@POST("auth/refresh")
suspend fun refresh(@Body body: RefreshRequest): ApiEnvelope<RefreshData>
@POST("auth/biometric-login")
suspend fun biometricLogin(@Body body: BiometricTokenRequest): ApiEnvelope<BiometricLoginData>
@POST("auth/logout")
suspend fun logout(@Body body: RefreshRequest): ApiEnvelope<SimpleMessageData>
@GET("auth/me")
suspend fun me(@Header("Authorization") authorization: String): ApiEnvelope<MeData>
@POST("auth/biometric-token")
suspend fun issueBiometricToken(@Header("Authorization") authorization: String): ApiEnvelope<BiometricTokenData>
@DELETE("auth/biometric-token")
suspend fun revokeBiometricToken(@Header("Authorization") authorization: String): ApiEnvelope<SimpleMessageData>
@PATCH("auth/change-password")
suspend fun changePassword(
@Header("Authorization") authorization: String,
@@ -77,7 +77,6 @@ class LinkedSignRepository(private val context: Context) {
)
sessionStore.save(session)
localCache.saveProfile(response.user)
sessionStore.updateBiometricSession(response.user.email, response.refreshToken)
session
}
}
@@ -110,41 +109,52 @@ class LinkedSignRepository(private val context: Context) {
}
suspend fun biometricLogin(): SessionSnapshot {
val biometric = sessionStore.loadBiometricRefreshToken()
val biometric = sessionStore.loadBiometricToken()
?: throw AppException("No hay sesión biométrica guardada.")
return execute {
val refresh = unwrap(service.refresh(RefreshRequest(biometric.second)))
val user = unwrap(service.me("Bearer ${refresh.accessToken}")).user
val session = SessionSnapshot(
accessToken = refresh.accessToken,
refreshToken = refresh.refreshToken,
user = user
)
sessionStore.save(session)
localCache.saveProfile(user)
sessionStore.updateBiometricSession(user.email, refresh.refreshToken)
session
return try {
execute {
val response = unwrap(service.biometricLogin(BiometricTokenRequest(biometric.second)))
val session = SessionSnapshot(
accessToken = response.accessToken,
refreshToken = response.refreshToken,
user = response.user
)
sessionStore.save(session)
localCache.saveProfile(response.user)
sessionStore.updateBiometricSession(response.user.email, response.biometricToken)
session
}
} catch (error: AppException) {
if (error.code == "BIOMETRIC_EXPIRED" || error.code == "BIOMETRIC_INVALIDATED") {
sessionStore.declineBiometricAccess()
}
throw error
}
}
fun biometricState(): BiometricAccessState = sessionStore.loadBiometricState()
fun enableBiometricAccess() {
suspend fun enableBiometricAccess() {
val session = sessionStore.load()
val user = session.user ?: throw AppException("Primero debes iniciar sesión.")
val refreshToken = session.refreshToken ?: throw AppException("No hay sesión para activar biometría.")
sessionStore.saveBiometricAccess(user.email, refreshToken)
val accessToken = session.accessToken ?: throw AppException("No hay sesión para activar biometría.")
val response = execute {
unwrap(service.issueBiometricToken("Bearer $accessToken"))
}
sessionStore.saveBiometricAccess(user.email, response.biometricToken)
}
fun declineBiometricAccess() {
sessionStore.declineBiometricAccess()
}
fun syncBiometricSession() {
suspend fun declineBiometricAccess() {
val session = sessionStore.load()
val user = session.user ?: return
val refreshToken = session.refreshToken ?: return
sessionStore.updateBiometricSession(user.email, refreshToken)
val accessToken = session.accessToken
if (!accessToken.isNullOrBlank()) {
runCatching {
execute {
unwrap(service.revokeBiometricToken("Bearer $accessToken"))
}
}
}
sessionStore.declineBiometricAccess()
}
suspend fun requestPasswordChange(currentPassword: String, newPassword: String): SimpleMessageData =
@@ -243,7 +253,6 @@ class LinkedSignRepository(private val context: Context) {
refreshToken = refresh.refreshToken
)
sessionStore.save(updated)
sessionStore.updateBiometricSession(updated.user?.email ?: session.user?.email.orEmpty(), refresh.refreshToken)
return updated
}
@@ -43,6 +43,21 @@ data class LoginData(
val user: SessionUser
)
data class BiometricTokenRequest(
val biometricToken: String
)
data class BiometricTokenData(
val biometricToken: String
)
data class BiometricLoginData(
val accessToken: String,
val refreshToken: String,
val biometricToken: String,
val user: SessionUser
)
data class RegisterData(
val message: String?,
val userId: Int?
@@ -75,22 +75,22 @@ class SessionStore(context: Context) {
val enabled = preferences.getBoolean(KEY_BIO_ENABLED, false)
val configured = preferences.getBoolean(KEY_BIO_CONFIGURED, false)
val email = preferences.getString(KEY_BIO_EMAIL, null)
val refreshToken = preferences.getString(KEY_BIO_REFRESH, null)
val biometricToken = preferences.getString(KEY_BIO_TOKEN, null)
return BiometricAccessState(
enabled = enabled,
configured = configured,
email = email,
hasCredentials = !email.isNullOrBlank() && !refreshToken.isNullOrBlank()
hasCredentials = !email.isNullOrBlank() && !biometricToken.isNullOrBlank()
)
}
fun saveBiometricAccess(email: String, refreshToken: String) {
fun saveBiometricAccess(email: String, biometricToken: String) {
preferences.edit()
.putBoolean(KEY_BIO_ENABLED, true)
.putBoolean(KEY_BIO_CONFIGURED, true)
.putString(KEY_BIO_EMAIL, email)
.putString(KEY_BIO_REFRESH, refreshToken)
.putString(KEY_BIO_TOKEN, biometricToken)
.apply()
}
@@ -99,28 +99,28 @@ class SessionStore(context: Context) {
.putBoolean(KEY_BIO_ENABLED, false)
.putBoolean(KEY_BIO_CONFIGURED, true)
.remove(KEY_BIO_EMAIL)
.remove(KEY_BIO_REFRESH)
.remove(KEY_BIO_TOKEN)
.apply()
}
fun updateBiometricSession(email: String, refreshToken: String) {
fun updateBiometricSession(email: String, biometricToken: String) {
if (!preferences.getBoolean(KEY_BIO_ENABLED, false)) {
return
}
preferences.edit()
.putString(KEY_BIO_EMAIL, email)
.putString(KEY_BIO_REFRESH, refreshToken)
.putString(KEY_BIO_TOKEN, biometricToken)
.apply()
}
fun loadBiometricRefreshToken(): Pair<String, String>? {
fun loadBiometricToken(): Pair<String, String>? {
val email = preferences.getString(KEY_BIO_EMAIL, null)
val refreshToken = preferences.getString(KEY_BIO_REFRESH, null)
if (email.isNullOrBlank() || refreshToken.isNullOrBlank()) {
val biometricToken = preferences.getString(KEY_BIO_TOKEN, null)
if (email.isNullOrBlank() || biometricToken.isNullOrBlank()) {
return null
}
return email to refreshToken
return email to biometricToken
}
companion object {
@@ -131,7 +131,7 @@ class SessionStore(context: Context) {
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 KEY_BIO_TOKEN = "bio_token"
private const val DEFAULT_SESSION_MS = 15 * 60 * 1000L
}
}
@@ -353,7 +353,6 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
appUnlocked = true
} else {
authMode = AuthMode.Login
viewModel.declineBiometricAccess()
appUnlocked = true
}
},
@@ -455,8 +454,10 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
if (showBiometricOptIn) {
AlertDialog(
onDismissRequest = {
viewModel.declineBiometricAccess()
showBiometricOptIn = false
scope.launch {
viewModel.declineBiometricAccess()
showBiometricOptIn = false
}
},
title = { Text("Activar acceso biométrico") },
text = {
@@ -464,16 +465,23 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
},
confirmButton = {
Button(onClick = {
viewModel.enableBiometricAccess()
showBiometricOptIn = false
scope.launch {
runCatching { viewModel.enableBiometricAccess() }
.onSuccess { showBiometricOptIn = false }
.onFailure {
snackbarHostState.showSnackbar((it as? AppException)?.message ?: "No se pudo activar la huella.")
}
}
}) {
Text("Sí, usar huella")
}
},
dismissButton = {
OutlinedButton(onClick = {
viewModel.declineBiometricAccess()
showBiometricOptIn = false
scope.launch {
viewModel.declineBiometricAccess()
showBiometricOptIn = false
}
}) {
Text("Ahora no")
}
@@ -53,7 +53,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
suspend fun login(email: String, password: String) {
val session = repository.login(email, password)
repository.syncBiometricSession()
val (_, cache) = repository.loadLocalSnapshot()
uiState = buildState(session, cache, loading = false)
}
@@ -162,12 +161,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
suspend fun accessToken(): String? = repository.getFreshAccessToken()
fun enableBiometricAccess() {
suspend fun enableBiometricAccess() {
repository.enableBiometricAccess()
refreshState()
}
fun declineBiometricAccess() {
suspend fun declineBiometricAccess() {
repository.declineBiometricAccess()
refreshState()
}