Gateway payments via card, Step 1: verify cards
Auto Tag on Version Change / check-version (push) Successful in 6s

This commit is contained in:
2026-10-01 05:08:17 +05:00
parent b97d0c5a18
commit 2b2fd59543
10 changed files with 966 additions and 0 deletions
@@ -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<ByteArray> {
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<ByteArray> {
val found = mutableListOf<ByteArray>()
walk(data, 0, data.size, target, found)
return found
}
private fun walk(data: ByteArray, start: Int, end: Int, target: Int, found: MutableList<ByteArray>) {
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) }
}
@@ -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
}
}
@@ -45,15 +45,18 @@ import sh.sar.basedbank.api.bml.BmlCardClient
import sh.sar.basedbank.api.bml.BmlTapToPayClient import sh.sar.basedbank.api.bml.BmlTapToPayClient
import sh.sar.basedbank.api.mib.MibCardsClient import sh.sar.basedbank.api.mib.MibCardsClient
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
import sh.sar.basedbank.nfc.EmvCardReader
import sh.sar.basedbank.api.mib.MibCard import sh.sar.basedbank.api.mib.MibCard
import android.text.InputType import android.text.InputType
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout import com.google.android.material.textfield.TextInputLayout
import sh.sar.basedbank.databinding.DialogCardManualVerifyBinding
import sh.sar.basedbank.databinding.FragmentCardsBinding import sh.sar.basedbank.databinding.FragmentCardsBinding
import sh.sar.basedbank.util.CardsCache import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.Totp import sh.sar.basedbank.util.Totp
import sh.sar.basedbank.util.VerifiedCardStore
import sh.sar.basedbank.util.bmlapi.BmlCardParser import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.NfcPaymentUtil import sh.sar.basedbank.util.NfcPaymentUtil
import sh.sar.basedbank.util.PaymvQrParser import sh.sar.basedbank.util.PaymvQrParser
@@ -265,6 +268,243 @@ class CardsFragment : Fragment() {
} }
} }
binding.btnBlock.setOnClickListener(wip) 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) { private fun confirmBmlFreezeToggle(item: CardItem.Bml) {
@@ -400,6 +640,7 @@ class CardsFragment : Fragment() {
} }
private fun setManageMode(enabled: Boolean) { private fun setManageMode(enabled: Boolean) {
if (!enabled) setVerifyMode(false)
isManageMode = enabled isManageMode = enabled
if (!enabled) managedCardKey = null if (!enabled) managedCardKey = null
requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card) 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) val mibFrozen = item is CardItem.Mib && isMibCardFrozen(item.card.cardStatus)
binding.btnChangePin.isEnabled = !mibFrozen binding.btnChangePin.isEnabled = !mibFrozen
binding.btnBlock.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() { private fun rebindManagedCardIfNeeded() {
@@ -1038,6 +1283,10 @@ class CardsFragment : Fragment() {
} }
fun onBackPressed(): Boolean { fun onBackPressed(): Boolean {
if (isVerifyMode) {
setVerifyMode(false)
return true
}
if (isTapMode) { if (isTapMode) {
setTapMode(false) setTapMode(false)
return true return true
@@ -1051,6 +1300,7 @@ class CardsFragment : Fragment() {
override fun onPause() { override fun onPause() {
super.onPause() super.onPause()
if (isVerifyMode) stopVerifyReader()
if (isTapMode) { if (isTapMode) {
BmlHostCardEmulatorService.clearToken() BmlHostCardEmulatorService.clearToken()
BmlHostCardEmulatorService.onTransactionComplete = null BmlHostCardEmulatorService.onTransactionComplete = null
@@ -1059,7 +1309,9 @@ class CardsFragment : Fragment() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (isVerifyMode) startVerifyReader()
requireActivity().title = getString(when { requireActivity().title = getString(when {
isVerifyMode -> R.string.card_verify_title
isTapMode -> R.string.card_pay_nfc isTapMode -> R.string.card_pay_nfc
isManageMode -> R.string.card_manage isManageMode -> R.string.card_manage
else -> R.string.nav_pay_with_card else -> R.string.nav_pay_with_card
@@ -1067,6 +1319,7 @@ class CardsFragment : Fragment() {
} }
override fun onDestroyView() { override fun onDestroyView() {
if (isVerifyMode) stopVerifyReader()
tapAnimView?.stopAnimation() tapAnimView?.stopAnimation()
tapAnimView = null tapAnimView = null
BmlHostCardEmulatorService.clearToken() BmlHostCardEmulatorService.clearToken()
@@ -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:<accountNumber>", "mib:<cardId>").
*/
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)
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material "credit_score": card with a check mark -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M20,4H4C2.89,4 2.01,4.89 2.01,6L2,18c0,1.11 0.89,2 2,2h5v-2H4v-6h18V6C22,4.89 21.11,4 20,4zM20,8H4V6h16V8zM14.93,19.17l-2.83,-2.83l-1.41,1.41L14.93,22L22,14.93l-1.41,-1.41L14.93,19.17z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M20,5L4,5c-1.1,0 -1.99,0.9 -1.99,2L2,17c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2L22,7c0,-1.1 -0.9,-2 -2,-2zM11,8h2v2h-2L11,8zM11,11h2v2h-2v-2zM8,8h2v2L8,10L8,8zM8,11h2v2L8,13v-2zM7,13L5,13v-2h2v2zM7,10L5,10L5,8h2v2zM16,17L8,17v-2h8v2zM16,13h-2v-2h2v2zM16,10h-2L14,8h2v2zM19,13h-2v-2h2v2zM19,10h-2L17,8h2v2z" />
</vector>
@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="24dp"
android:paddingTop="12dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilName"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:hint="@string/card_verify_name_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPersonName" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilCardNumber"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/card_verify_number_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etCardNumber"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:digits="0123456789 "
android:inputType="number"
android:maxLength="23"
android:autofillHints="creditCardNumber" />
</com.google.android.material.textfield.TextInputLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilExpiry"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
android:hint="@string/card_verify_expiry_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etExpiry"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:digits="0123456789/"
android:inputType="number"
android:maxLength="5"
android:autofillHints="creditCardExpirationDate" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilCvv"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:hint="@string/card_verify_cvv_hint"
app:endIconMode="password_toggle">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etCvv"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="numberPassword"
android:maxLength="4"
android:autofillHints="creditCardSecurityCode" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</LinearLayout>
</ScrollView>
@@ -87,10 +87,19 @@
<!-- Flexible spacer: absorbs remaining space, pushes buttons to bottom --> <!-- Flexible spacer: absorbs remaining space, pushes buttons to bottom -->
<View <View
android:id="@+id/bottomSpacer"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" /> android:layout_weight="1" />
<!-- Card verification animation (verify mode only); takes the spacer's place -->
<FrameLayout
android:id="@+id/flVerifyArea"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:visibility="gone" />
<!-- Divider --> <!-- Divider -->
<View <View
android:id="@+id/divider" android:id="@+id/divider"
@@ -265,6 +274,73 @@
app:iconGravity="top" app:iconGravity="top"
app:iconPadding="6dp" /> app:iconPadding="6dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnVerify"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:minWidth="0dp"
android:minHeight="0dp"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="@string/card_action_verify"
android:textSize="12sp"
app:icon="@drawable/ic_card_verify"
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
</LinearLayout>
<!-- Card verification actions (verify mode only); styled like llManageButtons -->
<LinearLayout
android:id="@+id/llVerifyButtons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="8dp"
android:paddingTop="8dp"
android:paddingBottom="12dp"
android:visibility="gone">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnCancelVerify"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:minWidth="0dp"
android:minHeight="0dp"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="@string/card_verify_cancel"
android:textSize="12sp"
app:icon="@drawable/ic_close"
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnManualVerify"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:minWidth="0dp"
android:minHeight="0dp"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="@string/card_verify_manual"
android:textSize="12sp"
app:icon="@drawable/ic_keyboard"
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
+23
View File
@@ -387,6 +387,29 @@
<string name="card_action_freeze">Freeze</string> <string name="card_action_freeze">Freeze</string>
<string name="card_action_unfreeze">Unfreeze</string> <string name="card_action_unfreeze">Unfreeze</string>
<string name="card_action_block">Block</string> <string name="card_action_block">Block</string>
<string name="card_action_verify">Verify</string>
<string name="card_action_verified">Verified</string>
<string name="card_verify_title">Verify Card</string>
<string name="card_verify_tap">Tap card to verify</string>
<string name="card_verify_reading">Reading card… hold still</string>
<string name="card_verify_matched">Card matched</string>
<string name="card_verify_read_failed">Couldn\'t read the card, try again</string>
<string name="card_verify_mismatch">Card ending %1$s doesn\'t match</string>
<string name="card_verify_cancel">Cancel Verification</string>
<string name="card_verify_manual">Manually Verify</string>
<string name="card_verify_manual_title">Enter Your Card Details</string>
<string name="card_verify_nfc_disabled_message">Turn on NFC to verify your card by tapping it, or enter the details manually.</string>
<string name="card_verify_cvv_title">Card ending %1$s</string>
<string name="card_verify_cvv_hint">CVV</string>
<string name="card_verify_cvv_invalid">Enter a 3 or 4 digit CVV</string>
<string name="card_verify_confirm">Verify</string>
<string name="card_verify_name_hint">Name on card</string>
<string name="card_verify_number_hint">Card number</string>
<string name="card_verify_expiry_hint">Expiry (MM/YY)</string>
<string name="card_verify_number_invalid">Enter a valid card number</string>
<string name="card_verify_number_mismatch">Number must end in %1$s</string>
<string name="card_verify_expiry_invalid">Enter a valid expiry, e.g. 08/29</string>
<string name="card_verify_success">Card verified</string>
<string name="card_freeze_confirm_title">Freeze card?</string> <string name="card_freeze_confirm_title">Freeze card?</string>
<string name="card_freeze_confirm_message">This will temporarily stop the card from being used. You can unfreeze it anytime you want to use it again.</string> <string name="card_freeze_confirm_message">This will temporarily stop the card from being used. You can unfreeze it anytime you want to use it again.</string>
<string name="card_unfreeze_confirm_title">Unfreeze card?</string> <string name="card_unfreeze_confirm_title">Unfreeze card?</string>