diff --git a/CHANGELOG.md b/CHANGELOG.md index fc29799..3bad2ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,16 @@ ## Versión actual -- Versión base: `1.2.0` +- Versión base: `1.2.1` ## Historial +### 1.2.1 - 2026-04-23 + +- Android muestra `About` automáticamente en el primer login de cada usuario y luego vuelve por defecto a `Documentos`. +- `About` agrega acceso a los documentos legales firmados del registro para abrirlos o descargarlos directamente en el teléfono. +- La preferencia de haber visto `About` queda persistente entre reinicios y actualizaciones de la app. + ### 1.2.0 - 2026-04-23 - Se separa el acceso biométrico Android de la sesión normal usando un token biométrico persistente. diff --git a/VERSION b/VERSION index 26aaba0..6085e94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.0 +1.2.1 diff --git a/android/app/build.gradle b/android/app/build.gradle index 93856da..4d39ada 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 10200 - versionName "1.2.0" + versionCode 10201 + versionName "1.2.1" buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\"" } diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/ApiService.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/ApiService.kt index 78965ac..3fd7547 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/data/ApiService.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/ApiService.kt @@ -88,6 +88,11 @@ interface LinkedSignApiService { @Header("Authorization") authorization: String ): ApiEnvelope> + @GET("legal") + suspend fun listLegalDocuments( + @Header("Authorization") authorization: String + ): ApiEnvelope> + @DELETE("signatures/{id}") suspend fun deleteSignedDocument( @Header("Authorization") authorization: String, 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 ea988a3..9385e1d 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 @@ -162,6 +162,10 @@ class LinkedSignRepository(private val context: Context) { unwrap(service.changePassword(auth, ChangePasswordRequest(currentPassword, newPassword))) } } + suspend fun listLegalDocuments(): List = execute { withAuth { auth -> + unwrap(service.listLegalDocuments(auth)) + } } + suspend fun syncAll(): CacheSnapshot = execute { withAuth { auth -> val documents = unwrap(service.listDocuments(auth)).rows val certificates = unwrap(service.listCertificates(auth)) @@ -226,6 +230,12 @@ class LinkedSignRepository(private val context: Context) { return refreshSession(session).accessToken } + fun hasSeenAboutForUser(email: String): Boolean = sessionStore.hasSeenAboutForUser(email) + + fun markAboutSeenForUser(email: String) { + sessionStore.markAboutSeenForUser(email) + } + private suspend fun withAuth(block: suspend (String) -> T): T { val session = sessionStore.load() val accessToken = session.accessToken ?: throw AppException("Debes iniciar sesión.") diff --git a/android/app/src/main/java/net/linkedcorp/linkedsign/data/Models.kt b/android/app/src/main/java/net/linkedcorp/linkedsign/data/Models.kt index bd32dbe..0f87034 100644 --- a/android/app/src/main/java/net/linkedcorp/linkedsign/data/Models.kt +++ b/android/app/src/main/java/net/linkedcorp/linkedsign/data/Models.kt @@ -138,6 +138,21 @@ data class SignedDocumentItem( val signedAt: String? ) +data class LegalDocumentItem( + val id: String, + val slug: String?, + val title: String?, + val version: String?, + @Json(name = "effectiveDate") + val effectiveDate: String?, + @Json(name = "createdAt") + val createdAt: String?, + @Json(name = "signedFilename") + val signedFilename: String?, + @Json(name = "hashSha256") + val hashSha256: String? +) + data class SignRequest( val documentId: Int, val certificateId: Int, 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 0bcb4b7..9273740 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 @@ -123,6 +123,24 @@ class SessionStore(context: Context) { return email to biometricToken } + fun hasSeenAboutForUser(email: String): Boolean { + val normalizedEmail = email.trim().lowercase() + if (normalizedEmail.isBlank()) return true + return preferences.getStringSet(KEY_ABOUT_SEEN_USERS, emptySet()).orEmpty().contains(normalizedEmail) + } + + fun markAboutSeenForUser(email: String) { + val normalizedEmail = email.trim().lowercase() + if (normalizedEmail.isBlank()) return + + val current = preferences.getStringSet(KEY_ABOUT_SEEN_USERS, emptySet()).orEmpty().toMutableSet() + if (current.add(normalizedEmail)) { + preferences.edit() + .putStringSet(KEY_ABOUT_SEEN_USERS, current) + .apply() + } + } + companion object { private const val KEY_ACCESS = "access_token" private const val KEY_REFRESH = "refresh_token" @@ -132,6 +150,7 @@ class SessionStore(context: Context) { private const val KEY_BIO_CONFIGURED = "bio_configured" private const val KEY_BIO_EMAIL = "bio_email" private const val KEY_BIO_TOKEN = "bio_token" + private const val KEY_ABOUT_SEEN_USERS = "about_seen_users" private const val DEFAULT_SESSION_MS = 15 * 60 * 1000L } } 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 e6adee2..5b851b3 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,7 @@ package net.linkedcorp.linkedsign.ui import android.app.DownloadManager +import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.graphics.Bitmap @@ -17,6 +18,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.biometric.BiometricManager import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -133,6 +135,7 @@ import okhttp3.OkHttpClient import okhttp3.Request import java.io.File import net.linkedcorp.linkedsign.data.DocumentItem +import net.linkedcorp.linkedsign.data.LegalDocumentItem import net.linkedcorp.linkedsign.data.SimpleMessageData import kotlin.math.roundToInt import net.linkedcorp.linkedsign.data.SignedDocumentItem @@ -333,7 +336,7 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo scope.launch { runCatching { viewModel.biometricLogin() } .onSuccess { - selectedTab = AppTab.Documents + selectedTab = if (viewModel.consumeAboutLandingForCurrentUser()) AppTab.About else AppTab.Documents appUnlocked = true snackbarHostState.showSnackbar("Sesión iniciada con huella.") } @@ -371,7 +374,7 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo scope.launch { runCatching { viewModel.login(email, password) } .onSuccess { - selectedTab = AppTab.Documents + selectedTab = if (viewModel.consumeAboutLandingForCurrentUser()) AppTab.About else AppTab.Documents appUnlocked = true showBiometricOptIn = biometricAvailable && !state.biometricConfigured snackbarHostState.showSnackbar("Sesión iniciada.") @@ -446,7 +449,8 @@ fun LinkedSignApp(viewModel: MainViewModel, appContext: Context, activity: Compo pendingSignDocumentId = documentId.toString() selectedTab = AppTab.Sign }, - accessToken = { viewModel.accessToken() } + accessToken = { viewModel.accessToken() }, + loadLegalDocuments = { viewModel.listLegalDocuments() } ) } } @@ -653,7 +657,8 @@ private fun AppFlow( onDeleteDocument: (UnifiedDocumentItem) -> Unit, onSign: (Int, Int, String, String, String, Int, SignatureRect?) -> Unit, onSelectDocumentToSign: (Int) -> Unit, - accessToken: suspend () -> String? + accessToken: suspend () -> String?, + loadLegalDocuments: suspend () -> List ) { Box( modifier = Modifier @@ -678,13 +683,37 @@ private fun AppFlow( onSign = onSign ) AppTab.Certificates -> CertificatesScreen(state = state, onSync = onSync, onUploadCertificate = onUploadCertificate) - AppTab.About -> AboutScreen() + AppTab.About -> AboutScreen( + appContext = appContext, + accessToken = accessToken, + loadLegalDocuments = loadLegalDocuments, + reloadKey = state.userEmail + ) } } } @Composable -private fun AboutScreen() { +private fun AboutScreen( + appContext: Context, + accessToken: suspend () -> String?, + loadLegalDocuments: suspend () -> List, + reloadKey: String? +) { + val scope = rememberCoroutineScope() + var legalDocuments by remember { mutableStateOf>(emptyList()) } + var legalLoading by remember { mutableStateOf(true) } + var legalError by remember { mutableStateOf(null) } + + LaunchedEffect(reloadKey) { + legalLoading = true + legalError = null + runCatching { loadLegalDocuments() } + .onSuccess { legalDocuments = it } + .onFailure { legalError = it.message ?: "No se pudieron cargar los documentos legales." } + legalLoading = false + } + LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 20.dp, vertical = 20.dp), @@ -722,6 +751,156 @@ private fun AboutScreen() { } } } + item { + SectionCard(title = "Documentos legales firmados") { + Text( + "Aquí puedes abrir o descargar en tu teléfono los documentos de protección de datos y aceptación legal firmados durante tu registro inicial. No se muestran dentro de la app.", + color = TextMuted, + fontSize = AppFontSizes.sm, + lineHeight = 19.sp + ) + when { + legalLoading -> { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 10.dp), + horizontalArrangement = Arrangement.Center + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), color = Brand, strokeWidth = 2.dp) + } + } + legalError != null -> { + Surface( + color = WarningBg, + shape = RoundedCornerShape(14.dp) + ) { + Text( + legalError ?: "No se pudieron cargar los documentos legales.", + color = Warning, + fontSize = AppFontSizes.sm, + modifier = Modifier.padding(12.dp) + ) + } + } + legalDocuments.isEmpty() -> { + Text("Aún no hay documentos legales asociados a esta cuenta.", color = TextMuted, fontSize = AppFontSizes.sm) + } + else -> { + legalDocuments.forEach { item -> + LegalDocumentRow( + item = item, + onOpenExternal = { + scope.launch { + runCatching { + val token = accessToken() + val file = downloadSecuredFileToCache( + context = appContext, + url = "${BuildConfig.API_BASE_URL}/legal/${item.id}/download", + accessToken = token, + fileName = item.signedFilename ?: "${item.slug ?: "legal"}.pdf" + ) + openDownloadedFile(appContext, file) + }.onFailure { + Toast.makeText(appContext, it.message ?: "No se pudo abrir el documento en el teléfono.", Toast.LENGTH_SHORT).show() + } + } + }, + onDownload = { + scope.launch { + runCatching { + downloadFile( + context = appContext, + url = "${BuildConfig.API_BASE_URL}/legal/${item.id}/download", + accessToken = accessToken(), + fileName = item.signedFilename ?: "${item.slug ?: "legal"}.pdf" + ) + }.onSuccess { + Toast.makeText(appContext, "Descarga iniciada.", Toast.LENGTH_SHORT).show() + }.onFailure { + Toast.makeText(appContext, it.message ?: "No se pudo descargar el documento.", Toast.LENGTH_SHORT).show() + } + } + } + ) + } + } + } + } + } + } +} + +@Composable +private fun LegalDocumentRow( + item: LegalDocumentItem, + onOpenExternal: () -> Unit, + onDownload: () -> Unit +) { + Surface( + shape = RoundedCornerShape(18.dp), + color = Color.White, + border = BorderStroke(1.dp, StrokeSoft) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(38.dp) + .background(BrandLight, RoundedCornerShape(12.dp)), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Outlined.Verified, contentDescription = null, tint = Brand) + } + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(item.title ?: "Documento legal", color = BrandDark, fontSize = AppFontSizes.base, fontWeight = FontWeight.SemiBold) + Text(item.signedFilename ?: "PDF firmado", color = TextMuted, fontSize = AppFontSizes.xs) + } + } + AssistChip( + onClick = {}, + enabled = false, + label = { Text("Firmado") }, + colors = AssistChipDefaults.assistChipColors( + containerColor = SuccessBg, + labelColor = Success, + disabledContainerColor = SuccessBg, + disabledLabelColor = Success + ) + ) + } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("Versión ${item.version ?: "-"}", color = TextMuted, fontSize = AppFontSizes.xs) + Text("Vigente desde ${item.effectiveDate ?: "-"}", color = TextMuted, fontSize = AppFontSizes.xs) + Text("Firmado el ${formatFriendlyDate(item.createdAt)}", color = TextMuted, fontSize = AppFontSizes.xs) + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedActionButton( + "Abrir en el teléfono", + Icons.Outlined.FolderOpen, + modifier = Modifier.weight(1f), + onClick = onOpenExternal + ) + FilledActionButton( + "Descargar", + Icons.Outlined.Download, + modifier = Modifier.weight(1f), + onClick = onDownload + ) + } + } } } @@ -1949,6 +2128,17 @@ private fun queryFileName(context: Context, uri: Uri): String? { } } +private fun formatFriendlyDate(value: String?): String { + if (value.isNullOrBlank()) return "-" + return runCatching { + java.time.OffsetDateTime.parse(value) + .toLocalDateTime() + .toString() + .replace('T', ' ') + .substring(0, 16) + }.getOrElse { value.replace('T', ' ').take(16).ifBlank { value } } +} + private fun downloadFile(context: Context, url: String, accessToken: String?, fileName: String) { val request = DownloadManager.Request(Uri.parse(url)) .setTitle(fileName) @@ -1964,6 +2154,24 @@ private fun downloadFile(context: Context, url: String, accessToken: String?, fi manager.enqueue(request) } +private fun openDownloadedFile(context: Context, file: File) { + val uri = FileProvider.getUriForFile( + context, + "${BuildConfig.APPLICATION_ID}.fileprovider", + file + ) + val intent = Intent(Intent.ACTION_VIEW) + .setDataAndType(uri, "application/pdf") + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + try { + context.startActivity(Intent.createChooser(intent, "Abrir documento")) + } catch (_: ActivityNotFoundException) { + throw AppException("No hay una app en el teléfono para abrir PDFs.") + } +} + private suspend fun downloadSecuredFileToCache( context: Context, url: String, 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 a42e3e0..c17ee6d 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 @@ -171,6 +171,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { refreshState() } + suspend fun listLegalDocuments(): List = + repository.listLegalDocuments() + + fun consumeAboutLandingForCurrentUser(): Boolean { + val email = repository.loadLocalSnapshot().first.user?.email ?: return false + if (repository.hasSeenAboutForUser(email)) { + return false + } + repository.markAboutSeenForUser(email) + return true + } + private fun buildState( session: SessionSnapshot, cache: CacheSnapshot, diff --git a/backend/package.json b/backend/package.json index d3d3402..0d94a4d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "linkedsign-backend", - "version": "1.2.0", + "version": "1.2.1", "private": true, "description": "Initial backend for LinkedSign", "main": "src/server.js", diff --git a/frontend/package.json b/frontend/package.json index 77119d0..70fc072 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.2.0", + "version": "1.2.1", "type": "module", "scripts": { "dev": "vite",