diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md index 87ebae9..880a9b1 100644 --- a/INSTALLATION_GUIDE.md +++ b/INSTALLATION_GUIDE.md @@ -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 diff --git a/README.md b/README.md index 1b46fe8..0bb0988 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/src/main/java/com/pjpmosteiro/itartanas/MainActivity.kt b/app/src/main/java/com/pjpmosteiro/itartanas/MainActivity.kt index 0f5d537..7042f7d 100644 --- a/app/src/main/java/com/pjpmosteiro/itartanas/MainActivity.kt +++ b/app/src/main/java/com/pjpmosteiro/itartanas/MainActivity.kt @@ -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 + ) + } } } diff --git a/app/src/main/java/com/pjpmosteiro/itartanas/nfc/CardRepositoryImpl.kt b/app/src/main/java/com/pjpmosteiro/itartanas/nfc/CardRepositoryImpl.kt index 3e92852..b86e7e1 100644 --- a/app/src/main/java/com/pjpmosteiro/itartanas/nfc/CardRepositoryImpl.kt +++ b/app/src/main/java/com/pjpmosteiro/itartanas/nfc/CardRepositoryImpl.kt @@ -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 }