commit 77c2ded4827fa0801e0f0de8d1c34f47b45dafc1 Author: Pablo Date: Mon Mar 9 22:05:28 2026 +0100 Initial version -- added millennium read funcionality diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 0000000..4a53bee --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..371f2e2 --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..b86273d --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 0000000..1f2ea11 --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml new file mode 100644 index 0000000..b268ef3 --- /dev/null +++ b/.idea/deploymentTargetSelector.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..cdbc250 --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..f0c6ad0 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,50 @@ + + + + \ No newline at end of file diff --git a/.idea/markdown.xml b/.idea/markdown.xml new file mode 100644 index 0000000..c61ea33 --- /dev/null +++ b/.idea/markdown.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..a4f09e2 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..16660f1 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,17 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/INSTALLATION_GUIDE.md b/INSTALLATION_GUIDE.md new file mode 100644 index 0000000..87ebae9 --- /dev/null +++ b/INSTALLATION_GUIDE.md @@ -0,0 +1,173 @@ +# Guía de Instalación y Uso - iTartanas + +## 📱 Instalación + +### Opción 1: Desde Android Studio + +1. Abre el proyecto en Android Studio +2. Conecta tu dispositivo Android via USB o usa un emulador con soporte NFC +3. Haz clic en el botón "Run" (▶️) o presiona `Shift + F10` +4. Selecciona tu dispositivo y espera a que se instale + +### Opción 2: Compilar APK + +```bash +# Desde la terminal en la raíz del proyecto +.\gradlew assembleDebug + +# El APK se generará en: +# app\build\outputs\apk\debug\app-debug.apk +``` + +Luego puedes transferir el APK a tu dispositivo Android e instalarlo manualmente. + +## ✅ Requisitos del Dispositivo + +- **Android 7.0 (API 24) o superior** +- **Hardware NFC** (obligatorio) +- **NFC activado** en la configuración del dispositivo + +### Cómo activar NFC: + +1. Ve a **Configuración** → **Conexiones** (o **Dispositivos conectados**) +2. Busca la opción **NFC** +3. Activa el interruptor NFC +4. También puedes activar **Android Beam** si está disponible + +## 📖 Cómo usar la aplicación + +### Paso 1: Abrir la aplicación + +Abre iTartanas desde el cajón de aplicaciones. Verás una pantalla con el mensaje: + +``` +"Acerca tu Tarjeta Millennium al lector NFC" +``` + +### Paso 2: Acercar la tarjeta + +1. Mantén tu **Tarjeta Millennium** cerca del lector NFC de tu dispositivo +2. El lector NFC suele estar en la parte trasera del teléfono, cerca de la cámara +3. Mantén la tarjeta estable durante 1-2 segundos + +### Paso 3: Ver los resultados + +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 €`) + +## 🔍 Ubicación del lector NFC + +La ubicación del lector NFC varía según el dispositivo: + +- **Samsung**: Parte trasera central o superior +- **Google Pixel**: Parte trasera central +- **Xiaomi**: Parte trasera central o superior +- **OnePlus**: Parte trasera cerca de la cámara + +💡 **Tip**: Puedes buscar en Internet "ubicación NFC [modelo de tu teléfono]" para encontrar la posición exacta. + +## ⚠️ Solución de problemas + +### La aplicación no detecta la tarjeta + +**Problema**: Al acercar la tarjeta no pasa nada. + +**Soluciones**: +1. Verifica que el NFC esté activado en tu dispositivo +2. Asegúrate de acercar la tarjeta a la zona correcta del lector NFC +3. Mantén la tarjeta estable durante al menos 2 segundos +4. Retira cualquier funda metálica o magnética del teléfono +5. Reinicia la aplicación + +### Error: "Este dispositivo no tiene NFC" + +**Problema**: Tu dispositivo no tiene hardware NFC. + +**Solución**: Lamentablemente, necesitas un dispositivo con NFC para usar esta aplicación. Consulta las especificaciones de tu dispositivo. + +### Error al leer la tarjeta + +**Problema**: La aplicación detecta la tarjeta pero muestra un error. + +**Soluciones**: +1. Intenta acercar la tarjeta de nuevo +2. Limpia suavemente la tarjeta con un paño seco +3. Asegúrate de que estás usando una Tarjeta Millennium válida +4. Verifica que la tarjeta no esté dañada +5. Intenta en una zona con menos interferencias electromagnéticas + +### "Por favor, activa el NFC en configuración" + +**Problema**: El NFC está desactivado. + +**Solución**: Ve a Configuración → Conexiones → NFC y actívalo. + +## 📊 Información mostrada + +### Número de Tarjeta +- Formato: 16 dígitos en grupos de 4 +- Los primeros 8 dígitos están enmascarados con asteriscos (*) +- Ejemplo: `**** **** 1234 5678` + +### Saldo +- Formato: Euros con 2 decimales +- Ejemplo: `15.75 €` +- Se obtiene directamente del chip de la tarjeta + +## 🔒 Privacidad y Seguridad + +- ✅ La aplicación **NO** envía datos a Internet +- ✅ **NO** guarda información de tu tarjeta +- ✅ Solo lee los datos visibles en el chip NFC +- ✅ No puede modificar el saldo ni los datos de la tarjeta +- ✅ El número de tarjeta se enmascara parcialmente por seguridad + +## 🛠️ Desarrollo + +### Compilar desde código fuente + +```bash +# Clonar el repositorio (si aplica) +git clone [URL_del_repositorio] +cd iTartanas + +# Compilar +.\gradlew build + +# Ejecutar tests +.\gradlew test + +# Instalar en dispositivo conectado +.\gradlew installDebug +``` + +### Dependencias principales + +- Kotlin 2.1.0 +- Jetpack Compose BOM +- Material 3 +- Lifecycle Runtime KTX +- Android NFC API + +## 📞 Soporte + +Si encuentras algún problema o tienes sugerencias, por favor: + +1. Verifica que tu tarjeta Millennium sea compatible +2. Asegúrate de tener la última versión de la aplicación +3. Revisa la sección de solución de problemas de este documento + +## 📝 Notas adicionales + +- La aplicación solo funciona con **Tarjetas Millennium** del sistema de transporte público +- No funcionará con otras tarjetas NFC (tarjetas bancarias, pases de acceso, etc.) +- La lectura es de solo lectura, no puede modificar el saldo +- Los datos se leen en tiempo real desde el chip de la tarjeta + +--- + +**Versión**: 1.0 +**Última actualización**: Marzo 2026 + diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b46fe8 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# iTartanas - Lector de Tarjeta Millennium + +Aplicación Android para leer tarjetas Millennium del sistema de transporte público mediante NFC. + +## Características + +✅ **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 +✅ **Interfaz moderna**: Diseño con Material 3 y Jetpack Compose +✅ **Detección automática**: Lee la tarjeta automáticamente al acercarla al dispositivo + +## Requisitos + +- Dispositivo Android con NFC +- Android 7.0 (API 24) o superior +- NFC activado en el dispositivo + +## Funcionalidades implementadas + +La aplicación implementa las siguientes funcionalidades basadas en el código original de iTranvias: + +1. **Comunicación NFC con protocolo ISO-DEP** + - Selección de red de transporte + - Lectura de entorno + - Lectura de contrato + +2. **Procesamiento de datos** + - Decodificación de números de tarjeta BCD a decimal + - Conversión de PAN a IEP según aplicación + - Cálculo de dígito de verificación Luhn + - Enmascaramiento de datos sensibles + - Conversión de saldo de céntimos a euros + +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 + - Manejo de errores con mensajes descriptivos + +## Cómo usar + +1. Instala la aplicación en tu dispositivo Android +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 + +## Tecnologías utilizadas + +- **Kotlin**: Lenguaje de programación principal +- **Jetpack Compose**: Framework de UI moderna +- **Material 3**: Diseño de interfaz +- **Coroutines**: Manejo de operaciones asíncronas +- **NFC Android API**: Comunicación con tarjetas NFC/ISO-DEP + +## Estructura del proyecto + +``` +app/src/main/java/com/pjpmosteiro/itartanas/ +├── MainActivity.kt # Actividad principal con UI +├── nfc/ +│ └── CardRepositoryImpl.kt # Lógica de lectura NFC y procesamiento +└── ui/theme/ # Tema de la aplicación +``` + +## Permisos + +La aplicación requiere los siguientes permisos: + +- `android.permission.NFC`: Para acceder al hardware NFC +- `android.hardware.nfc`: Hardware NFC requerido + +## Notas técnicas + +### Protocolo de comunicación + +La aplicación utiliza comandos APDU para comunicarse con la tarjeta: + +1. **SELECT Transport Network** (0x80 0x26 0x4F 0x11 0x0A) +2. **READ Environment** (0x80 0x32 0x00 0x00 0x18) +3. **READ Contract** (0x80 0x2E 0x01 0x00 0x20) + +### Formato de datos + +- **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 + +## Créditos + +Basado en el análisis del código decompilado de la aplicación iTranvias original. + +## Licencia + +Este proyecto es de código abierto para fines educativos y de aprendizaje. + diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..68a3c17 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,58 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.pjpmosteiro.itartanas" + compileSdk { + version = release(36) { + minorApiLevel = 1 + } + } + + defaultConfig { + applicationId = "com.pjpmosteiro.itartanas" + minSdk = 24 + targetSdk = 36 + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + buildFeatures { + compose = true + } +} + +dependencies { + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.graphics) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.compose.ui.test.junit4) + debugImplementation(libs.androidx.compose.ui.tooling) + debugImplementation(libs.androidx.compose.ui.test.manifest) +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/androidTest/java/com/pjpmosteiro/itartanas/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/pjpmosteiro/itartanas/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..e47b1d0 --- /dev/null +++ b/app/src/androidTest/java/com/pjpmosteiro/itartanas/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.pjpmosteiro.itartanas + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.pjpmosteiro.itartanas", appContext.packageName) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c364e7f --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/pjpmosteiro/itartanas/MainActivity.kt b/app/src/main/java/com/pjpmosteiro/itartanas/MainActivity.kt new file mode 100644 index 0000000..0f5d537 --- /dev/null +++ b/app/src/main/java/com/pjpmosteiro/itartanas/MainActivity.kt @@ -0,0 +1,282 @@ +package com.pjpmosteiro.itartanas + +import android.app.PendingIntent +import android.content.Intent +import android.nfc.NfcAdapter +import android.nfc.Tag +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.lifecycleScope +import com.pjpmosteiro.itartanas.nfc.CardData +import com.pjpmosteiro.itartanas.nfc.CardRepositoryImpl +import com.pjpmosteiro.itartanas.ui.theme.ITartanasTheme +import kotlinx.coroutines.launch +import java.util.Locale + +class MainActivity : ComponentActivity() { + private var nfcAdapter: NfcAdapter? = null + private var pendingIntent: PendingIntent? = null + private val cardRepository = CardRepositoryImpl() + + private var cardData by mutableStateOf(null) + private var isReading by mutableStateOf(false) + private var errorMessage by mutableStateOf(null) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Inicializar NFC + nfcAdapter = NfcAdapter.getDefaultAdapter(this) + + if (nfcAdapter == null) { + Toast.makeText(this, "Este dispositivo no tiene NFC", Toast.LENGTH_LONG).show() + } else if (nfcAdapter?.isEnabled == false) { + Toast.makeText(this, "Por favor, activa el NFC en configuración", Toast.LENGTH_LONG).show() + } + + val intent = Intent(this, javaClass).apply { + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + pendingIntent = PendingIntent.getActivity( + this, 0, intent, + PendingIntent.FLAG_MUTABLE + ) + + enableEdgeToEdge() + setContent { + ITartanasTheme { + CardReaderScreen( + cardData = cardData, + isReading = isReading, + errorMessage = errorMessage + ) + } + } + + // Procesar intent si viene de NFC + handleIntent(intent) + } + + override fun onResume() { + super.onResume() + nfcAdapter?.enableForegroundDispatch(this, pendingIntent, null, null) + } + + override fun onPause() { + super.onPause() + nfcAdapter?.disableForegroundDispatch(this) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleIntent(intent) + } + + private fun handleIntent(intent: Intent) { + if (NfcAdapter.ACTION_TECH_DISCOVERED == intent.action || + NfcAdapter.ACTION_TAG_DISCOVERED == intent.action) { + + val tag: Tag? = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) + } + if (tag != null) { + readCard(tag) + } + } + } + + private fun readCard(tag: Tag) { + isReading = true + errorMessage = null + + lifecycleScope.launch { + try { + val result = cardRepository.getCard(tag) + result.onSuccess { data -> + cardData = data + errorMessage = null + }.onFailure { error -> + errorMessage = "Error al leer la tarjeta: ${error.message}" + Toast.makeText( + this@MainActivity, + errorMessage, + Toast.LENGTH_LONG + ).show() + } + } catch (e: Exception) { + errorMessage = "Error inesperado: ${e.message}" + Toast.makeText( + this@MainActivity, + errorMessage, + Toast.LENGTH_LONG + ).show() + } finally { + isReading = false + } + } + } +} + +@Composable +fun CardReaderScreen( + cardData: CardData?, + isReading: Boolean, + errorMessage: String? +) { + Scaffold( + modifier = Modifier.fillMaxSize() + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = "Lector de Tarjeta Millennium", + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 32.dp) + ) + + when { + isReading -> { + CircularProgressIndicator( + modifier = Modifier + .size(64.dp) + .padding(bottom = 16.dp) + ) + Text("Leyendo tarjeta...") + } + + cardData != null -> { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + ) { + Text( + text = "Número de Tarjeta", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.secondary + ) + Text( + text = cardData.id, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(top = 4.dp, bottom = 16.dp) + ) + + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + Text( + text = "Saldo", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.secondary + ) + Text( + text = if (cardData.balance != null) { + String.format(Locale.getDefault(), "%.2f €", cardData.balance) + } else { + "No disponible" + }, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 4.dp) + ) + } + } + + Text( + text = "✓ Tarjeta leída correctamente", + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(top = 16.dp) + ) + } + + errorMessage != null -> { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp) + ) { + Text( + text = "Error", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error + ) + Text( + text = errorMessage, + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(top = 8.dp) + ) + } + } + } + + else -> { + Icon( + painter = androidx.compose.ui.res.painterResource( + android.R.drawable.ic_dialog_info + ), + contentDescription = "NFC Icon", + modifier = Modifier + .size(120.dp) + .padding(bottom = 24.dp), + tint = MaterialTheme.colorScheme.primary + ) + + Text( + text = "Acerca tu Tarjeta Millennium al lector NFC", + fontSize = 16.sp, + textAlign = TextAlign.Center, + modifier = Modifier.padding(bottom = 8.dp) + ) + + Text( + text = "La app leerá automáticamente el número de tarjeta y el saldo", + fontSize = 14.sp, + textAlign = TextAlign.Center, + 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 new file mode 100644 index 0000000..3e92852 --- /dev/null +++ b/app/src/main/java/com/pjpmosteiro/itartanas/nfc/CardRepositoryImpl.kt @@ -0,0 +1,165 @@ +package com.pjpmosteiro.itartanas.nfc + +import android.nfc.Tag +import android.nfc.tech.IsoDep +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +data class CardData( + val id: String, + val balance: Double? = null, + val lastOperation: String? = null +) + +class CardRepositoryImpl( + private val dispatcher: CoroutineDispatcher = Dispatchers.IO +) { + + suspend fun getCard(tag: Tag): Result = withContext(dispatcher) { + try { + val isoDep = IsoDep.get(tag) + isoDep.connect() + + // Seleccionar red de transporte + val transportNetworkResponse = selectTransportNetwork(isoDep) + + // Leer entorno + readEnvironment(isoDep) + + // Leer contrato + val contract = readContract(isoDep) + + // Obtener ID de tarjeta + val cardId = getCardId(transportNetworkResponse) + + // Obtener saldo (bytes 29-31, 3 bytes, big endian, en céntimos) + val balance = toIntBigEndian(contract, 29, 3) / 100.0 + + isoDep.close() + + Result.success(CardData(cardId, balance)) + } catch (e: Exception) { + Result.failure(e) + } + } + + private fun getCardId(transportNetworkResponse: ByteArray): String { + val bArrSliceArray = transportNetworkResponse.sliceArray(2 until transportNetworkResponse.size - 2) + var strBcdToDecString = bcdToDecString(bArrSliceArray) + + val strSubstring = strBcdToDecString.substring(6, 8) + val str = when { + strSubstring == "04" || !strBcdToDecString.startsWith("987020") -> { + if (strSubstring != "04") "00" else null + } + else -> "01" + } + + if (str != null) { + val strPanToIep = panToIep(bArrSliceArray, str) + if (strPanToIep != null) { + strBcdToDecString = strPanToIep + } + } + + return groupEvery(maskFirstNChars(strBcdToDecString, 8), 4) + } + + private fun selectTransportNetwork(isoDep: IsoDep): ByteArray { + return sendApdu(isoDep, byteArrayOf(0x80.toByte(), 0x26, 0x4F, 0x11, 0x0A), 12) + } + + private fun readEnvironment(isoDep: IsoDep): ByteArray { + return sendApdu(isoDep, byteArrayOf(0x80.toByte(), 0x32, 0x00, 0x00, 0x18), 26) + } + + private fun readContract(isoDep: IsoDep): ByteArray { + return sendApdu(isoDep, byteArrayOf(0x80.toByte(), 0x2E, 0x01, 0x00, 0x20), 34) + } + + private fun sendApdu(isoDep: IsoDep, command: ByteArray, responseSize: Int, statusWord: String = "9000"): ByteArray { + val response = isoDep.transceive(command) + requireSize(response, responseSize) + requireStatusWord(response, statusWord) + return response + } + + private fun requireSize(response: ByteArray, size: Int) { + if (response.size != size) { + throw IllegalArgumentException("APDU response size error: expected $size bytes but was ${response.size}") + } + } + + private fun requireStatusWord(response: ByteArray, statusWord: String) { + val sw = String.format("%02X%02X", response[response.size - 2], response[response.size - 1]) + if (statusWord != sw) { + throw IllegalArgumentException("APDU error: expected status word $statusWord but was $sw") + } + } + + private fun toIntBigEndian(arr: ByteArray, offset: Int, length: Int): Int { + if (offset < 0 || offset + length > arr.size) { + throw IllegalArgumentException("Array too small: need $length bytes at offset $offset but size is ${arr.size}") + } + var result = 0 + for (i in 0 until length) { + result = (result shl 8) or (arr[offset + i].toInt() and 0xFF) + } + return result + } + + private fun bcdToDecString(arr: ByteArray): String { + if (arr.size != 8) { + throw IllegalArgumentException("8 BCD bytes were expected from PAN, but they were ${arr.size}") + } + return arr.joinToString("") { byte -> + val i = byte.toInt() and 0xFF + val high = i ushr 4 + val low = byte.toInt() and 0x0F + if (high >= 10 || low >= 10) { + throw IllegalArgumentException("Byte is not valid BCD: ${String.format("%02X", i)}") + } + "$high$low" + } + } + + private fun panToIep(arr: ByteArray, apli: String): String? { + if (apli.length != 2) { + throw IllegalArgumentException("Apli must have exactly 2 characters") + } + val pan16 = bcdToDecString(arr).padEnd(16, '0') + val result = pan16.substring(0, 6) + apli + pan16.substring(8, 15) + "0" + val partial = result.substring(0, 15) + return partial + luhnCheckDigit(result) + } + + private fun luhnCheckDigit(str: String): Char { + var sum = 0 + var i = 0 + str.forEach { char -> + i++ + var digit = char.digitToInt() + if (i % 2 == (str.length + 1) % 2) { + digit *= 2 + } + if (digit > 9) { + digit -= 9 + } + sum += digit + } + return ((10 - (sum % 10)) % 10).digitToChar() + } + + private fun maskFirstNChars(str: String, n: Int): String { + if (str.length <= n) { + return "*".repeat(str.length) + } + return "*".repeat(n) + str.substring(n) + } + + private fun groupEvery(str: String, n: Int, separator: String = " "): String { + return str.chunked(n).joinToString(separator) + } +} + diff --git a/app/src/main/java/com/pjpmosteiro/itartanas/ui/theme/Color.kt b/app/src/main/java/com/pjpmosteiro/itartanas/ui/theme/Color.kt new file mode 100644 index 0000000..cf4abb1 --- /dev/null +++ b/app/src/main/java/com/pjpmosteiro/itartanas/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.pjpmosteiro.itartanas.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/pjpmosteiro/itartanas/ui/theme/Theme.kt b/app/src/main/java/com/pjpmosteiro/itartanas/ui/theme/Theme.kt new file mode 100644 index 0000000..fa92284 --- /dev/null +++ b/app/src/main/java/com/pjpmosteiro/itartanas/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.pjpmosteiro.itartanas.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun ITartanasTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/pjpmosteiro/itartanas/ui/theme/Type.kt b/app/src/main/java/com/pjpmosteiro/itartanas/ui/theme/Type.kt new file mode 100644 index 0000000..c5761f3 --- /dev/null +++ b/app/src/main/java/com/pjpmosteiro/itartanas/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.pjpmosteiro.itartanas.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..6f3b755 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..c209e78 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..b2dfe3d Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..4f0f1d6 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..62b611d Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..948a307 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..1b9a695 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..28d4b77 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9287f50 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..aa7d642 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..9126ae3 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..f652ff4 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + iTartanas + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..86dd740 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +