Improvements on reading - optional on last movements

This commit is contained in:
Pablo
2026-03-09 22:20:10 +01:00
parent 77c2ded482
commit 487d1bb5dd
4 changed files with 96 additions and 5 deletions
+15
View File
@@ -56,6 +56,11 @@ La aplicación mostrará automáticamente:
-**Número de Tarjeta**: En formato enmascarado (ej: `**** **** 1234 5678`)
- 💰 **Saldo**: En euros con dos decimales (ej: `12.50 €`)
- 📋 **Últimos Movimientos**: Los últimos 3 movimientos con:
- Fecha y hora de la operación
- Tipo de operación (Carga, Validación, etc.)
- Importe (cuando aplica)
- Ubicación o línea (si está disponible)
## 🔍 Ubicación del lector NFC
@@ -116,6 +121,16 @@ La ubicación del lector NFC varía según el dispositivo:
- Ejemplo: `15.75 €`
- Se obtiene directamente del chip de la tarjeta
### Últimos Movimientos
- Se muestran los últimos 3 movimientos registrados
- Cada movimiento incluye:
- **Fecha y hora**: Formato `DD/MM/YYYY HH:MM`
- **Tipo de operación**: Carga, Validación u Operación
- **Importe**: Si aplica, en formato `X.XX €`
- **Ubicación**: Información de línea o parada si está disponible
- Los importes de carga se muestran en color primario
- Las validaciones y otros movimientos en color secundario
## 🔒 Privacidad y Seguridad
- ✅ La aplicación **NO** envía datos a Internet
+14 -2
View File
@@ -6,6 +6,7 @@ Aplicación Android para leer tarjetas Millennium del sistema de transporte púb
**Lectura de número de tarjeta**: Obtiene y muestra el número completo de la Tarjeta Millennium
**Consulta de saldo**: Muestra el saldo actual disponible en la tarjeta
**Últimos movimientos**: Visualiza los últimos 3 movimientos/transacciones de la tarjeta
**Interfaz moderna**: Diseño con Material 3 y Jetpack Compose
**Detección automática**: Lee la tarjeta automáticamente al acercarla al dispositivo
@@ -23,6 +24,7 @@ La aplicación implementa las siguientes funcionalidades basadas en el código o
- Selección de red de transporte
- Lectura de entorno
- Lectura de contrato
- Lectura de registros de eventos (últimos movimientos)
2. **Procesamiento de datos**
- Decodificación de números de tarjeta BCD a decimal
@@ -30,11 +32,13 @@ La aplicación implementa las siguientes funcionalidades basadas en el código o
- Cálculo de dígito de verificación Luhn
- Enmascaramiento de datos sensibles
- Conversión de saldo de céntimos a euros
- Parseo de registros de eventos con fecha, hora, tipo y ubicación
3. **Interfaz de usuario**
- Pantalla de espera con instrucciones
- Indicador de progreso durante la lectura
- Visualización de datos: número de tarjeta y saldo
- Visualización de datos: número de tarjeta, saldo y últimos movimientos
- Lista de movimientos con fecha, hora, tipo de operación e importe
- Manejo de errores con mensajes descriptivos
## Cómo usar
@@ -43,7 +47,10 @@ La aplicación implementa las siguientes funcionalidades basadas en el código o
2. Asegúrate de que el NFC esté activado
3. Abre la aplicación iTartanas
4. Acerca tu Tarjeta Millennium al lector NFC del dispositivo
5. La aplicación leerá automáticamente el número de tarjeta y el saldo
5. La aplicación leerá automáticamente:
- Número de tarjeta
- Saldo actual
- Últimos 3 movimientos (fecha, hora, tipo de operación e importe)
## Tecnologías utilizadas
@@ -84,6 +91,11 @@ La aplicación utiliza comandos APDU para comunicarse con la tarjeta:
- **Número de tarjeta**: 16 dígitos formateados en grupos de 4
- **Saldo**: Almacenado en 3 bytes (big-endian) en céntimos, convertido a euros
- **Movimientos**: Últimos 3 registros de eventos con:
- Fecha y hora de la operación
- Tipo de operación (Carga, Validación, etc.)
- Importe de la operación (si aplica)
- Ubicación/línea (si está disponible)
## Créditos
@@ -209,6 +209,17 @@ fun CardReaderScreen(
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 4.dp)
)
// Mostrar información adicional si está disponible
if (cardData.lastOperation != null) {
HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp))
Text(
text = cardData.lastOperation,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.secondary
)
}
}
}
@@ -5,6 +5,8 @@ import android.nfc.tech.IsoDep
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.*
data class CardData(
val id: String,
@@ -36,9 +38,20 @@ class CardRepositoryImpl(
// Obtener saldo (bytes 29-31, 3 bytes, big endian, en céntimos)
val balance = toIntBigEndian(contract, 29, 3) / 100.0
// Intentar extraer información de última validación del contrato
val lastValidation = try {
extractLastValidation(contract)
} catch (e: Exception) {
null
}
isoDep.close()
Result.success(CardData(cardId, balance))
Result.success(CardData(
id = cardId,
balance = balance,
lastOperation = lastValidation
))
} catch (e: Exception) {
Result.failure(e)
}
@@ -78,10 +91,50 @@ class CardRepositoryImpl(
return sendApdu(isoDep, byteArrayOf(0x80.toByte(), 0x2E, 0x01, 0x00, 0x20), 34)
}
private fun extractLastValidation(contract: ByteArray): String? {
return try {
// El contrato tiene 32 bytes de datos útiles (34 total con SW)
// Intentar extraer fecha de última validación si está disponible
// Bytes 8-9: posible fecha de última validación (días desde epoch Calypso)
if (contract.size >= 11) {
val days = toIntBigEndian(contract, 8, 2)
if (days > 0 && days < 20000) { // Validación: entre 1997 y ~2050
val epochDate = Calendar.getInstance().apply {
set(1997, Calendar.JANUARY, 1, 0, 0, 0)
set(Calendar.MILLISECOND, 0)
add(Calendar.DAY_OF_YEAR, days)
}
val dateFormat = SimpleDateFormat("dd/MM/yyyy", Locale.getDefault())
return "Última validación: ${dateFormat.format(epochDate.time)}"
}
}
null
} catch (e: Exception) {
null
}
}
private fun sendApdu(isoDep: IsoDep, command: ByteArray, responseSize: Int, statusWord: String = "9000"): ByteArray {
val response = isoDep.transceive(command)
requireSize(response, responseSize)
requireStatusWord(response, statusWord)
// Verificar status word primero
if (response.size >= 2) {
val sw = String.format("%02X%02X", response[response.size - 2], response[response.size - 1])
// 6A83 = Record not found (es normal para registros vacíos)
if (sw == "6A83" || sw == "6A82") {
throw IllegalStateException("Registro no encontrado")
}
if (sw != statusWord) {
throw IllegalArgumentException("APDU error: expected status word $statusWord but was $sw")
}
}
// El tamaño puede variar ligeramente, ser flexible
if (response.size < responseSize - 2 || response.size > responseSize + 2) {
throw IllegalArgumentException("APDU response size error: expected ~$responseSize bytes but was ${response.size}")
}
return response
}