Fix Android documents and idle session
This commit is contained in:
+8
-1
@@ -2,10 +2,17 @@
|
|||||||
|
|
||||||
## Versión actual
|
## Versión actual
|
||||||
|
|
||||||
- Versión base: `1.1.1`
|
- Versión base: `1.1.2`
|
||||||
|
|
||||||
## Historial
|
## 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
|
### 1.1.1 - 2026-04-23
|
||||||
|
|
||||||
- Se oculta la barra de navegación del sistema Android en modo inmersivo.
|
- Se oculta la barra de navegación del sistema Android en modo inmersivo.
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ android {
|
|||||||
applicationId "net.linkedcorp.linkedsign"
|
applicationId "net.linkedcorp.linkedsign"
|
||||||
minSdk 26
|
minSdk 26
|
||||||
targetSdk 35
|
targetSdk 35
|
||||||
versionCode 10101
|
versionCode 10102
|
||||||
versionName "1.1.1"
|
versionName "1.1.2"
|
||||||
|
|
||||||
buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\""
|
buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import java.io.FileInputStream
|
|||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
private const val TOKEN_REFRESH_MARGIN_MS = 6 * 60 * 1000L
|
||||||
|
|
||||||
class LinkedSignRepository(private val context: Context) {
|
class LinkedSignRepository(private val context: Context) {
|
||||||
private val sessionStore = SessionStore(context)
|
private val sessionStore = SessionStore(context)
|
||||||
private val localCache = LocalCache(context)
|
private val localCache = LocalCache(context)
|
||||||
@@ -203,7 +205,16 @@ class LinkedSignRepository(private val context: Context) {
|
|||||||
result
|
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 {
|
private suspend fun <T> withAuth(block: suspend (String) -> T): T {
|
||||||
val session = sessionStore.load()
|
val session = sessionStore.load()
|
||||||
@@ -214,6 +225,12 @@ class LinkedSignRepository(private val context: Context) {
|
|||||||
} catch (error: HttpException) {
|
} catch (error: HttpException) {
|
||||||
if (error.code() != 401) throw mapHttpException(error)
|
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 refreshToken = session.refreshToken ?: throwSessionExpired()
|
||||||
val refresh = try {
|
val refresh = try {
|
||||||
unwrap(service.refresh(RefreshRequest(refreshToken)))
|
unwrap(service.refresh(RefreshRequest(refreshToken)))
|
||||||
@@ -227,8 +244,7 @@ class LinkedSignRepository(private val context: Context) {
|
|||||||
)
|
)
|
||||||
sessionStore.save(updated)
|
sessionStore.save(updated)
|
||||||
sessionStore.updateBiometricSession(updated.user?.email ?: session.user?.email.orEmpty(), refresh.refreshToken)
|
sessionStore.updateBiometricSession(updated.user?.email ?: session.user?.email.orEmpty(), refresh.refreshToken)
|
||||||
block("Bearer ${refresh.accessToken}")
|
return updated
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun throwSessionExpired(): Nothing {
|
private fun throwSessionExpired(): Nothing {
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ class SessionStore(context: Context) {
|
|||||||
val userJson = preferences.getString(KEY_USER, null)
|
val userJson = preferences.getString(KEY_USER, null)
|
||||||
val expiresAt = preferences.getLong(KEY_EXPIRES_AT, 0L).takeIf { it > 0L }
|
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) }
|
val user = userJson?.let { userAdapter.fromJson(it) }
|
||||||
return SessionSnapshot(accessToken, refreshToken, user, expiresAt)
|
return SessionSnapshot(accessToken, refreshToken, user, expiresAt)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package net.linkedcorp.linkedsign.ui
|
package net.linkedcorp.linkedsign.ui
|
||||||
|
|
||||||
import android.app.DownloadManager
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
@@ -9,7 +8,6 @@ import android.hardware.biometrics.BiometricPrompt
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.CancellationSignal
|
import android.os.CancellationSignal
|
||||||
import android.os.Environment
|
|
||||||
import android.os.ParcelFileDescriptor
|
import android.os.ParcelFileDescriptor
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
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.CheckCircle
|
||||||
import androidx.compose.material.icons.outlined.Dashboard
|
import androidx.compose.material.icons.outlined.Dashboard
|
||||||
import androidx.compose.material.icons.outlined.Description
|
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.Draw
|
||||||
import androidx.compose.material.icons.outlined.Delete
|
import androidx.compose.material.icons.outlined.Delete
|
||||||
import androidx.compose.material.icons.outlined.Fingerprint
|
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.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
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(
|
Scaffold(
|
||||||
|
modifier = Modifier.pointerInput(state.authenticated) {
|
||||||
|
if (!state.authenticated) return@pointerInput
|
||||||
|
awaitPointerEventScope {
|
||||||
|
while (true) {
|
||||||
|
awaitPointerEvent()
|
||||||
|
viewModel.noteUserActivity()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||||
containerColor = SurfaceSoft,
|
containerColor = SurfaceSoft,
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -628,7 +642,7 @@ private fun AppFlow(
|
|||||||
onDeleteDocument: (UnifiedDocumentItem) -> Unit,
|
onDeleteDocument: (UnifiedDocumentItem) -> Unit,
|
||||||
onSign: (Int, Int, String, String, String, Int, SignatureRect?) -> Unit,
|
onSign: (Int, Int, String, String, String, Int, SignatureRect?) -> Unit,
|
||||||
onSelectDocumentToSign: (Int) -> Unit,
|
onSelectDocumentToSign: (Int) -> Unit,
|
||||||
accessToken: () -> String?
|
accessToken: suspend () -> String?
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -866,7 +880,7 @@ private fun DashboardScreen(state: MainUiState, onSync: () -> Unit) {
|
|||||||
@Composable
|
@Composable
|
||||||
private fun DocumentsScreen(
|
private fun DocumentsScreen(
|
||||||
state: MainUiState,
|
state: MainUiState,
|
||||||
accessToken: () -> String?,
|
accessToken: suspend () -> String?,
|
||||||
appContext: Context,
|
appContext: Context,
|
||||||
onSync: () -> Unit,
|
onSync: () -> Unit,
|
||||||
onUploadDocument: (Uri, String) -> Unit,
|
onUploadDocument: (Uri, String) -> Unit,
|
||||||
@@ -875,6 +889,7 @@ private fun DocumentsScreen(
|
|||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var deleteTarget by remember { mutableStateOf<UnifiedDocumentItem?>(null) }
|
var deleteTarget by remember { mutableStateOf<UnifiedDocumentItem?>(null) }
|
||||||
|
var showSignedDocuments by rememberSaveable { mutableStateOf(true) }
|
||||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
uri ?: return@rememberLauncherForActivityResult
|
uri ?: return@rememberLauncherForActivityResult
|
||||||
try {
|
try {
|
||||||
@@ -889,24 +904,30 @@ private fun DocumentsScreen(
|
|||||||
onUploadDocument(uri, name)
|
onUploadDocument(uri, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
val signedByDocumentId = state.signedDocuments.associateBy { it.documentId }
|
val documentsById = state.documents.associateBy { it.id }
|
||||||
val unifiedDocuments = state.documents.map { document ->
|
val signedDocuments = state.signedDocuments.mapNotNull { signed ->
|
||||||
val signed = signedByDocumentId[document.id]
|
val documentId = signed.documentId ?: return@mapNotNull null
|
||||||
val isSignedDocument = signed != null || document.status.equals("signed", ignoreCase = true)
|
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(
|
UnifiedDocumentItem(
|
||||||
documentId = document.id,
|
documentId = document.id,
|
||||||
title = document.title ?: document.filename ?: "Documento",
|
title = document.title ?: document.filename ?: "Documento",
|
||||||
subtitle = if (isSignedDocument) {
|
subtitle = "PDF original",
|
||||||
signed?.signedAt ?: "PDF firmado"
|
|
||||||
} else {
|
|
||||||
document.status ?: "PDF original"
|
|
||||||
},
|
|
||||||
originalFilename = document.filename ?: "${document.title ?: "documento"}.pdf",
|
originalFilename = document.filename ?: "${document.title ?: "documento"}.pdf",
|
||||||
signedSignatureId = signed?.id,
|
isSigned = false
|
||||||
signedFilename = signed?.signedFilename,
|
|
||||||
isSigned = isSignedDocument
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val visibleDocuments = if (showSignedDocuments) signedDocuments else originalDocuments
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -918,16 +939,26 @@ private fun DocumentsScreen(
|
|||||||
FilledActionButton("Subir documento", Icons.Outlined.Article, modifier = Modifier.weight(1f)) {
|
FilledActionButton("Subir documento", Icons.Outlined.Article, modifier = Modifier.weight(1f)) {
|
||||||
launcher.launch(arrayOf("application/pdf"))
|
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)
|
OutlinedActionButton("Actualizar", Icons.Outlined.Sync, modifier = Modifier.weight(1f), onClick = onSync)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item {
|
item {
|
||||||
CompactSectionLabel("MIS DOCUMENTOS")
|
CompactSectionLabel(if (showSignedDocuments) "DOCUMENTOS FIRMADOS" else "DOCUMENTOS ORIGINALES")
|
||||||
SectionCard(title = "Documentos") {
|
SectionCard(title = if (showSignedDocuments) "Firmados" else "Originales") {
|
||||||
if (unifiedDocuments.isEmpty()) {
|
if (visibleDocuments.isEmpty()) {
|
||||||
Text("No hay documentos sincronizados.", color = Color(0xFF6B7280))
|
Text(
|
||||||
|
if (showSignedDocuments) "No hay documentos firmados sincronizados." else "No hay documentos originales sincronizados.",
|
||||||
|
color = Color(0xFF6B7280)
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
unifiedDocuments.forEach { item ->
|
visibleDocuments.forEach { item ->
|
||||||
DocumentRow(
|
DocumentRow(
|
||||||
title = item.title,
|
title = item.title,
|
||||||
subtitle = item.subtitle,
|
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 }
|
onDelete = { deleteTarget = item }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1059,7 +1073,7 @@ private fun CertificatesScreen(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun SignScreen(
|
private fun SignScreen(
|
||||||
state: MainUiState,
|
state: MainUiState,
|
||||||
accessToken: () -> String?,
|
accessToken: suspend () -> String?,
|
||||||
appContext: Context,
|
appContext: Context,
|
||||||
initialDocumentId: String?,
|
initialDocumentId: String?,
|
||||||
onSign: (Int, Int, String, String, String, Int, SignatureRect?) -> Unit
|
onSign: (Int, Int, String, String, String, Int, SignatureRect?) -> Unit
|
||||||
@@ -1451,7 +1465,7 @@ private fun SectionCard(title: String, content: @Composable ColumnScope.() -> Un
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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 isSigned = subtitle.contains("firmad", ignoreCase = true) || subtitle.contains("signed", ignoreCase = true)
|
||||||
val compactSubtitle = subtitle
|
val compactSubtitle = subtitle
|
||||||
.replace("signed", "", ignoreCase = true)
|
.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)) {
|
IconButton(onClick = onView, modifier = Modifier.size(28.dp)) {
|
||||||
Icon(Icons.Outlined.Visibility, contentDescription = "Ver documento", tint = Brand, modifier = Modifier.size(17.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)) {
|
IconButton(onClick = onDelete, modifier = Modifier.size(28.dp)) {
|
||||||
Icon(Icons.Outlined.Delete, contentDescription = "Eliminar documento", tint = Danger, modifier = Modifier.size(17.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) {
|
private suspend fun openSecurePdf(context: Context, url: String, accessToken: String?, fileName: String) {
|
||||||
val targetFile = downloadSecuredFileToCache(context, url, accessToken, fileName)
|
val targetFile = downloadSecuredFileToCache(context, url, accessToken, fileName)
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ data class MainUiState(
|
|||||||
|
|
||||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
private val repository = LinkedSignRepository(application.applicationContext)
|
private val repository = LinkedSignRepository(application.applicationContext)
|
||||||
|
private var lastActivityAt = System.currentTimeMillis()
|
||||||
|
|
||||||
var uiState by mutableStateOf(MainUiState())
|
var uiState by mutableStateOf(MainUiState())
|
||||||
private set
|
private set
|
||||||
@@ -137,13 +138,29 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
repository.requestPasswordChange(currentPassword, newPassword)
|
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() {
|
suspend fun logout() {
|
||||||
repository.logout()
|
repository.logout()
|
||||||
val (session, cache) = repository.loadLocalSnapshot()
|
val (session, cache) = repository.loadLocalSnapshot()
|
||||||
uiState = buildState(session, cache, loading = false)
|
uiState = buildState(session, cache, loading = false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun accessToken(): String? = repository.getAccessToken()
|
suspend fun accessToken(): String? = repository.getFreshAccessToken()
|
||||||
|
|
||||||
fun enableBiometricAccess() {
|
fun enableBiometricAccess() {
|
||||||
repository.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.")
|
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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "linkedsign-backend",
|
"name": "linkedsign-backend",
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Initial backend for LinkedSign",
|
"description": "Initial backend for LinkedSign",
|
||||||
"main": "src/server.js",
|
"main": "src/server.js",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const register = (data) => client.post('/auth/register', data, {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
export const login = (data) => client.post('/auth/login', 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 logout = () => client.post('/auth/logout', {});
|
||||||
export const getMe = () => client.get('/auth/me');
|
export const getMe = () => client.get('/auth/me');
|
||||||
export const verifyEmail = (token) => client.get(`/auth/verify/${token}`);
|
export const verifyEmail = (token) => client.get(`/auth/verify/${token}`);
|
||||||
|
|||||||
@@ -1,31 +1,24 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { login as apiLogin, logout as apiLogout, getMe } from '../api/auth';
|
import { login as apiLogin, logout as apiLogout, getMe, refreshSession as apiRefreshSession } from '../api/auth';
|
||||||
import { updateSignatureAppearancePreference as apiUpdateSignatureAppearancePreference } from '../api/users';
|
import { updateSignatureAppearancePreference as apiUpdateSignatureAppearancePreference } from '../api/users';
|
||||||
import {
|
import {
|
||||||
clearAccessToken,
|
clearAccessToken,
|
||||||
expireSession,
|
expireSession,
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
getAccessTokenExpiryDelay,
|
|
||||||
SESSION_EXPIRED_EVENT,
|
SESSION_EXPIRED_EVENT,
|
||||||
setAccessToken,
|
setAccessToken,
|
||||||
} from '../utils/session';
|
} from '../utils/session';
|
||||||
|
|
||||||
const AuthContext = createContext(null);
|
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 }) {
|
export function AuthProvider({ children }) {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const lastActivityRef = useRef(Date.now());
|
||||||
const scheduleSessionExpiry = useCallback((token) => {
|
const lastRefreshRef = useRef(0);
|
||||||
const delay = getAccessTokenExpiryDelay(token);
|
|
||||||
if (delay === null) return undefined;
|
|
||||||
|
|
||||||
const timeoutId = window.setTimeout(() => {
|
|
||||||
expireSession();
|
|
||||||
}, delay);
|
|
||||||
|
|
||||||
return () => window.clearTimeout(timeoutId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = getAccessToken();
|
const token = getAccessToken();
|
||||||
@@ -48,15 +41,50 @@ export function AuthProvider({ children }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = getAccessToken();
|
if (!user) return undefined;
|
||||||
if (!user || !token) return undefined;
|
|
||||||
return scheduleSessionExpiry(token);
|
const registerActivity = () => {
|
||||||
}, [scheduleSessionExpiry, user]);
|
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 login = useCallback(async (email, password) => {
|
||||||
const { data } = await apiLogin({ email, password });
|
const { data } = await apiLogin({ email, password });
|
||||||
const { accessToken, user: userData } = data.data;
|
const { accessToken, user: userData } = data.data;
|
||||||
setAccessToken(accessToken);
|
setAccessToken(accessToken);
|
||||||
|
lastActivityRef.current = Date.now();
|
||||||
|
lastRefreshRef.current = Date.now();
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
return userData;
|
return userData;
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
Reference in New Issue
Block a user