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
+8 -1
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
1.1.1
1.1.2
+2 -2
View File
@@ -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}\""
}
@@ -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,6 +225,12 @@ class LinkedSignRepository(private val context: Context) {
} catch (error: HttpException) {
if (error.code() != 401) throw mapHttpException(error)
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)))
@@ -227,8 +244,7 @@ class LinkedSignRepository(private val context: Context) {
)
sessionStore.save(updated)
sessionStore.updateBiometricSession(updated.user?.email ?: session.user?.email.orEmpty(), refresh.refreshToken)
block("Bearer ${refresh.accessToken}")
}
return updated
}
private fun throwSessionExpired(): Nothing {
@@ -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
}
}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.1.1",
"version": "1.1.2",
"type": "module",
"scripts": {
"dev": "vite",
+1
View File
@@ -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}`);
+46 -18
View File
@@ -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;
}, []);