From 622048b163fd561a091ccf1a8dfaf8ebfebe26cd Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Thu, 23 Apr 2026 10:12:00 -0500 Subject: [PATCH] Fix Android documents and idle session --- CHANGELOG.md | 9 +- VERSION | 2 +- android/app/build.gradle | 4 +- .../linkedsign/data/LinkedSignRepository.kt | 46 ++++--- .../linkedsign/data/SessionStore.kt | 5 - .../linkedcorp/linkedsign/ui/LinkedSignApp.kt | 114 +++++++++--------- .../linkedcorp/linkedsign/ui/MainViewModel.kt | 23 +++- backend/package.json | 2 +- frontend/package.json | 2 +- frontend/src/api/auth.js | 1 + frontend/src/contexts/AuthContext.jsx | 64 +++++++--- 11 files changed, 168 insertions(+), 104 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa451bd..53600a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,17 @@ ## Versión actual -- Versión base: `1.1.1` +- Versión base: `1.1.2` ## Historial +### 1.1.2 - 2026-04-23 + +- Se separa en Android la vista de documentos firmados y originales. +- Se deja una sola acción para abrir documentos en Android y se elimina la acción duplicada. +- Se renueva la sesión por actividad en web y Android para que expire tras inactividad. +- Se refresca el token Android antes de abrir documentos cuando está cerca de vencer. + ### 1.1.1 - 2026-04-23 - Se oculta la barra de navegación del sistema Android en modo inmersivo. diff --git a/VERSION b/VERSION index 524cb55..45a1b3f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.1 +1.1.2 diff --git a/android/app/build.gradle b/android/app/build.gradle index a79ab8a..5815c8a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -18,8 +18,8 @@ android { applicationId "net.linkedcorp.linkedsign" minSdk 26 targetSdk 35 - versionCode 10101 - versionName "1.1.1" + versionCode 10102 + versionName "1.1.2" buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\"" } diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt index 92cc5fe..e777c7a 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/LinkedSignRepository.kt @@ -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 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") } diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/SessionStore.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/SessionStore.kt index 502166d..4f24f8a 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/data/SessionStore.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/SessionStore.kt @@ -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) } diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/LinkedSignApp.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/LinkedSignApp.kt index 9ee2de2..9d9f599 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/LinkedSignApp.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/LinkedSignApp.kt @@ -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(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) diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt index a4dd751..7ce8598 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/ui/MainViewModel.kt @@ -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 + } } diff --git a/backend/package.json b/backend/package.json index f0d957f..3d223c4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "linkedsign-backend", - "version": "1.1.1", + "version": "1.1.2", "private": true, "description": "Initial backend for LinkedSign", "main": "src/server.js", diff --git a/frontend/package.json b/frontend/package.json index d9d3d49..6ee9a69 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.1.1", + "version": "1.1.2", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/auth.js b/frontend/src/api/auth.js index 74e0d3a..705197b 100644 --- a/frontend/src/api/auth.js +++ b/frontend/src/api/auth.js @@ -6,6 +6,7 @@ export const register = (data) => client.post('/auth/register', data, { }, }); export const login = (data) => client.post('/auth/login', data); +export const refreshSession = () => client.post('/auth/refresh', {}); export const logout = () => client.post('/auth/logout', {}); export const getMe = () => client.get('/auth/me'); export const verifyEmail = (token) => client.get(`/auth/verify/${token}`); diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx index af08459..3b5156b 100644 --- a/frontend/src/contexts/AuthContext.jsx +++ b/frontend/src/contexts/AuthContext.jsx @@ -1,31 +1,24 @@ -import { createContext, useContext, useState, useEffect, useCallback } from 'react'; -import { login as apiLogin, logout as apiLogout, getMe } from '../api/auth'; +import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'; +import { login as apiLogin, logout as apiLogout, getMe, refreshSession as apiRefreshSession } from '../api/auth'; import { updateSignatureAppearancePreference as apiUpdateSignatureAppearancePreference } from '../api/users'; import { clearAccessToken, expireSession, getAccessToken, - getAccessTokenExpiryDelay, SESSION_EXPIRED_EVENT, setAccessToken, } from '../utils/session'; const AuthContext = createContext(null); +const IDLE_SESSION_MS = 15 * 60 * 1000; +const ACTIVE_REFRESH_MS = 9 * 60 * 1000; +const ACTIVITY_EVENTS = ['click', 'keydown', 'mousemove', 'scroll', 'touchstart']; export function AuthProvider({ children }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); - - const scheduleSessionExpiry = useCallback((token) => { - const delay = getAccessTokenExpiryDelay(token); - if (delay === null) return undefined; - - const timeoutId = window.setTimeout(() => { - expireSession(); - }, delay); - - return () => window.clearTimeout(timeoutId); - }, []); + const lastActivityRef = useRef(Date.now()); + const lastRefreshRef = useRef(0); useEffect(() => { const token = getAccessToken(); @@ -48,15 +41,50 @@ export function AuthProvider({ children }) { }, []); useEffect(() => { - const token = getAccessToken(); - if (!user || !token) return undefined; - return scheduleSessionExpiry(token); - }, [scheduleSessionExpiry, user]); + if (!user) return undefined; + + const registerActivity = () => { + const now = Date.now(); + if (now - lastActivityRef.current > IDLE_SESSION_MS) { + expireSession(); + return; + } + lastActivityRef.current = now; + }; + + const keepAlive = async () => { + const now = Date.now(); + if (now - lastActivityRef.current > IDLE_SESSION_MS) { + expireSession(); + return; + } + + if (now - lastRefreshRef.current < ACTIVE_REFRESH_MS || !getAccessToken()) return; + + try { + const { data } = await apiRefreshSession(); + setAccessToken(data.data.accessToken); + lastRefreshRef.current = now; + } catch { + expireSession(); + } + }; + + ACTIVITY_EVENTS.forEach((eventName) => window.addEventListener(eventName, registerActivity, { passive: true })); + const intervalId = window.setInterval(keepAlive, 60 * 1000); + + return () => { + ACTIVITY_EVENTS.forEach((eventName) => window.removeEventListener(eventName, registerActivity)); + window.clearInterval(intervalId); + }; + }, [user]); const login = useCallback(async (email, password) => { const { data } = await apiLogin({ email, password }); const { accessToken, user: userData } = data.data; setAccessToken(accessToken); + lastActivityRef.current = Date.now(); + lastRefreshRef.current = Date.now(); setUser(userData); return userData; }, []);