Add first-login About flow to Android

This commit is contained in:
2026-04-23 14:11:36 -05:00
parent eb3f47423f
commit 1b930e1737
11 changed files with 287 additions and 12 deletions
+7 -1
View File
@@ -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.
+1 -1
View File
@@ -1 +1 @@
1.2.0
1.2.1
+2 -2
View File
@@ -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}\""
}
@@ -88,6 +88,11 @@ interface LinkedSignApiService {
@Header("Authorization") authorization: String
): ApiEnvelope<List<SignedDocumentItem>>
@GET("legal")
suspend fun listLegalDocuments(
@Header("Authorization") authorization: String
): ApiEnvelope<List<LegalDocumentItem>>
@DELETE("signatures/{id}")
suspend fun deleteSignedDocument(
@Header("Authorization") authorization: String,
@@ -162,6 +162,10 @@ class LinkedSignRepository(private val context: Context) {
unwrap(service.changePassword(auth, ChangePasswordRequest(currentPassword, newPassword)))
} }
suspend fun listLegalDocuments(): List<LegalDocumentItem> = 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 <T> withAuth(block: suspend (String) -> T): T {
val session = sessionStore.load()
val accessToken = session.accessToken ?: throw AppException("Debes iniciar sesión.")
@@ -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,
@@ -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
}
}
@@ -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<LegalDocumentItem>
) {
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<LegalDocumentItem>,
reloadKey: String?
) {
val scope = rememberCoroutineScope()
var legalDocuments by remember { mutableStateOf<List<LegalDocumentItem>>(emptyList()) }
var legalLoading by remember { mutableStateOf(true) }
var legalError by remember { mutableStateOf<String?>(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,
@@ -171,6 +171,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
refreshState()
}
suspend fun listLegalDocuments(): List<net.linkedcorp.linkedsign.data.LegalDocumentItem> =
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,
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.2.0",
"version": "1.2.1",
"type": "module",
"scripts": {
"dev": "vite",