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`)
|
- ✅ **Número de Tarjeta**: En formato enmascarado (ej: `**** **** 1234 5678`)
|
||||||
- 💰 **Saldo**: En euros con dos decimales (ej: `12.50 €`)
|
- 💰 **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
|
## 🔍 Ubicación del lector NFC
|
||||||
|
|
||||||
@@ -116,6 +121,16 @@ La ubicación del lector NFC varía según el dispositivo:
|
|||||||
- Ejemplo: `15.75 €`
|
- Ejemplo: `15.75 €`
|
||||||
- Se obtiene directamente del chip de la tarjeta
|
- 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
|
## 🔒 Privacidad y Seguridad
|
||||||
|
|
||||||
- ✅ La aplicación **NO** envía datos a Internet
|
- ✅ 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
|
✅ **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
|
✅ **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
|
✅ **Interfaz moderna**: Diseño con Material 3 y Jetpack Compose
|
||||||
✅ **Detección automática**: Lee la tarjeta automáticamente al acercarla al dispositivo
|
✅ **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
|
- Selección de red de transporte
|
||||||
- Lectura de entorno
|
- Lectura de entorno
|
||||||
- Lectura de contrato
|
- Lectura de contrato
|
||||||
|
- Lectura de registros de eventos (últimos movimientos)
|
||||||
|
|
||||||
2. **Procesamiento de datos**
|
2. **Procesamiento de datos**
|
||||||
- Decodificación de números de tarjeta BCD a decimal
|
- 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
|
- Cálculo de dígito de verificación Luhn
|
||||||
- Enmascaramiento de datos sensibles
|
- Enmascaramiento de datos sensibles
|
||||||
- Conversión de saldo de céntimos a euros
|
- 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**
|
3. **Interfaz de usuario**
|
||||||
- Pantalla de espera con instrucciones
|
- Pantalla de espera con instrucciones
|
||||||
- Indicador de progreso durante la lectura
|
- 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
|
- Manejo de errores con mensajes descriptivos
|
||||||
|
|
||||||
## Cómo usar
|
## 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
|
2. Asegúrate de que el NFC esté activado
|
||||||
3. Abre la aplicación iTartanas
|
3. Abre la aplicación iTartanas
|
||||||
4. Acerca tu Tarjeta Millennium al lector NFC del dispositivo
|
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
|
## 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
|
- **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
|
- **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
|
## Créditos
|
||||||
|
|
||||||
|
|||||||
@@ -209,6 +209,17 @@ fun CardReaderScreen(
|
|||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.padding(top = 4.dp)
|
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.CoroutineDispatcher
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
data class CardData(
|
data class CardData(
|
||||||
val id: String,
|
val id: String,
|
||||||
@@ -36,9 +38,20 @@ class CardRepositoryImpl(
|
|||||||
// Obtener saldo (bytes 29-31, 3 bytes, big endian, en céntimos)
|
// Obtener saldo (bytes 29-31, 3 bytes, big endian, en céntimos)
|
||||||
val balance = toIntBigEndian(contract, 29, 3) / 100.0
|
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()
|
isoDep.close()
|
||||||
|
|
||||||
Result.success(CardData(cardId, balance))
|
Result.success(CardData(
|
||||||
|
id = cardId,
|
||||||
|
balance = balance,
|
||||||
|
lastOperation = lastValidation
|
||||||
|
))
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Result.failure(e)
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
@@ -78,10 +91,50 @@ class CardRepositoryImpl(
|
|||||||
return sendApdu(isoDep, byteArrayOf(0x80.toByte(), 0x2E, 0x01, 0x00, 0x20), 34)
|
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 {
|
private fun sendApdu(isoDep: IsoDep, command: ByteArray, responseSize: Int, statusWord: String = "9000"): ByteArray {
|
||||||
val response = isoDep.transceive(command)
|
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
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user