Fix Android documents and idle session

This commit is contained in:
2026-04-23 10:12:00 -05:00
parent 9e0fbcaa0d
commit 622048b163
11 changed files with 168 additions and 104 deletions
@@ -21,6 +21,8 @@ import java.io.FileInputStream
import java.io.IOException
import java.util.Locale
private const val TOKEN_REFRESH_MARGIN_MS = 6 * 60 * 1000L
class LinkedSignRepository(private val context: Context) {
private val sessionStore = SessionStore(context)
private val localCache = LocalCache(context)
@@ -203,7 +205,16 @@ class LinkedSignRepository(private val context: Context) {
result
} }
fun getAccessToken(): String? = sessionStore.load().accessToken
suspend fun getFreshAccessToken(): String? {
val session = sessionStore.load()
if (session.accessToken.isNullOrBlank()) return null
if ((session.expiresAt ?: Long.MAX_VALUE) > System.currentTimeMillis() + TOKEN_REFRESH_MARGIN_MS) {
return session.accessToken
}
return refreshSession(session).accessToken
}
private suspend fun <T> withAuth(block: suspend (String) -> T): T {
val session = sessionStore.load()
@@ -214,23 +225,28 @@ class LinkedSignRepository(private val context: Context) {
} catch (error: HttpException) {
if (error.code() != 401) throw mapHttpException(error)
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
)
sessionStore.save(updated)
sessionStore.updateBiometricSession(updated.user?.email ?: session.user?.email.orEmpty(), refresh.refreshToken)
block("Bearer ${refresh.accessToken}")
val updated = refreshSession(session)
block("Bearer ${updated.accessToken}")
}
}
private suspend fun refreshSession(session: SessionSnapshot): SessionSnapshot {
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
)
sessionStore.save(updated)
sessionStore.updateBiometricSession(updated.user?.email ?: session.user?.email.orEmpty(), refresh.refreshToken)
return updated
}
private fun throwSessionExpired(): Nothing {
throw AppException("Tu sesión expiró. Vuelve a iniciar sesión.", "SESSION_EXPIRED")
}
@@ -30,11 +30,6 @@ class SessionStore(context: Context) {
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, expiresAt)
}
@@ -1,6 +1,5 @@
package net.linkedcorp.linkedsign.ui
import android.app.DownloadManager
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
@@ -9,7 +8,6 @@ import android.hardware.biometrics.BiometricPrompt
import android.net.Uri
import android.os.Build
import android.os.CancellationSignal
import android.os.Environment
import android.os.ParcelFileDescriptor
import android.widget.Toast
import androidx.activity.ComponentActivity
@@ -50,7 +48,6 @@ import androidx.compose.material.icons.outlined.Badge
import androidx.compose.material.icons.outlined.CheckCircle
import androidx.compose.material.icons.outlined.Dashboard
import androidx.compose.material.icons.outlined.Description
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.Draw
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.Fingerprint
@@ -120,6 +117,7 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.layout.ColumnScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -224,7 +222,23 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo
}
}
LaunchedEffect(state.authenticated) {
while (state.authenticated) {
delay(60 * 1000L)
runCatching { viewModel.keepSessionAliveIfActive() }
}
}
Scaffold(
modifier = Modifier.pointerInput(state.authenticated) {
if (!state.authenticated) return@pointerInput
awaitPointerEventScope {
while (true) {
awaitPointerEvent()
viewModel.noteUserActivity()
}
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = SurfaceSoft,
topBar = {
@@ -628,7 +642,7 @@ private fun AppFlow(
onDeleteDocument: (UnifiedDocumentItem) -> Unit,
onSign: (Int, Int, String, String, String, Int, SignatureRect?) -> Unit,
onSelectDocumentToSign: (Int) -> Unit,
accessToken: () -> String?
accessToken: suspend () -> String?
) {
Box(
modifier = Modifier
@@ -866,7 +880,7 @@ private fun DashboardScreen(state: MainUiState, onSync: () -> Unit) {
@Composable
private fun DocumentsScreen(
state: MainUiState,
accessToken: () -> String?,
accessToken: suspend () -> String?,
appContext: Context,
onSync: () -> Unit,
onUploadDocument: (Uri, String) -> Unit,
@@ -875,6 +889,7 @@ private fun DocumentsScreen(
) {
val scope = rememberCoroutineScope()
var deleteTarget by remember { mutableStateOf<UnifiedDocumentItem?>(null) }
var showSignedDocuments by rememberSaveable { mutableStateOf(true) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri ?: return@rememberLauncherForActivityResult
try {
@@ -889,24 +904,30 @@ private fun DocumentsScreen(
onUploadDocument(uri, name)
}
val signedByDocumentId = state.signedDocuments.associateBy { it.documentId }
val unifiedDocuments = state.documents.map { document ->
val signed = signedByDocumentId[document.id]
val isSignedDocument = signed != null || document.status.equals("signed", ignoreCase = true)
val documentsById = state.documents.associateBy { it.id }
val signedDocuments = state.signedDocuments.mapNotNull { signed ->
val documentId = signed.documentId ?: return@mapNotNull null
val original = documentsById[documentId]
UnifiedDocumentItem(
documentId = documentId,
title = original?.title ?: original?.filename ?: signed.documentTitle ?: signed.signedFilename ?: "Documento firmado",
subtitle = signed.signedAt ?: "PDF firmado",
originalFilename = original?.filename ?: "${original?.title ?: "documento"}.pdf",
signedSignatureId = signed.id,
signedFilename = signed.signedFilename,
isSigned = true
)
}
val originalDocuments = state.documents.map { document ->
UnifiedDocumentItem(
documentId = document.id,
title = document.title ?: document.filename ?: "Documento",
subtitle = if (isSignedDocument) {
signed?.signedAt ?: "PDF firmado"
} else {
document.status ?: "PDF original"
},
subtitle = "PDF original",
originalFilename = document.filename ?: "${document.title ?: "documento"}.pdf",
signedSignatureId = signed?.id,
signedFilename = signed?.signedFilename,
isSigned = isSignedDocument
isSigned = false
)
}
val visibleDocuments = if (showSignedDocuments) signedDocuments else originalDocuments
LazyColumn(
modifier = Modifier.fillMaxSize(),
@@ -918,16 +939,26 @@ private fun DocumentsScreen(
FilledActionButton("Subir documento", Icons.Outlined.Article, modifier = Modifier.weight(1f)) {
launcher.launch(arrayOf("application/pdf"))
}
OutlinedActionButton(
if (showSignedDocuments) "Originales" else "Firmados",
Icons.Outlined.PictureAsPdf,
modifier = Modifier.weight(1f)
) {
showSignedDocuments = !showSignedDocuments
}
OutlinedActionButton("Actualizar", Icons.Outlined.Sync, modifier = Modifier.weight(1f), onClick = onSync)
}
}
item {
CompactSectionLabel("MIS DOCUMENTOS")
SectionCard(title = "Documentos") {
if (unifiedDocuments.isEmpty()) {
Text("No hay documentos sincronizados.", color = Color(0xFF6B7280))
CompactSectionLabel(if (showSignedDocuments) "DOCUMENTOS FIRMADOS" else "DOCUMENTOS ORIGINALES")
SectionCard(title = if (showSignedDocuments) "Firmados" else "Originales") {
if (visibleDocuments.isEmpty()) {
Text(
if (showSignedDocuments) "No hay documentos firmados sincronizados." else "No hay documentos originales sincronizados.",
color = Color(0xFF6B7280)
)
} else {
unifiedDocuments.forEach { item ->
visibleDocuments.forEach { item ->
DocumentRow(
title = item.title,
subtitle = item.subtitle,
@@ -955,23 +986,6 @@ private fun DocumentsScreen(
}
}
},
onDownload = {
if (item.isSigned && item.signedSignatureId != null) {
downloadFile(
appContext,
"${BuildConfig.API_BASE_URL}/signatures/${item.signedSignatureId}/download",
accessToken(),
item.signedFilename ?: "firmado.pdf"
)
} else {
downloadFile(
appContext,
"${BuildConfig.API_BASE_URL}/documents/${item.documentId}/download",
accessToken(),
item.originalFilename
)
}
},
onDelete = { deleteTarget = item }
)
}
@@ -1059,7 +1073,7 @@ private fun CertificatesScreen(
@Composable
private fun SignScreen(
state: MainUiState,
accessToken: () -> String?,
accessToken: suspend () -> String?,
appContext: Context,
initialDocumentId: String?,
onSign: (Int, Int, String, String, String, Int, SignatureRect?) -> Unit
@@ -1451,7 +1465,7 @@ private fun SectionCard(title: String, content: @Composable ColumnScope.() -> Un
}
@Composable
private fun DocumentRow(title: String, subtitle: String, onSelect: (() -> Unit)? = null, onView: () -> Unit, onDownload: () -> Unit, onDelete: () -> Unit) {
private fun DocumentRow(title: String, subtitle: String, onSelect: (() -> Unit)? = null, onView: () -> Unit, onDelete: () -> Unit) {
val isSigned = subtitle.contains("firmad", ignoreCase = true) || subtitle.contains("signed", ignoreCase = true)
val compactSubtitle = subtitle
.replace("signed", "", ignoreCase = true)
@@ -1520,9 +1534,6 @@ private fun DocumentRow(title: String, subtitle: String, onSelect: (() -> Unit)?
IconButton(onClick = onView, modifier = Modifier.size(28.dp)) {
Icon(Icons.Outlined.Visibility, contentDescription = "Ver documento", tint = Brand, modifier = Modifier.size(17.dp))
}
IconButton(onClick = onDownload, modifier = Modifier.size(28.dp)) {
Icon(Icons.Outlined.Download, contentDescription = "Descargar documento", tint = TextMuted, modifier = Modifier.size(17.dp))
}
IconButton(onClick = onDelete, modifier = Modifier.size(28.dp)) {
Icon(Icons.Outlined.Delete, contentDescription = "Eliminar documento", tint = Danger, modifier = Modifier.size(17.dp))
}
@@ -1813,21 +1824,6 @@ private fun queryFileName(context: Context, uri: Uri): String? {
}
}
private fun downloadFile(context: Context, url: String, accessToken: String?, fileName: String) {
val request = DownloadManager.Request(Uri.parse(url))
.setTitle(fileName)
.setDescription("Descargando archivo de LinkedSign")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
if (!accessToken.isNullOrBlank()) {
request.addRequestHeader("Authorization", "Bearer $accessToken")
}
val manager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
manager.enqueue(request)
}
private suspend fun openSecurePdf(context: Context, url: String, accessToken: String?, fileName: String) {
val targetFile = downloadSecuredFileToCache(context, url, accessToken, fileName)
@@ -36,6 +36,7 @@ data class MainUiState(
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val repository = LinkedSignRepository(application.applicationContext)
private var lastActivityAt = System.currentTimeMillis()
var uiState by mutableStateOf(MainUiState())
private set
@@ -137,13 +138,29 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
repository.requestPasswordChange(currentPassword, newPassword)
}
fun noteUserActivity() {
lastActivityAt = System.currentTimeMillis()
}
suspend fun keepSessionAliveIfActive() {
if (!uiState.authenticated) return
if (System.currentTimeMillis() - lastActivityAt > IDLE_SESSION_MS) return
try {
repository.getFreshAccessToken()
val (session, cache) = repository.loadLocalSnapshot()
uiState = buildState(session, cache, loading = false, syncing = uiState.syncing)
} catch (error: Exception) {
normalize(error)
}
}
suspend fun logout() {
repository.logout()
val (session, cache) = repository.loadLocalSnapshot()
uiState = buildState(session, cache, loading = false)
}
fun accessToken(): String? = repository.getAccessToken()
suspend fun accessToken(): String? = repository.getFreshAccessToken()
fun enableBiometricAccess() {
repository.enableBiometricAccess()
@@ -196,4 +213,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return if (error is AppException) error else AppException(error.message ?: "Ocurrió un error inesperado.")
}
companion object {
private const val IDLE_SESSION_MS = 15 * 60 * 1000L
}
}