Add first-login About flow to Android
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user