From ae5649f4089524628ab626f6aa9e9166782477c3 Mon Sep 17 00:00:00 2001 From: Milton Gonzalez Date: Thu, 23 Apr 2026 10:37:04 -0500 Subject: [PATCH] Adjust Android document actions --- CHANGELOG.md | 7 +- VERSION | 2 +- android/app/build.gradle | 4 +- .../linkedcorp/linkedsign/ui/LinkedSignApp.kt | 170 +++++++++++++++--- backend/package.json | 2 +- frontend/package.json | 2 +- 6 files changed, 156 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53600a3..ca9edf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,15 @@ ## Versión actual -- Versión base: `1.1.2` +- Versión base: `1.1.3` ## Historial +### 1.1.3 - 2026-04-23 + +- Se cambia en Android el botón de ojo por descarga real del PDF. +- El botón con ícono PDF ahora abre el documento localmente dentro de la app. + ### 1.1.2 - 2026-04-23 - Se separa en Android la vista de documentos firmados y originales. diff --git a/VERSION b/VERSION index 45a1b3f..781dcb0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.2 +1.1.3 diff --git a/android/app/build.gradle b/android/app/build.gradle index 5815c8a..77d9a21 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 10102 - versionName "1.1.2" + versionCode 10103 + versionName "1.1.3" buildConfigField "String", "API_BASE_URL", "\"${apiBaseUrl}\"" } 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 9d9f599..8d9040c 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,5 +1,6 @@ package net.linkedcorp.linkedsign.ui +import android.app.DownloadManager import android.content.Context import android.content.Intent import android.graphics.Bitmap @@ -8,6 +9,7 @@ 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 @@ -15,7 +17,6 @@ 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 @@ -48,6 +49,7 @@ 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 @@ -116,6 +118,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog import androidx.compose.foundation.layout.ColumnScope import kotlinx.coroutines.delay import kotlinx.coroutines.Dispatchers @@ -890,6 +893,7 @@ private fun DocumentsScreen( val scope = rememberCoroutineScope() var deleteTarget by remember { mutableStateOf(null) } var showSignedDocuments by rememberSaveable { mutableStateOf(true) } + var documentPreview by remember { mutableStateOf(null) } val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> uri ?: return@rememberLauncherForActivityResult try { @@ -963,26 +967,56 @@ private fun DocumentsScreen( title = item.title, subtitle = item.subtitle, onSelect = if (item.isSigned) null else ({ onSelectDocumentToSign(item.documentId) }), - onView = { + onPreview = { scope.launch { runCatching { - if (item.isSigned && item.signedSignatureId != null) { - openSecurePdf( + val token = accessToken() + val file = if (item.isSigned && item.signedSignatureId != null) { + downloadSecuredFileToCache( appContext, "${BuildConfig.API_BASE_URL}/signatures/${item.signedSignatureId}/download", - accessToken(), + token, item.signedFilename ?: "firmado.pdf" ) } else { - openSecurePdf( + downloadSecuredFileToCache( appContext, "${BuildConfig.API_BASE_URL}/documents/${item.documentId}/download", - accessToken(), + token, item.originalFilename ) } + renderPdfPreview(file, 0) + }.onSuccess { + documentPreview = it }.onFailure { - Toast.makeText(appContext, it.message ?: "No se pudo abrir el PDF.", Toast.LENGTH_SHORT).show() + Toast.makeText(appContext, it.message ?: "No se pudo abrir el PDF dentro de la app.", Toast.LENGTH_SHORT).show() + } + } + }, + onDownload = { + scope.launch { + runCatching { + val token = accessToken() + if (item.isSigned && item.signedSignatureId != null) { + downloadFile( + appContext, + "${BuildConfig.API_BASE_URL}/signatures/${item.signedSignatureId}/download", + token, + item.signedFilename ?: "firmado.pdf" + ) + } else { + downloadFile( + appContext, + "${BuildConfig.API_BASE_URL}/documents/${item.documentId}/download", + token, + item.originalFilename + ) + } + }.onSuccess { + Toast.makeText(appContext, "Descarga iniciada.", Toast.LENGTH_SHORT).show() + }.onFailure { + Toast.makeText(appContext, it.message ?: "No se pudo descargar el PDF.", Toast.LENGTH_SHORT).show() } } }, @@ -1022,6 +1056,22 @@ private fun DocumentsScreen( } ) } + + documentPreview?.let { preview -> + PdfPreviewDialog( + preview = preview, + onDismiss = { documentPreview = null }, + onPageChange = { page -> + scope.launch { + runCatching { renderPdfPreview(preview.file, page) } + .onSuccess { documentPreview = it } + .onFailure { + Toast.makeText(appContext, it.message ?: "No se pudo cargar la página.", Toast.LENGTH_SHORT).show() + } + } + } + ) + } } @Composable @@ -1465,7 +1515,14 @@ private fun SectionCard(title: String, content: @Composable ColumnScope.() -> Un } @Composable -private fun DocumentRow(title: String, subtitle: String, onSelect: (() -> Unit)? = null, onView: () -> Unit, onDelete: () -> Unit) { +private fun DocumentRow( + title: String, + subtitle: String, + onSelect: (() -> Unit)? = null, + onPreview: () -> Unit, + onDownload: () -> Unit, + onDelete: () -> Unit +) { val isSigned = subtitle.contains("firmad", ignoreCase = true) || subtitle.contains("signed", ignoreCase = true) val compactSubtitle = subtitle .replace("signed", "", ignoreCase = true) @@ -1525,14 +1582,16 @@ private fun DocumentRow(title: String, subtitle: String, onSelect: (() -> Unit)? ) } Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.CenterVertically) { - Icon( - if (isSigned) Icons.Outlined.Verified else Icons.Outlined.PictureAsPdf, - contentDescription = null, - tint = if (isSigned) Success else Warning, - modifier = Modifier.size(18.dp) - ) - IconButton(onClick = onView, modifier = Modifier.size(28.dp)) { - Icon(Icons.Outlined.Visibility, contentDescription = "Ver documento", tint = Brand, modifier = Modifier.size(17.dp)) + IconButton(onClick = onPreview, modifier = Modifier.size(28.dp)) { + Icon( + if (isSigned) Icons.Outlined.Verified else Icons.Outlined.PictureAsPdf, + contentDescription = "Ver PDF dentro de la app", + tint = if (isSigned) Success else Warning, + modifier = Modifier.size(18.dp) + ) + } + IconButton(onClick = onDownload, modifier = Modifier.size(28.dp)) { + Icon(Icons.Outlined.Download, contentDescription = "Descargar documento", tint = Brand, 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)) @@ -1544,6 +1603,64 @@ private fun DocumentRow(title: String, subtitle: String, onSelect: (() -> Unit)? Spacer(Modifier.height(2.dp)) } +@Composable +private fun PdfPreviewDialog( + preview: PdfPreviewState, + onDismiss: () -> Unit, + onPageChange: (Int) -> Unit +) { + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = RoundedCornerShape(20.dp), + color = Color.White, + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Vista local del PDF", color = BrandDark, fontSize = AppFontSizes.lg, fontWeight = FontWeight.SemiBold) + TextButton(onClick = onDismiss) { Text("Cerrar") } + } + Text( + "Página ${preview.pageIndex + 1} / ${preview.pageCount}", + color = TextMuted, + fontSize = AppFontSizes.sm + ) + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFFF3F6FC), RoundedCornerShape(16.dp)) + .padding(10.dp), + contentAlignment = Alignment.Center + ) { + Image( + bitmap = preview.bitmap, + contentDescription = "Vista local PDF", + modifier = Modifier.fillMaxWidth(), + contentScale = ContentScale.FillWidth + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedActionButton("‹", Icons.Outlined.Description, modifier = Modifier.weight(1f)) { + if (preview.pageIndex > 0) onPageChange(preview.pageIndex - 1) + } + OutlinedActionButton("›", Icons.Outlined.Description, modifier = Modifier.weight(1f)) { + if (preview.pageIndex < preview.pageCount - 1) onPageChange(preview.pageIndex + 1) + } + } + } + } + } +} + @Composable private fun CertificateRow(certificate: CertificateItem) { val isActive = (certificate.isActive ?: 0) == 1 @@ -1824,16 +1941,19 @@ private fun queryFileName(context: Context, uri: Uri): String? { } } -private suspend fun openSecurePdf(context: Context, url: String, accessToken: String?, fileName: String) { - val targetFile = downloadSecuredFileToCache(context, url, accessToken, fileName) +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) - val uri = FileProvider.getUriForFile(context, "net.linkedcorp.linkedsign.fileprovider", targetFile) - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(uri, "application/pdf") - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (!accessToken.isNullOrBlank()) { + request.addRequestHeader("Authorization", "Bearer $accessToken") } - context.startActivity(intent) + + val manager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + manager.enqueue(request) } private suspend fun downloadSecuredFileToCache( diff --git a/backend/package.json b/backend/package.json index 3d223c4..905525b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "linkedsign-backend", - "version": "1.1.2", + "version": "1.1.3", "private": true, "description": "Initial backend for LinkedSign", "main": "src/server.js", diff --git a/frontend/package.json b/frontend/package.json index 6ee9a69..33082dc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.1.2", + "version": "1.1.3", "type": "module", "scripts": { "dev": "vite",