android: agregar ojo y desbloqueo biométrico
This commit is contained in:
@@ -0,0 +1,202 @@
|
|||||||
|
package com.korexlabs.dhinspeccion.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Visibility
|
||||||
|
import androidx.compose.material.icons.filled.VisibilityOff
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import com.korexlabs.dhinspeccion.MainViewModel
|
||||||
|
|
||||||
|
private const val BIOMETRIC_PREFS = "dh_v2_biometric"
|
||||||
|
private const val BIOMETRIC_ENABLED = "enabled"
|
||||||
|
|
||||||
|
private fun biometricAvailable(context: Context): Boolean =
|
||||||
|
BiometricManager.from(context).canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
|
||||||
|
BiometricManager.BIOMETRIC_SUCCESS
|
||||||
|
|
||||||
|
private fun biometricEnabled(context: Context): Boolean =
|
||||||
|
context.getSharedPreferences(BIOMETRIC_PREFS, Context.MODE_PRIVATE)
|
||||||
|
.getBoolean(BIOMETRIC_ENABLED, false)
|
||||||
|
|
||||||
|
private fun setBiometricEnabled(context: Context, enabled: Boolean) {
|
||||||
|
context.getSharedPreferences(BIOMETRIC_PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit().putBoolean(BIOMETRIC_ENABLED, enabled).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DhRoot(model: MainViewModel, activity: FragmentActivity) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val capable = remember { biometricAvailable(context) }
|
||||||
|
var unlocked by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var passwordLoginInFlight by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var enabled by remember { mutableStateOf(biometricEnabled(context)) }
|
||||||
|
|
||||||
|
LaunchedEffect(model.session) {
|
||||||
|
if (model.session != null && passwordLoginInFlight) {
|
||||||
|
if (capable) {
|
||||||
|
setBiometricEnabled(context, true)
|
||||||
|
enabled = true
|
||||||
|
}
|
||||||
|
unlocked = true
|
||||||
|
passwordLoginInFlight = false
|
||||||
|
}
|
||||||
|
if (model.session == null) unlocked = false
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialTheme {
|
||||||
|
Surface(Modifier.fillMaxSize()) {
|
||||||
|
when {
|
||||||
|
model.session == null -> EnhancedLoginScreen(model) {
|
||||||
|
passwordLoginInFlight = true
|
||||||
|
}
|
||||||
|
enabled && capable && !unlocked -> BiometricUnlockScreen(
|
||||||
|
activity = activity,
|
||||||
|
displayName = model.session?.displayName.orEmpty(),
|
||||||
|
onAuthenticated = { unlocked = true },
|
||||||
|
onUsePassword = {
|
||||||
|
setBiometricEnabled(context, false)
|
||||||
|
enabled = false
|
||||||
|
model.logout()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
else -> DhApp(model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EnhancedLoginScreen(model: MainViewModel, onPasswordLogin: () -> Unit) {
|
||||||
|
var identifier by rememberSaveable { mutableStateOf("") }
|
||||||
|
var password by rememberSaveable { mutableStateOf("") }
|
||||||
|
var passwordVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||||
|
Text("Aplicación de campo · Dirección de Hidrocarburos")
|
||||||
|
model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) }
|
||||||
|
OutlinedTextField(
|
||||||
|
value = identifier,
|
||||||
|
onValueChange = { identifier = it },
|
||||||
|
label = { Text("Usuario o email") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = password,
|
||||||
|
onValueChange = { password = it },
|
||||||
|
label = { Text("Contraseña") },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (passwordVisible) Icons.Filled.VisibilityOff else Icons.Filled.Visibility,
|
||||||
|
contentDescription = if (passwordVisible) "Ocultar contraseña" else "Mostrar contraseña",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
onPasswordLogin()
|
||||||
|
model.login(identifier, password)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !model.busy,
|
||||||
|
) { Text(if (model.busy) "Ingresando…" else "Ingresar") }
|
||||||
|
Text(
|
||||||
|
"Después del primer ingreso correcto, si la tablet tiene una huella fuerte configurada, se habilita el acceso biométrico. La contraseña no se almacena.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun BiometricUnlockScreen(
|
||||||
|
activity: FragmentActivity,
|
||||||
|
displayName: String,
|
||||||
|
onAuthenticated: () -> Unit,
|
||||||
|
onUsePassword: () -> Unit,
|
||||||
|
) {
|
||||||
|
var error by rememberSaveable { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
fun authenticate() {
|
||||||
|
val executor = ContextCompat.getMainExecutor(activity)
|
||||||
|
val prompt = BiometricPrompt(
|
||||||
|
activity,
|
||||||
|
executor,
|
||||||
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||||
|
super.onAuthenticationSucceeded(result)
|
||||||
|
error = null
|
||||||
|
onAuthenticated()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||||
|
super.onAuthenticationError(errorCode, errString)
|
||||||
|
error = errString.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthenticationFailed() {
|
||||||
|
super.onAuthenticationFailed()
|
||||||
|
error = "Huella no reconocida."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val info = BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle("Ingresar a DH Inspección")
|
||||||
|
.setSubtitle(if (displayName.isBlank()) "Validá tu identidad" else "Hola, $displayName")
|
||||||
|
.setNegativeButtonText("Usar contraseña")
|
||||||
|
.setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
|
||||||
|
.build()
|
||||||
|
prompt.authenticate(info)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) { authenticate() }
|
||||||
|
|
||||||
|
Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||||
|
Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text("DH Inspección", style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
|
||||||
|
Text("Ingresá con tu huella")
|
||||||
|
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||||
|
Button(onClick = { authenticate() }, modifier = Modifier.fillMaxWidth()) { Text("Usar huella") }
|
||||||
|
OutlinedButton(onClick = onUsePassword, modifier = Modifier.fillMaxWidth()) { Text("Ingresar con contraseña") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user