Improvements on reading - optional on last movements
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user