diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ea1c1d1..dfa5208 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,8 +13,8 @@ android { applicationId = "com.iiyh.emoneyinfo" minSdk = 28 targetSdk = 36 - versionCode = 4 - versionName = "1.0.2" + versionCode = 5 + versionName = "1.0.3" vectorDrawables { useSupportLibrary = true diff --git a/app/src/main/java/com/iiyh/emoneyinfo/data/Models.kt b/app/src/main/java/com/iiyh/emoneyinfo/data/Models.kt index 58d36d4..8a69614 100644 --- a/app/src/main/java/com/iiyh/emoneyinfo/data/Models.kt +++ b/app/src/main/java/com/iiyh/emoneyinfo/data/Models.kt @@ -24,7 +24,8 @@ data class TransactionItem( val date: Date, val amount: Long, val isCredit: Boolean, - val locationName: String = "" + val locationName: String = "", + val terminalId: String = "" ) { fun formattedAmount(): String { val formatter = NumberFormat.getCurrencyInstance(Locale.forLanguageTag("id-ID")).apply { diff --git a/app/src/main/java/com/iiyh/emoneyinfo/nfc/CardReaders.kt b/app/src/main/java/com/iiyh/emoneyinfo/nfc/CardReaders.kt index ff6ea91..63e254f 100644 --- a/app/src/main/java/com/iiyh/emoneyinfo/nfc/CardReaders.kt +++ b/app/src/main/java/com/iiyh/emoneyinfo/nfc/CardReaders.kt @@ -246,12 +246,23 @@ private object BrizziReader { private object FlazzReader { private val selectDfAid = "A0000000180F0000018001".hexToBytes() + private val history60Offsets = listOf(0x00, 0x0F, 0x1E, 0x2D, 0x3C, 0x4B, 0x5A, 0x69, 0x78, 0x87) fun read(isoDep: IsoDep, strings: AndroidStrings): EmoneyUiState? { AppLog.d("EmoneyInfoFlazz", "Starting IsoDep Flazz detection") val select = isoDep.transceiveSelect(selectDfAid) - AppLog.d("EmoneyInfoFlazz", "Select DF status=${"%02X%02X".format(select.sw1, select.sw2)}") - val fallbackSelect = if (!select.isSuccess()) { + AppLog.d("EmoneyInfoFlazz", "Select AID status=${"%02X%02X".format(select.sw1, select.sw2)}") + if (!select.isSuccess()) return null + + val selectDf = isoDep.transceiveApdu( + cla = 0x00, + ins = 0xA4, + p1 = 0x01, + p2 = 0x00, + data = byteArrayOf(0x02, 0x00) + ) + AppLog.d("EmoneyInfoFlazz", "Select DF 0200 status=${"%02X%02X".format(selectDf.sw1, selectDf.sw2)}") + if (!selectDf.isSuccess()) { isoDep.transceiveApdu( cla = 0x00, ins = 0xA4, @@ -261,10 +272,11 @@ private object FlazzReader { le = 0x00 ) } else { - select + selectDf + }.also { + AppLog.d("EmoneyInfoFlazz", "Fallback select DF status=${"%02X%02X".format(it.sw1, it.sw2)}") + if (!it.isSuccess()) return null } - AppLog.d("EmoneyInfoFlazz", "Fallback select status=${"%02X%02X".format(fallbackSelect.sw1, fallbackSelect.sw2)}") - if (!fallbackSelect.isSuccess()) return null val cardInfo = isoDep.transceiveApdu( cla = 0x00, @@ -293,7 +305,7 @@ private object FlazzReader { ) AppLog.d("EmoneyInfoFlazz", "Balance status=${"%02X%02X".format(balanceResp.sw1, balanceResp.sw2)}") val balance = if (balanceResp.isSuccess() && balanceResp.data.size >= 4) { - balanceResp.data.toHex().substring(2, 8).hexToLong() + balanceResp.data.copyOfRange(0, 4).bigEndianLong() } else { 0L } @@ -319,123 +331,106 @@ private object FlazzReader { private fun readFlazzHistory(isoDep: IsoDep, strings: AndroidStrings): List { val logCheck = isoDep.transceiveApdu(cla = 0x00, ins = 0xB0, p1 = 0x81, p2 = 0x00, le = 0x00) - return if (logCheck.isSuccess()) { - readV2History(isoDep, strings) - } else { - readV1History(isoDep, strings) - } + val v1 = readV1History(isoDep, strings) + val v2 = if (logCheck.isSuccess()) readV2History(isoDep, strings) else emptyList() + return (v1 + v2).distinctBy { it.historyKey() }.sortedByDescending { it.date } } private fun readV1History(isoDep: IsoDep, strings: AndroidStrings): List { - val part1 = StringBuilder() - - for (index in 0 until 16) { - val offset = index * 15 - val first = isoDep.transceiveApdu(cla = 0x00, ins = 0xB0, p1 = 0x84, p2 = offset, le = 60) - if (!first.isSuccess()) break - part1.append(first.data.toHex()) + return buildList { + for (offset in history60Offsets) { + val resp = isoDep.transceiveApdu(cla = 0x00, ins = 0xB0, p1 = 0x84, p2 = offset, le = 0x3C) + if (resp.hasStatus(0x6B, 0x00)) break + if (!resp.isSuccess() || resp.data.size < 60) break + parseFlazz60Record(resp.data, strings)?.let(::add) + } } - for (index in 0 until 16) { - val offset = index * 15 - val second = isoDep.transceiveApdu(cla = 0x00, ins = 0xB0, p1 = 0x85, p2 = offset, le = 60) - if (!second.isSuccess()) break - part1.append(second.data.toHex()) - } - return parseFlazz120Logs(part1.toString(), strings) } private fun readV2History(isoDep: IsoDep, strings: AndroidStrings): List { - val mapV1 = StringBuilder() - val mapV2 = StringBuilder() - - for (index in 0 until 5) { - val offset = index * 60 - val resp = isoDep.transceiveApdu(cla = 0x00, ins = 0xB0, p1 = 0x85, p2 = offset, le = 120) - if (!resp.isSuccess()) break - mapV1.append(resp.data.toHex()) - } - for (index in 0 until 5) { - val offset = index * 60 - val resp = isoDep.transceiveApdu(cla = 0x00, ins = 0xB0, p1 = 0x84, p2 = offset, le = 240) - if (!resp.isSuccess()) break - mapV1.append(resp.data.toHex()) - } - val random = isoDep.transceiveApdu(cla = 0x00, ins = 0x84, p1 = 0x00, p2 = 0x00, le = 8) - if (random.isSuccess()) { - val auth = isoDep.transceiveApdu( - cla = 0x90, - ins = 0x32, - p1 = 0x03, - p2 = 0x00, - data = ("0801" + random.data.toHex()).hexToBytes(), - le = 41 + if (!random.isSuccess()) return emptyList() + + return buildList { + for (index in 0 until 256) { + val resp = isoDep.transceiveApdu( + cla = 0x90, + ins = 0x32, + p1 = 0x03, + p2 = 0x00, + data = byteArrayOf(index.toByte()), + le = 0x20 + ) + if (resp.hasStatus(0x6A, 0x80)) break + if (!resp.isSuccess() || resp.data.size < 32) break + parseFlazz32Record(resp.data, strings)?.let(::add) + } + } + } + + private fun parseFlazz60Record(data: ByteArray, strings: AndroidStrings): TransactionItem? { + val typeRaw = ((data[0].toInt() and 0xFF) shl 8) or (data[1].toInt() and 0xFF) + val transactionTime = be32Prefixed(data[38], data[39], data[40], data[41]) + if (transactionTime <= 0L) return null + + val amount = be24(data[6], data[7], data[8]).toLong() + val terminalId = data.copyOfRange(30, 38).toString(Charsets.US_ASCII).trim('\u0000', ' ') + val isPayment = typeRaw == 0x0400 + val transactionLabel = if (isPayment) strings.get(R.string.payment) else strings.get(R.string.topup) + + return TransactionItem( + title = transactionLabel, + date = flazzSecondsFrom1980(transactionTime), + amount = amount, + isCredit = !isPayment, + locationName = transactionLabel, + terminalId = terminalId + ) + } + + private fun parseFlazz32Record(data: ByteArray, strings: AndroidStrings): TransactionItem? { + val typeRaw = data[0].toInt() and 0xFF + val transactionTime = be32Prefixed(data[4], data[5], data[6], data[7]) + if (transactionTime <= 0L) return null + + val rawAmount = be24(data[1], data[2], data[3]).toLong() + val terminalId = data.copyOfRange(14, 22).toString(Charsets.US_ASCII).trim('\u0000', ' ') + val isPayment = typeRaw == 0x04 + val amount = if (isPayment) 0x1000000L - rawAmount else rawAmount + val transactionLabel = if (isPayment) strings.get(R.string.payment) else strings.get(R.string.topup) + AppLog.d( + "EmoneyInfoFlazz", + "V2 record type=%02X rawAmount=%d amount=%d location=%s time=%d tailAmount=%d".format( + typeRaw, + rawAmount, + amount, + terminalId, + transactionTime, + be24(data[22], data[23], data[24]) ) - if (auth.isSuccess()) { - for (index in 0 until 256) { - val resp = isoDep.transceiveApdu(cla = 0x00, ins = 0xB0, p1 = 0x89, p2 = index, le = 64) - if (!resp.isSuccess()) break - mapV2.append(resp.data.toHex()) - } - for (index in 0 until 256) { - val resp = isoDep.transceiveApdu( - cla = 0x90, - ins = 0x32, - p1 = 0x03, - p2 = 0x00, - data = byteArrayOf(index.toByte()), - le = 32 - ) - if (!resp.isSuccess()) break - mapV2.append(resp.data.toHex()) - } - } - } + ) - return (parseFlazz120Logs(mapV1.toString(), strings) + parseFlazz64Logs(mapV2.toString(), strings)) + return TransactionItem( + title = transactionLabel, + date = flazzSecondsFrom1980(transactionTime), + amount = amount, + isCredit = !isPayment, + locationName = transactionLabel, + terminalId = terminalId + ) } - private fun parseFlazz120Logs(logs: String, strings: AndroidStrings): List { - if (logs.isBlank() || logs.length % 120 != 0) return emptyList() - return buildList { - for (offset in logs.indices step 120) { - val chunk = logs.substring(offset, offset + 120) - val transactionTime = chunk.substring(76, 84).hexToLong() - if (transactionTime <= 0) continue - val amount = chunk.substring(12, 18).hexToLong() - val type = chunk.substring(0, 4).hexToLong() - add( - TransactionItem( - title = if (type == 1024L) strings.get(R.string.payment) else strings.get(R.string.topup), - date = flazzSecondsFrom1980(transactionTime), - amount = amount, - isCredit = type != 1024L - ) - ) - } - } + private fun be24(b0: Byte, b1: Byte, b2: Byte): Int { + return ((b0.toInt() and 0xFF) shl 16) or + ((b1.toInt() and 0xFF) shl 8) or + (b2.toInt() and 0xFF) } - private fun parseFlazz64Logs(logs: String, strings: AndroidStrings): List { - if (logs.isBlank() || logs.length % 64 != 0) return emptyList() - return buildList { - for (offset in logs.indices step 64) { - val chunk = logs.substring(offset, offset + 64) - val transactionTime = chunk.substring(8, 16).hexToLong() - if (transactionTime <= 0) continue - val type = chunk.substring(0, 2).hexToLong() - val rawAmount = chunk.substring(2, 8).hexToLong() - val amount = if (type == 4L) 16777216L - rawAmount else rawAmount - add( - TransactionItem( - title = if (type == 4L) strings.get(R.string.payment) else strings.get(R.string.topup), - date = flazzSecondsFrom1980(transactionTime), - amount = amount, - isCredit = type != 4L - ) - ) - } - } + private fun be32Prefixed(b0: Byte, b1: Byte, b2: Byte, b3: Byte): Long { + return ((b0.toLong() and 0xFF) shl 24) or + ((b1.toLong() and 0xFF) shl 16) or + ((b2.toLong() and 0xFF) shl 8) or + (b3.toLong() and 0xFF) } }