From 2b2fd59543459df8136891529b77e16599c9b513 Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Thu, 1 Oct 2026 05:08:17 +0500 Subject: [PATCH] Gateway payments via card, Step 1: verify cards --- .../sh/sar/basedbank/nfc/EmvCardReader.kt | 186 +++++++++++++ .../ui/home/CardVerifyAnimationView.kt | 236 ++++++++++++++++ .../basedbank/ui/home/PayWithCardFragment.kt | 253 ++++++++++++++++++ .../sar/basedbank/util/VerifiedCardStore.kt | 62 +++++ app/src/main/res/drawable/ic_card_verify.xml | 11 + app/src/main/res/drawable/ic_close.xml | 10 + app/src/main/res/drawable/ic_keyboard.xml | 10 + .../res/layout/dialog_card_manual_verify.xml | 99 +++++++ app/src/main/res/layout/fragment_cards.xml | 76 ++++++ app/src/main/res/values/strings.xml | 23 ++ 10 files changed, 966 insertions(+) create mode 100644 app/src/main/java/sh/sar/basedbank/nfc/EmvCardReader.kt create mode 100644 app/src/main/java/sh/sar/basedbank/ui/home/CardVerifyAnimationView.kt create mode 100644 app/src/main/java/sh/sar/basedbank/util/VerifiedCardStore.kt create mode 100644 app/src/main/res/drawable/ic_card_verify.xml create mode 100644 app/src/main/res/drawable/ic_close.xml create mode 100644 app/src/main/res/drawable/ic_keyboard.xml create mode 100644 app/src/main/res/layout/dialog_card_manual_verify.xml diff --git a/app/src/main/java/sh/sar/basedbank/nfc/EmvCardReader.kt b/app/src/main/java/sh/sar/basedbank/nfc/EmvCardReader.kt new file mode 100644 index 0000000..0cac7a8 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/nfc/EmvCardReader.kt @@ -0,0 +1,186 @@ +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) } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/CardVerifyAnimationView.kt b/app/src/main/java/sh/sar/basedbank/ui/home/CardVerifyAnimationView.kt new file mode 100644 index 0000000..425d19a --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/CardVerifyAnimationView.kt @@ -0,0 +1,236 @@ +package sh.sar.basedbank.ui.home + +import android.animation.ValueAnimator +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.os.SystemClock +import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.OvershootInterpolator +import com.google.android.material.color.MaterialColors +import kotlin.math.PI +import kotlin.math.min +import kotlin.math.sin + +/** + * "Tap card to verify" animation: a bank card swings onto the back of a phone, NFC waves + * ripple out from the contact point, then it lifts away and repeats. Has reading / success / + * error states so the fragment can reflect what the reader is doing. + */ +class CardVerifyAnimationView(context: Context) : View(context) { + + enum class State { WAITING, READING, SUCCESS, ERROR } + + private var state = State.WAITING + private var stateStart = SystemClock.uptimeMillis() + private var label: String = "" + + /** Text shown under the animation while waiting (and restored after an error). */ + var waitingLabel: String = "" + set(value) { field = value; if (state == State.WAITING) label = value; invalidate() } + + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER } + private val rect = RectF() + private val path = Path() + private val easeInOut = AccelerateDecelerateInterpolator() + private val overshoot = OvershootInterpolator(2.2f) + + // Drives redraws only; all motion is derived from elapsed time in the current state. + private val ticker = ValueAnimator.ofFloat(0f, 1f).apply { + duration = 1000 + repeatCount = ValueAnimator.INFINITE + addUpdateListener { invalidate() } + } + + private val revertToWaiting = Runnable { setState(State.WAITING) } + + fun setState(newState: State, text: String? = null) { + removeCallbacks(revertToWaiting) + state = newState + stateStart = SystemClock.uptimeMillis() + label = text ?: if (newState == State.WAITING) waitingLabel else label + if (newState == State.ERROR) postDelayed(revertToWaiting, ERROR_HOLD_MS) + invalidate() + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + ticker.start() + } + + override fun onDetachedFromWindow() { + ticker.cancel() + removeCallbacks(revertToWaiting) + super.onDetachedFromWindow() + } + + override fun onDraw(canvas: Canvas) { + val w = width.toFloat(); val h = height.toFloat() + if (w <= 0f || h <= 0f) return + val dp = resources.displayMetrics.density + + val colorOnSurface = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurface, 0xFF000000.toInt()) + val colorPrimary = MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimary, 0xFF3F51B5.toInt()) + val colorOnPrimary = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnPrimary, 0xFFFFFFFF.toInt()) + val colorSurfaceVariant = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurfaceVariant, 0xFFDDDDDD.toInt()) + val colorError = MaterialColors.getColor(this, com.google.android.material.R.attr.colorError, 0xFFB3261E.toInt()) + + // Artwork is laid out in a DESIGN_W x DESIGN_H dp box, scaled to fit the available area. + val textArea = 36 * dp + val scale = min(min(w / (DESIGN_W * dp), (h - textArea) / (DESIGN_H * dp)), 1.3f).coerceAtLeast(0.3f) + val u = dp * scale + val cx = w / 2f + val top = ((h - textArea) - DESIGN_H * u) / 2f + + val elapsed = SystemClock.uptimeMillis() - stateStart + + // ── Card motion: 0 = resting away from phone, 1 = held on phone ───────── + val contact = when (state) { + State.WAITING -> { + val p = (elapsed % CYCLE_MS) / CYCLE_MS.toFloat() + when { + p < 0.35f -> easeInOut.getInterpolation(p / 0.35f) + p < 0.70f -> 1f + p < 1.00f -> 1f - easeInOut.getInterpolation((p - 0.70f) / 0.30f) + else -> 0f + } + } + else -> 1f + } + val shake = if (state == State.ERROR && elapsed < 500) + sin(elapsed / 500f * 6 * PI).toFloat() * (1f - elapsed / 500f) * 8 * u else 0f + + // Phone + val phoneW = 64 * u; val phoneH = 112 * u + val phoneL = cx - phoneW / 2f; val phoneT = top + 44 * u + paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant + rect.set(phoneL, phoneT, phoneL + phoneW, phoneT + phoneH) + canvas.drawRoundRect(rect, 10 * u, 10 * u, paint) + paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * u; paint.color = colorOnSurface + canvas.drawRoundRect(rect, 10 * u, 10 * u, paint) + // Camera bump (we're looking at the back of the phone) + paint.style = Paint.Style.FILL; paint.color = colorOnSurface; paint.alpha = 60 + rect.set(phoneL + 8 * u, phoneT + 8 * u, phoneL + 26 * u, phoneT + 30 * u) + canvas.drawRoundRect(rect, 5 * u, 5 * u, paint) + paint.alpha = 255 + + // Contact point where the NFC antenna sits + val touchX = cx; val touchY = phoneT + phoneH * 0.42f + + // ── NFC waves (behind the card) ──────────────────────────────────────── + val waveStrength = when (state) { + State.WAITING -> ((contact - 0.85f) / 0.15f).coerceIn(0f, 1f) + State.READING -> 1f + else -> 0f + } + if (waveStrength > 0f) { + val period = if (state == State.READING) 700f else 1100f + val base = (elapsed % period.toLong()) / period + paint.style = Paint.Style.STROKE; paint.strokeWidth = 3 * u + for (i in 0..2) { + val p = (base + i / 3f) % 1f + val r = 58 * u + p * 46 * u + paint.color = colorPrimary + paint.alpha = ((1f - p) * 220 * waveStrength).toInt().coerceIn(0, 255) + rect.set(touchX - r, touchY - r * 0.72f, touchX + r, touchY + r * 0.72f) + canvas.drawOval(rect, paint) + } + paint.alpha = 255 + } + + // ── Card ─────────────────────────────────────────────────────────────── + val cardW = 104 * u; val cardH = 66 * u + val restX = cx + 58 * u; val restY = top + 48 * u + val cardCx = restX + (touchX - restX) * contact + shake + val cardCy = restY + (touchY - restY) * contact + val rotation = 18f * (1f - contact) + val lift = 1f + 0.08f * (1f - contact) + + canvas.save() + canvas.translate(cardCx, cardCy) + canvas.rotate(rotation) + canvas.scale(lift, lift) + + // Same flat look as the phone: surface-variant body, on-surface outline, primary tint for the chip + val outline = if (state == State.ERROR) colorError else colorOnSurface + rect.set(-cardW / 2, -cardH / 2, cardW / 2, cardH / 2) + paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant + canvas.drawRoundRect(rect, 8 * u, 8 * u, paint) + paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * u; paint.color = outline + canvas.drawRoundRect(rect, 8 * u, 8 * u, paint) + + // Chip + rect.set(-cardW / 2 + 12 * u, -9 * u, -cardW / 2 + 30 * u, 5 * u) + paint.style = Paint.Style.FILL; paint.color = colorPrimary; paint.alpha = 70 + canvas.drawRoundRect(rect, 3 * u, 3 * u, paint) + paint.alpha = 255 + paint.style = Paint.Style.STROKE; paint.strokeWidth = 1.5f * u; paint.color = outline + canvas.drawRoundRect(rect, 3 * u, 3 * u, paint) + canvas.drawLine(rect.left, rect.centerY(), rect.right, rect.centerY(), paint) + + // Contactless symbol on the card + paint.strokeWidth = 1.8f * u; paint.strokeCap = Paint.Cap.ROUND + for (i in 0..2) { + val r = (5 + i * 4.5f) * u + rect.set(cardW / 2 - 28 * u - r, -14 * u - r, cardW / 2 - 28 * u + r, -14 * u + r) + canvas.drawArc(rect, -45f, 90f, false, paint) + } + // Number + name placeholders + paint.strokeWidth = 3f * u; paint.alpha = 150 + for (g in 0..3) { + val x = -cardW / 2 + 12 * u + g * 21 * u + canvas.drawLine(x, 16 * u, x + 15 * u, 16 * u, paint) + } + paint.alpha = 100; paint.strokeWidth = 2.5f * u + canvas.drawLine(-cardW / 2 + 12 * u, 26 * u, -cardW / 2 + 48 * u, 26 * u, paint) + paint.alpha = 255; paint.strokeCap = Paint.Cap.BUTT + canvas.restore() + + // ── Success badge ────────────────────────────────────────────────────── + if (state == State.SUCCESS) { + val t = (elapsed / 450f).coerceIn(0f, 1f) + val badgeR = 22 * u * overshoot.getInterpolation(t) + val bx = touchX + cardW / 2 - 6 * u; val by = touchY - cardH / 2 + 4 * u + paint.style = Paint.Style.FILL; paint.color = colorPrimary + canvas.drawCircle(bx, by, badgeR, paint) + val checkT = ((elapsed - 200) / 350f).coerceIn(0f, 1f) + if (checkT > 0f) { + paint.style = Paint.Style.STROKE; paint.strokeWidth = 3.5f * u + paint.strokeCap = Paint.Cap.ROUND; paint.color = colorOnPrimary + val x0 = bx - 9 * u; val y0 = by + val x1 = bx - 3 * u; val y1 = by + 7 * u + val x2 = bx + 10 * u; val y2 = by - 7 * u + path.reset(); path.moveTo(x0, y0) + if (checkT < 0.4f) { + val k = checkT / 0.4f + path.lineTo(x0 + (x1 - x0) * k, y0 + (y1 - y0) * k) + } else { + val k = (checkT - 0.4f) / 0.6f + path.lineTo(x1, y1); path.lineTo(x1 + (x2 - x1) * k, y1 + (y2 - y1) * k) + } + canvas.drawPath(path, paint) + paint.strokeCap = Paint.Cap.BUTT + } + } + + // ── Label ────────────────────────────────────────────────────────────── + textPaint.textSize = 16 * dp + textPaint.color = if (state == State.ERROR) colorError else colorOnSurface + textPaint.alpha = when (state) { + State.WAITING -> (170 + 60 * sin(elapsed / 600.0).toFloat()).toInt().coerceIn(0, 255) + else -> 230 + } + canvas.drawText(label, cx, h - textArea / 2f + textPaint.textSize / 3f, textPaint) + } + + companion object { + private const val DESIGN_W = 240f + private const val DESIGN_H = 170f + private const val CYCLE_MS = 2600L + private const val ERROR_HOLD_MS = 1800L + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt index 34d0cf8..f04e143 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt @@ -45,15 +45,18 @@ import sh.sar.basedbank.api.bml.BmlCardClient import sh.sar.basedbank.api.bml.BmlTapToPayClient import sh.sar.basedbank.api.mib.MibCardsClient import sh.sar.basedbank.nfc.BmlHostCardEmulatorService +import sh.sar.basedbank.nfc.EmvCardReader import sh.sar.basedbank.api.mib.MibCard import android.text.InputType import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout +import sh.sar.basedbank.databinding.DialogCardManualVerifyBinding import sh.sar.basedbank.databinding.FragmentCardsBinding import sh.sar.basedbank.util.CardsCache import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.Totp +import sh.sar.basedbank.util.VerifiedCardStore import sh.sar.basedbank.util.bmlapi.BmlCardParser import sh.sar.basedbank.util.NfcPaymentUtil import sh.sar.basedbank.util.PaymvQrParser @@ -265,6 +268,243 @@ class CardsFragment : Fragment() { } } binding.btnBlock.setOnClickListener(wip) + binding.btnVerify.setOnClickListener { + cards.getOrNull(currentCardPosition)?.let { onVerifyClicked(it) } + } + binding.btnCancelVerify.setOnClickListener { setVerifyMode(false) } + binding.btnManualVerify.setOnClickListener { + verifyItem?.let { showCardDetailsDialog(it) } + } + } + + // ── Card verification (NFC tap or manual entry) ─────────────────────────── + + private var isVerifyMode = false + private var verifyItem: CardItem? = null + private var verifyAnimView: CardVerifyAnimationView? = null + /** True while the CVV / manual dialog is up; the NFC reader stays off meanwhile. */ + private var verifyDialogOpen = false + + private fun cardLast4(item: CardItem): String { + val number = when (item) { + is CardItem.Bml -> item.account.accountNumber + is CardItem.Mib -> item.card.maskedCardNumber + } + return number.filter { it.isDigit() }.takeLast(4) + } + + private fun onVerifyClicked(item: CardItem) { + val ctx = requireContext() + val adapter = android.nfc.NfcAdapter.getDefaultAdapter(ctx) + when { + adapter == null -> showCardDetailsDialog(item) + !adapter.isEnabled -> MaterialAlertDialogBuilder(ctx) + .setTitle(R.string.nfc_disabled_title) + .setMessage(R.string.card_verify_nfc_disabled_message) + .setPositiveButton(R.string.nfc_open_settings) { _, _ -> + startActivity(Intent(android.provider.Settings.ACTION_NFC_SETTINGS)) + } + .setNeutralButton(R.string.card_verify_manual) { _, _ -> showCardDetailsDialog(item) } + .setNegativeButton(R.string.cancel, null) + .show() + else -> setVerifyMode(true, item) + } + } + + private fun setVerifyMode(enabled: Boolean, item: CardItem? = null) { + if (enabled == isVerifyMode) return + isVerifyMode = enabled + verifyItem = if (enabled) item else null + verifyDialogOpen = false + requireActivity().title = getString(if (enabled) R.string.card_verify_title else R.string.card_manage) + + val manageVisibility = if (enabled) View.GONE else View.VISIBLE + binding.llManageButtons.visibility = manageVisibility + binding.llDefaultCardRow.visibility = manageVisibility + binding.llHideDashboardRow.visibility = manageVisibility + binding.bottomSpacer.visibility = manageVisibility + binding.flVerifyArea.visibility = if (enabled) View.VISIBLE else View.GONE + binding.llVerifyButtons.visibility = if (enabled) View.VISIBLE else View.GONE + + binding.flVerifyArea.removeAllViews() + if (enabled) { + val anim = CardVerifyAnimationView(requireContext()).apply { + waitingLabel = getString(R.string.card_verify_tap) + alpha = 0f + } + verifyAnimView = anim + binding.flVerifyArea.addView(anim, ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + anim.animate().alpha(1f).setDuration(300).start() + startVerifyReader() + } else { + verifyAnimView = null + stopVerifyReader() + } + } + + private fun startVerifyReader() { + if (!isVerifyMode || verifyDialogOpen || !isResumed) return + val activity = requireActivity() + val adapter = android.nfc.NfcAdapter.getDefaultAdapter(activity) ?: return + adapter.enableReaderMode(activity, { tag -> + // Binder thread: fine to block on the card here. + view?.post { + if (isVerifyMode) verifyAnimView?.setState( + CardVerifyAnimationView.State.READING, getString(R.string.card_verify_reading)) + } + val data = runCatching { EmvCardReader.read(tag) }.getOrNull() + view?.post { onVerifyCardRead(data) } + }, android.nfc.NfcAdapter.FLAG_READER_NFC_A or android.nfc.NfcAdapter.FLAG_READER_NFC_B or + android.nfc.NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK, null) + } + + private fun stopVerifyReader() { + val activity = activity ?: return + android.nfc.NfcAdapter.getDefaultAdapter(activity)?.disableReaderMode(activity) + } + + private fun onVerifyCardRead(data: EmvCardReader.CardData?) { + val item = verifyItem + if (!isVerifyMode || item == null || _binding == null || verifyDialogOpen) return + val anim = verifyAnimView + val expected = cardLast4(item) + when { + data == null -> anim?.setState(CardVerifyAnimationView.State.ERROR, + getString(R.string.card_verify_read_failed)) + data.pan.takeLast(4) != expected -> anim?.setState(CardVerifyAnimationView.State.ERROR, + getString(R.string.card_verify_mismatch, data.pan.takeLast(4))) + else -> { + anim?.setState(CardVerifyAnimationView.State.SUCCESS, getString(R.string.card_verify_matched)) + verifyDialogOpen = true + stopVerifyReader() + // Let the check mark land before the dialog covers it + binding.root.postDelayed({ + if (isVerifyMode && verifyItem === item && _binding != null) showCardDetailsDialog(item, data) + }, 750) + } + } + } + + private fun resumeWaitingForTap() { + verifyDialogOpen = false + if (!isVerifyMode) return + verifyAnimView?.setState(CardVerifyAnimationView.State.WAITING) + startVerifyReader() + } + + private fun cardHolderName(item: CardItem): String = when (item) { + is CardItem.Bml -> item.account.accountBriefName + is CardItem.Mib -> item.card.cardHolderName + } + + /** + * Card details form. With [nfcData] (after a matching tap) the number and expiry read from the + * chip are prefilled and locked, so only the CVV is asked for; without it everything but the + * name is entered manually. The name always comes from the bank API and is read-only. + */ + private fun showCardDetailsDialog(item: CardItem, nfcData: EmvCardReader.CardData? = null) { + val ctx = requireContext() + val expected = cardLast4(item) + val b = DialogCardManualVerifyBinding.inflate(layoutInflater) + + b.etName.setText(cardHolderName(item)) + b.tilName.isEnabled = false + + // Auto-insert the "/" in MM/YY while typing forwards + b.etExpiry.addTextChangedListener(object : android.text.TextWatcher { + private var deleting = false + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { deleting = after < count } + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: android.text.Editable) { + if (!deleting && s.length == 2 && !s.contains('/')) s.append('/') + } + }) + + if (nfcData != null) { + b.etCardNumber.setText(nfcData.pan.chunked(4).joinToString(" ")) + b.tilCardNumber.isEnabled = false + nfcData.expiry?.let { + b.etExpiry.setText(it) + b.tilExpiry.isEnabled = false + } + } + + if (isVerifyMode) { + verifyDialogOpen = true + stopVerifyReader() + } + var saved = false + val dialog = MaterialAlertDialogBuilder(ctx) + .setTitle(if (nfcData != null) getString(R.string.card_verify_cvv_title, nfcData.pan.takeLast(4)) + else getString(R.string.card_verify_manual_title)) + .setView(b.root) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.card_verify_confirm, null) + .setOnDismissListener { if (!saved && isVerifyMode) resumeWaitingForTap() } + .create() + dialog.setOnShowListener { + dialog.getButton(android.content.DialogInterface.BUTTON_POSITIVE).setOnClickListener { + b.tilCardNumber.error = null; b.tilExpiry.error = null; b.tilCvv.error = null + val pan = b.etCardNumber.text?.toString().orEmpty().filter { it.isDigit() } + val expiry = normalizeExpiry(b.etExpiry.text?.toString().orEmpty()) + val cvv = b.etCvv.text?.toString().orEmpty() + var ok = true + if (pan.length !in 12..19 || !luhnValid(pan)) { + b.tilCardNumber.error = getString(R.string.card_verify_number_invalid); ok = false + } else if (pan.takeLast(4) != expected) { + b.tilCardNumber.error = getString(R.string.card_verify_number_mismatch, expected); ok = false + } + if (expiry == null) { b.tilExpiry.error = getString(R.string.card_verify_expiry_invalid); ok = false } + if (!cvv.matches(Regex("\\d{3,4}"))) { b.tilCvv.error = getString(R.string.card_verify_cvv_invalid); ok = false } + if (!ok) return@setOnClickListener + + saved = true + saveVerifiedCard(item, VerifiedCardStore.VerifiedCard( + pan = pan, + expiry = expiry!!, + cvv = cvv, + method = if (nfcData != null) VerifiedCardStore.METHOD_NFC else VerifiedCardStore.METHOD_MANUAL, + verifiedAt = System.currentTimeMillis() + )) + dialog.dismiss() + } + // Focus the first field the user actually has to fill in + val firstEditable = listOf(b.tilCardNumber to b.etCardNumber, b.tilExpiry to b.etExpiry, b.tilCvv to b.etCvv) + .first { it.first.isEnabled }.second + firstEditable.requestFocus() + } + dialog.window?.setSoftInputMode(android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE) + dialog.show() + } + + private fun saveVerifiedCard(item: CardItem, card: VerifiedCardStore.VerifiedCard) { + VerifiedCardStore.save(requireContext(), cardItemKey(item), card) + Toast.makeText(requireContext(), R.string.card_verify_success, Toast.LENGTH_SHORT).show() + setVerifyMode(false) + if (isManageMode) cards.getOrNull(currentCardPosition)?.let { bindManageCardData(it) } + } + + /** Accepts "MMYY" or "MM/YY"; returns "MM/YY" if it's a valid, unexpired month. */ + private fun normalizeExpiry(raw: String): String? { + val m = Regex("^(0[1-9]|1[0-2])/?(\\d{2})$").find(raw.trim()) ?: return null + val month = m.groupValues[1].toInt() + val year = 2000 + m.groupValues[2].toInt() + val now = java.util.Calendar.getInstance() + val nowYear = now.get(java.util.Calendar.YEAR) + val nowMonth = now.get(java.util.Calendar.MONTH) + 1 + if (year < nowYear || (year == nowYear && month < nowMonth)) return null + return "%02d/%02d".format(month, year % 100) + } + + private fun luhnValid(pan: String): Boolean { + var sum = 0 + pan.reversed().forEachIndexed { i, c -> + var d = c - '0' + if (i % 2 == 1) { d *= 2; if (d > 9) d -= 9 } + sum += d + } + return sum % 10 == 0 } private fun confirmBmlFreezeToggle(item: CardItem.Bml) { @@ -400,6 +640,7 @@ class CardsFragment : Fragment() { } private fun setManageMode(enabled: Boolean) { + if (!enabled) setVerifyMode(false) isManageMode = enabled if (!enabled) managedCardKey = null requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card) @@ -441,6 +682,10 @@ class CardsFragment : Fragment() { val mibFrozen = item is CardItem.Mib && isMibCardFrozen(item.card.cardStatus) binding.btnChangePin.isEnabled = !mibFrozen binding.btnBlock.isEnabled = !mibFrozen + binding.btnVerify.setText( + if (VerifiedCardStore.isVerified(requireContext(), cardItemKey(item))) R.string.card_action_verified + else R.string.card_action_verify + ) } private fun rebindManagedCardIfNeeded() { @@ -1038,6 +1283,10 @@ class CardsFragment : Fragment() { } fun onBackPressed(): Boolean { + if (isVerifyMode) { + setVerifyMode(false) + return true + } if (isTapMode) { setTapMode(false) return true @@ -1051,6 +1300,7 @@ class CardsFragment : Fragment() { override fun onPause() { super.onPause() + if (isVerifyMode) stopVerifyReader() if (isTapMode) { BmlHostCardEmulatorService.clearToken() BmlHostCardEmulatorService.onTransactionComplete = null @@ -1059,7 +1309,9 @@ class CardsFragment : Fragment() { override fun onResume() { super.onResume() + if (isVerifyMode) startVerifyReader() requireActivity().title = getString(when { + isVerifyMode -> R.string.card_verify_title isTapMode -> R.string.card_pay_nfc isManageMode -> R.string.card_manage else -> R.string.nav_pay_with_card @@ -1067,6 +1319,7 @@ class CardsFragment : Fragment() { } override fun onDestroyView() { + if (isVerifyMode) stopVerifyReader() tapAnimView?.stopAnimation() tapAnimView = null BmlHostCardEmulatorService.clearToken() diff --git a/app/src/main/java/sh/sar/basedbank/util/VerifiedCardStore.kt b/app/src/main/java/sh/sar/basedbank/util/VerifiedCardStore.kt new file mode 100644 index 0000000..35788a1 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/util/VerifiedCardStore.kt @@ -0,0 +1,62 @@ +package sh.sar.basedbank.util + +import android.content.Context +import org.json.JSONObject + +/** + * Full card details the user has verified (via NFC tap or manual entry), encrypted at rest + * with the shared AndroidKeyStore key. Keyed by the card's identity in the cards screen + * (e.g. "bml:", "mib:"). + */ +object VerifiedCardStore { + + private const val PREFS = "verified_cards" + + data class VerifiedCard( + val pan: String, + val expiry: String, // MM/YY + val cvv: String, + val method: String, // METHOD_NFC or METHOD_MANUAL + val verifiedAt: Long + ) + + const val METHOD_NFC = "nfc" + const val METHOD_MANUAL = "manual" + + fun save(context: Context, cardKey: String, card: VerifiedCard) { + val json = JSONObject().apply { + put("pan", card.pan) + put("expiry", card.expiry) + put("cvv", card.cvv) + put("method", card.method) + put("verifiedAt", card.verifiedAt) + } + prefs(context).edit().putString(cardKey, CacheEncryption.encrypt(json.toString())).apply() + } + + fun load(context: Context, cardKey: String): VerifiedCard? { + val raw = prefs(context).getString(cardKey, null) ?: return null + return try { + val o = JSONObject(CacheEncryption.decrypt(raw)) + VerifiedCard( + pan = o.getString("pan"), + expiry = o.optString("expiry"), + cvv = o.optString("cvv"), + method = o.optString("method"), + verifiedAt = o.optLong("verifiedAt") + ) + } catch (_: Exception) { null } + } + + fun isVerified(context: Context, cardKey: String): Boolean = prefs(context).contains(cardKey) + + fun remove(context: Context, cardKey: String) { + prefs(context).edit().remove(cardKey).apply() + } + + fun clear(context: Context) { + prefs(context).edit().clear().apply() + } + + private fun prefs(context: Context) = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) +} diff --git a/app/src/main/res/drawable/ic_card_verify.xml b/app/src/main/res/drawable/ic_card_verify.xml new file mode 100644 index 0000000..9173516 --- /dev/null +++ b/app/src/main/res/drawable/ic_card_verify.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..673f2f4 --- /dev/null +++ b/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_keyboard.xml b/app/src/main/res/drawable/ic_keyboard.xml new file mode 100644 index 0000000..02c5251 --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/dialog_card_manual_verify.xml b/app/src/main/res/layout/dialog_card_manual_verify.xml new file mode 100644 index 0000000..a182595 --- /dev/null +++ b/app/src/main/res/layout/dialog_card_manual_verify.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_cards.xml b/app/src/main/res/layout/fragment_cards.xml index 6e912fd..d5744a2 100644 --- a/app/src/main/res/layout/fragment_cards.xml +++ b/app/src/main/res/layout/fragment_cards.xml @@ -87,10 +87,19 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f226fa9..f6640e5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -387,6 +387,29 @@ Freeze Unfreeze Block + Verify + Verified + Verify Card + Tap card to verify + Reading card… hold still + Card matched + Couldn\'t read the card, try again + Card ending %1$s doesn\'t match + Cancel Verification + Manually Verify + Enter Your Card Details + Turn on NFC to verify your card by tapping it, or enter the details manually. + Card ending %1$s + CVV + Enter a 3 or 4 digit CVV + Verify + Name on card + Card number + Expiry (MM/YY) + Enter a valid card number + Number must end in %1$s + Enter a valid expiry, e.g. 08/29 + Card verified Freeze card? This will temporarily stop the card from being used. You can unfreeze it anytime you want to use it again. Unfreeze card?