package sh.sar.basedbank.nfc import android.nfc.Tag import android.nfc.tech.IsoDep import java.io.ByteArrayOutputStream /** * Minimal contactless EMV reader: selects the payment app, runs GPO and reads the * AFL records until it finds the PAN (tag 5A / Track 2 tag 57) and expiry (5F24 / Track 2). */ object EmvCardReader { /** [expiry] is "MM/YY". */ data class CardData(val pan: String, val expiry: String?) private class Collected { var pan: String? = null var expiry: String? = null val complete get() = pan != null && expiry != null fun result() = pan?.let { CardData(it, expiry) } } /** Returns the card data, or null if the PAN couldn't be read. Blocking — call off the main thread. */ fun read(tag: Tag): CardData? { val iso = IsoDep.get(tag) ?: return null val c = Collected() iso.use { it.connect() it.timeout = 5000 val aids = selectPpse(it).ifEmpty { KNOWN_AIDS } for (aid in aids) { val fci = transceive(it, selectApdu(aid)) ?: continue val pdol = findTag(fci, 0x9F38) val gpo = transceive(it, gpoApdu(pdol)) ?: continue collect(gpo, c) if (c.complete) return c.result() // Format 1 (tag 80): AIP (2 bytes) + AFL. Format 2 (tag 77): AFL in tag 94. val afl = findTag(gpo, 0x94) ?: findTag(gpo, 0x80)?.let { b -> if (b.size > 2) b.copyOfRange(2, b.size) else null } ?: continue for (i in 0 until afl.size / 4) { val sfi = (afl[i * 4].toInt() and 0xFF) shr 3 val first = afl[i * 4 + 1].toInt() and 0xFF val last = afl[i * 4 + 2].toInt() and 0xFF for (rec in first..last) { val data = transceive(it, readRecordApdu(sfi, rec)) ?: continue collect(data, c) if (c.complete) return c.result() } } if (c.pan != null) return c.result() } } return c.result() } private val KNOWN_AIDS = listOf( "A0000000031010", // Visa "A0000000041010", // Mastercard "A0000000043060", // Maestro "A000000025010801", // Amex "A0000003330101", // UnionPay ).map { hex(it) } private fun selectPpse(iso: IsoDep): List { val resp = transceive(iso, selectApdu("2PAY.SYS.DDF01".toByteArray())) ?: return emptyList() return findAllTags(resp, 0x4F) } private fun collect(data: ByteArray, c: Collected) { findTag(data, 0x5A)?.let { c.pan = c.pan ?: toHex(it).trimEnd('F') } findTag(data, 0x57)?.let { raw -> val t2 = toHex(raw) c.pan = c.pan ?: t2.substringBefore('D') // Track 2: PAN 'D' YYMM service-code ... val yymm = t2.substringAfter('D', "").take(4) if (c.expiry == null && yymm.length == 4) c.expiry = "${yymm.substring(2, 4)}/${yymm.substring(0, 2)}" } findTag(data, 0x5F24)?.let { raw -> val yymmdd = toHex(raw) if (yymmdd.length >= 4) c.expiry = "${yymmdd.substring(2, 4)}/${yymmdd.substring(0, 2)}" } } private fun selectApdu(aid: ByteArray): ByteArray = byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00, aid.size.toByte()) + aid + byteArrayOf(0x00) private fun readRecordApdu(sfi: Int, rec: Int): ByteArray = byteArrayOf(0x00, 0xB2.toByte(), rec.toByte(), ((sfi shl 3) or 0x04).toByte(), 0x00) /** Builds GPO with the PDOL filled in: sensible TTQ/country/currency/date, zeros otherwise. */ private fun gpoApdu(pdol: ByteArray?): ByteArray { val out = ByteArrayOutputStream() if (pdol != null) { var i = 0 while (i < pdol.size) { var tag = pdol[i].toInt() and 0xFF i++ if (tag and 0x1F == 0x1F) { do { tag = (tag shl 8) or (pdol[i].toInt() and 0xFF) } while (pdol[i++].toInt() and 0x80 != 0 && i < pdol.size) } if (i >= pdol.size) break val len = pdol[i++].toInt() and 0xFF val value = when (tag) { 0x9F66 -> hex("B620C000") // TTQ: contactless qVSDC, online capable 0x9F1A, 0x5F2A -> hex("0462") // Maldives / MVR 0x9A -> hex("260101") 0x9C -> hex("00") 0x9F37 -> hex("12345678") else -> ByteArray(len) } out.write(value.copyOf(len)) } } val pdolData = out.toByteArray() val body = byteArrayOf(0x83.toByte(), pdolData.size.toByte()) + pdolData return byteArrayOf(0x80.toByte(), 0xA8.toByte(), 0x00, 0x00, body.size.toByte()) + body + byteArrayOf(0x00) } /** Sends an APDU, returning the response data on 9000 (following 61xx / 6Cxx), else null. */ private fun transceive(iso: IsoDep, apdu: ByteArray): ByteArray? { var resp = iso.transceive(apdu) if (resp.size < 2) return null var sw1 = resp[resp.size - 2].toInt() and 0xFF if (sw1 == 0x6C) { val retry = apdu.copyOf() retry[retry.size - 1] = resp[resp.size - 1] resp = iso.transceive(retry) sw1 = resp[resp.size - 2].toInt() and 0xFF } if (sw1 == 0x61) { resp = iso.transceive(byteArrayOf(0x00, 0xC0.toByte(), 0x00, 0x00, resp[resp.size - 1])) sw1 = resp[resp.size - 2].toInt() and 0xFF } val sw2 = resp[resp.size - 1].toInt() and 0xFF return if (sw1 == 0x90 && sw2 == 0x00) resp.copyOf(resp.size - 2) else null } // ── BER-TLV ────────────────────────────────────────────────────────────── private fun findTag(data: ByteArray, target: Int): ByteArray? = findAllTags(data, target).firstOrNull() private fun findAllTags(data: ByteArray, target: Int): List { val found = mutableListOf() walk(data, 0, data.size, target, found) return found } private fun walk(data: ByteArray, start: Int, end: Int, target: Int, found: MutableList) { var i = start while (i < end) { val b0 = data[i].toInt() and 0xFF if (b0 == 0x00 || b0 == 0xFF) { i++; continue } // padding val constructed = b0 and 0x20 != 0 var tag = b0 i++ if (b0 and 0x1F == 0x1F) { while (i < end) { val b = data[i++].toInt() and 0xFF tag = (tag shl 8) or b if (b and 0x80 == 0) break } } if (i >= end) return var len = data[i++].toInt() and 0xFF if (len and 0x80 != 0) { val n = len and 0x7F len = 0 repeat(n) { if (i < end) len = (len shl 8) or (data[i++].toInt() and 0xFF) } } if (len < 0 || i + len > end) return if (tag == target) found.add(data.copyOfRange(i, i + len)) if (constructed) walk(data, i, i + len, target, found) i += len } } private fun hex(s: String): ByteArray = ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() } private fun toHex(b: ByteArray): String = b.joinToString("") { "%02X".format(it) } }