forked from thijooree/android
Gateway payments via card, Step 1: verify cards
This commit is contained in:
@@ -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.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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 -->
|
||||
<View
|
||||
android:id="@+id/bottomSpacer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
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 -->
|
||||
<View
|
||||
android:id="@+id/divider"
|
||||
@@ -265,6 +274,73 @@
|
||||
app:iconGravity="top"
|
||||
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>
|
||||
|
||||
@@ -387,6 +387,29 @@
|
||||
<string name="card_action_freeze">Freeze</string>
|
||||
<string name="card_action_unfreeze">Unfreeze</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_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>
|
||||
|
||||
Reference in New Issue
Block a user