Compare commits

..
9 Commits
Author SHA1 Message Date
shihaam 2b2fd59543 Gateway payments via card, Step 1: verify cards
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-10-01 05:08:17 +05:00
shihaam b97d0c5a18 Release v1.0.33
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m35s
2026-09-30 21:57:09 +05:00
shihaam 4ac328cf52 App lock icon does not go behind navigation bar in landscape mode 2026-09-30 21:56:43 +05:00
shihaam c2f473a6a3 UI is more vivid when merchant sets amount
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-30 21:52:17 +05:00
shihaam 9f40c56b49 Fix UX issues with rotation/resize window and merchant info loading for card payments
Auto Tag on Version Change / check-version (push) Successful in 7s
2026-09-30 21:50:57 +05:00
shihaam 0747fbdbfd add logos to top seed docs: png microsoft
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-29 12:24:49 +05:00
shihaam 528e9eeef8 add logos to top seed docs
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-09-29 12:22:22 +05:00
shihaam 0e7f329a4b release v1.0.31
Auto Tag on Version Change / check-version (push) Successful in 4s
Build and Release APK / build (push) Successful in 3m15s
2026-09-27 00:31:12 +05:00
shihaam 2082e8fd0c Pay with BML Transaction ID 2026-09-27 00:28:12 +05:00
28 changed files with 1598 additions and 149 deletions
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 36
versionCode = 31
versionName = "1.0.30"
versionCode = 33
versionName = "1.0.32"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+6
View File
@@ -32,6 +32,7 @@
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
@@ -45,20 +46,24 @@
<activity
android:name=".LockActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
android:exported="false"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.onboarding.OnboardingActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
android:exported="false" />
<activity
android:name=".ui.login.LoginActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
android:exported="false"
android:windowSoftInputMode="adjustResize" />
<activity
android:name=".ui.home.HomeActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|locale|layoutDirection|fontScale|density"
android:exported="false"
android:windowSoftInputMode="adjustPan" />
@@ -69,6 +74,7 @@
<activity
android:name=".nfc.BmlTapToPayActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
android:exported="false"
android:launchMode="singleTop"
android:theme="@style/Theme.BasedBank" />
@@ -0,0 +1,82 @@
package sh.sar.basedbank.api.bml
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
/**
* BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/<id>`),
* e.g. the bill links Fenaka sends. The web page only shows a QR; this fetches the QR's text so it
* can go through the regular BML QR payment flow.
*/
class BmlMerchantTxnClient {
private val client = newBmlApiClient()
/**
* Returns the transaction's EMV QR payload (`vendorQrCode`).
*
* A GET on the transaction is 401 without the page's Cognito credentials, but the PATCHes the
* page itself sends need no auth and return the full transaction:
* - on load, `activeBrowserId` (`<id>_<epoch millis>`);
* - on picking "BML" as the payment method, `provider: bml_mpos`.
*
* A fresh link has no provider yet, so `vendorQrCode` is null until the second PATCH selects
* one. Links already opened with BML chosen return it from the first.
*/
fun fetchQrPayload(transactionId: String): String {
val browserId = JSONObject()
.put("activeBrowserId", "${transactionId}_${System.currentTimeMillis()}")
patch(transactionId, browserId).vendorQrCode()?.let { return it }
// Whether the provider PATCH returns the QR itself or it is generated a moment later has
// not been observed, so re-read a few times before giving up. Re-reads use the load PATCH:
// each provider PATCH counts as another payment attempt.
var txn = patch(transactionId, JSONObject().put("provider", PROVIDER_BML))
repeat(3) {
txn.vendorQrCode()?.let { return it }
Thread.sleep(1000)
txn = patch(transactionId, browserId)
}
return txn.vendorQrCode() ?: throw Exception("Transaction has no QR")
}
private fun patch(transactionId: String, body: JSONObject): JSONObject {
val request = Request.Builder()
.url("$API_BASE/transactions/$transactionId")
.patch(body.toString().toRequestBody("application/json".toMediaType()))
.header("Accept", "*/*")
.header("Origin", PAGE_ORIGIN)
.header("Referer", "$PAGE_ORIGIN/")
.build()
return client.newCall(request).execute().use { response ->
val text = response.body?.string().orEmpty()
if (!response.isSuccessful || !text.trimStart().startsWith("{"))
throw Exception("Transaction lookup failed (HTTP ${response.code})")
JSONObject(text)
}
}
/**
* No state check: only QR_CODE_GENERATED has been observed, and BML's payrequest lookup
* already rejects a paid or expired QR with its own message. `isNull` first — `optString`
* turns a JSON null into the string "null".
*/
private fun JSONObject.vendorQrCode(): String? =
if (isNull("vendorQrCode")) null else optString("vendorQrCode").ifBlank { null }
companion object {
private const val API_BASE = "https://api.merchants.bankofmaldives.com.mv"
private const val PAGE_ORIGIN = "https://transaction.merchants.bankofmaldives.com.mv"
private const val PROVIDER_BML = "bml_mpos"
private val TXN_URL = Regex("^https?://transaction\\.merchants\\.bankofmaldives\\.com\\.mv/([0-9a-fA-F]{24})(?:[/?#].*)?$")
private val TXN_ID = Regex("^[0-9a-fA-F]{24}$")
/** The transaction ID from a bare 24-hex ID or a pasted payment link, else null. */
fun parseTransactionId(input: String): String? {
val s = input.trim()
val id = if (TXN_ID.matches(s)) s else TXN_URL.find(s)?.groupValues?.get(1)
return id?.lowercase()
}
}
}
@@ -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
}
}
@@ -25,7 +25,9 @@ import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -104,6 +106,57 @@ class HomeActivity : AppCompatActivity() {
if (securitySet) lock()
}
// ── Payment guard ─────────────────────────────────────────────────────────
//
// The manifest has this activity handle theme, language, font-size and display-size changes
// itself, because recreating it mid-payment tears down the screen waiting on the bank's
// answer — the money can move with nothing left to say so. Those changes still need a
// recreate to re-inflate with the new resources, so it runs straight away when nothing is in
// flight and otherwise waits until the last payment finishes.
private var paymentsInFlight = 0
private var recreatePending = false
private var lastConfig: Configuration? = null
/** A payment in flight; [end] it once the outcome is on screen. Ending twice is harmless. */
inner class PaymentGuard internal constructor() {
private var ended = false
fun end() {
if (ended) return
ended = true
paymentsInFlight--
if (paymentsInFlight == 0 && recreatePending) {
recreatePending = false
// Posted so a receipt screen committed in the same pass is saved with the state
binding.root.post { recreate() }
}
}
}
/** Holds off recreation until the guard ends, or [owner] is destroyed, whichever is first. */
fun beginPayment(owner: LifecycleOwner): PaymentGuard {
paymentsInFlight++
val guard = PaymentGuard()
owner.lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onDestroy(owner: LifecycleOwner) = guard.end()
})
return guard
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
val previous = lastConfig
lastConfig = Configuration(newConfig)
// Size and orientation changes are handled in place; these need fresh resources.
val needsRecreate = android.content.pm.ActivityInfo.CONFIG_UI_MODE or
android.content.pm.ActivityInfo.CONFIG_LOCALE or
android.content.pm.ActivityInfo.CONFIG_LAYOUT_DIRECTION or
android.content.pm.ActivityInfo.CONFIG_FONT_SCALE or
android.content.pm.ActivityInfo.CONFIG_DENSITY
if (previous == null || (previous.diff(newConfig) and needsRecreate) == 0) return
if (paymentsInFlight > 0) recreatePending = true else recreate()
}
fun lockApp() = lock()
fun notifyWheelLockTap() {
@@ -136,6 +189,7 @@ class HomeActivity : AppCompatActivity() {
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
}
setContentView(binding.root)
lastConfig = Configuration(resources.configuration)
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = isLight
@@ -171,6 +225,16 @@ class HomeActivity : AppCompatActivity() {
insets
}
// The app bar only pads for the status bar. In landscape the navigation bar (and any
// cutout) sits at a side edge, and the toolbar's end icons — the lock button — would
// draw underneath it, out of reach.
ViewCompat.setOnApplyWindowInsetsListener(binding.toolbar) { v, insets ->
val sides = insets.getInsets(
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
v.updatePadding(left = sides.left, right = sides.right)
insets
}
binding.bottomNavigation.setOnItemSelectedListener { item ->
if (suppressBottomNavCallback) return@setOnItemSelectedListener true
val frag = when (item.itemId) {
@@ -528,6 +592,10 @@ fun applyNavLabelVisibility() {
private fun routeSharedQrText(text: String) {
val store = CredentialStore(this)
sh.sar.basedbank.api.bml.BmlMerchantTxnClient.parseTransactionId(text)?.let {
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlTxn(it))
return
}
val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text)
if (bmlTarget != null) {
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber()))
@@ -56,4 +56,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
* for HTTP 5xx server errors from specific banks.
*/
val connectivityErrors = MutableLiveData<Set<String>>(emptySet())
/** The Transfer screen's form, kept here so tab switches and recreation don't lose it. */
var transferDraft = sh.sar.basedbank.ui.home.transfer.TransferDraft()
}
@@ -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
@@ -123,15 +126,11 @@ class CardsFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val screenW = resources.displayMetrics.widthPixels
val peekPx = screenW / 8
cardWidth = screenW - 2 * peekPx
stackAdapter = CardStackAdapter(cardWidth)
stackAdapter = CardStackAdapter()
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
binding.rvCards.adapter = stackAdapter
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
binding.rvCards.clipToPadding = false
applyCarouselWidth()
val snapHelper = PagerSnapHelper()
snapHelper.attachToRecyclerView(binding.rvCards)
@@ -269,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) {
@@ -404,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)
@@ -445,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() {
@@ -637,8 +878,13 @@ class CardsFragment : Fragment() {
// ── Tap-to-pay mode ────────────────────────────────────────────────────────
/** Held while tap mode is up: recreating the activity would clear the NFC payment token. */
private var tapGuard: HomeActivity.PaymentGuard? = null
private fun setTapMode(enabled: Boolean, item: CardItem.Bml? = null) {
isTapMode = enabled
tapGuard?.end()
tapGuard = if (enabled) (activity as? HomeActivity)?.beginPayment(viewLifecycleOwner) else null
requireActivity().title = getString(if (enabled) R.string.card_pay_nfc else R.string.nav_pay_with_card)
if (enabled) enterTapMode(item!!) else exitTapMode()
}
@@ -959,6 +1205,25 @@ class CardsFragment : Fragment() {
}
}
/** Sizes the carousel from the window width: each card leaves a 1/8 peek on either side. */
private fun applyCarouselWidth() {
val screenW = resources.displayMetrics.widthPixels
val peekPx = screenW / 8
cardWidth = screenW - 2 * peekPx
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
}
// HomeActivity handles size changes itself (rotation, split screen) rather than being
// recreated, so the carousel has to re-measure for the new width on its own.
override fun onConfigurationChanged(newConfig: android.content.res.Configuration) {
super.onConfigurationChanged(newConfig)
if (_binding == null) return
applyCarouselWidth()
stackAdapter.notifyDataSetChanged()
binding.rvCards.scrollToPosition(currentCardPosition)
binding.rvCards.post { if (_binding != null) applyCardScales() }
}
private fun applyCardScales() {
val rv = binding.rvCards
val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f
@@ -1018,6 +1283,10 @@ class CardsFragment : Fragment() {
}
fun onBackPressed(): Boolean {
if (isVerifyMode) {
setVerifyMode(false)
return true
}
if (isTapMode) {
setTapMode(false)
return true
@@ -1031,6 +1300,7 @@ class CardsFragment : Fragment() {
override fun onPause() {
super.onPause()
if (isVerifyMode) stopVerifyReader()
if (isTapMode) {
BmlHostCardEmulatorService.clearToken()
BmlHostCardEmulatorService.onTransactionComplete = null
@@ -1039,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
@@ -1047,6 +1319,7 @@ class CardsFragment : Fragment() {
}
override fun onDestroyView() {
if (isVerifyMode) stopVerifyReader()
tapAnimView?.stopAnimation()
tapAnimView = null
BmlHostCardEmulatorService.clearToken()
@@ -1055,7 +1328,7 @@ class CardsFragment : Fragment() {
_binding = null
}
private inner class CardStackAdapter(private val cardWidth: Int) : RecyclerView.Adapter<CardStackAdapter.VH>() {
private inner class CardStackAdapter : RecyclerView.Adapter<CardStackAdapter.VH>() {
private var items: List<CardItem> = emptyList()
fun update(newItems: List<CardItem>) {
@@ -1070,6 +1343,8 @@ class CardsFragment : Fragment() {
override fun onBindViewHolder(holder: VH, position: Int) {
holder.bind(items[position])
// Re-applied on every bind so a width change reaches recycled holders too
holder.itemView.layoutParams.width = cardWidth
// Pre-scale based on data position so initial render and off-screen cards are correct
val fraction = abs(position - currentCardPosition).toFloat().coerceIn(0f, 1f)
val scale = 1f - 0.18f * fraction
@@ -39,6 +39,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
@@ -50,6 +51,7 @@ import sh.sar.basedbank.ui.home.transfer.BmlTransferHandler
import sh.sar.basedbank.ui.home.transfer.FahipayTransferHandler
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
import sh.sar.basedbank.ui.home.transfer.MibTransferHandler
import sh.sar.basedbank.ui.home.transfer.TransferDraft
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.AccountInputParser
@@ -66,7 +68,16 @@ class TransferFragment : Fragment() {
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private var selectedAccount: BankAccount? = null
/**
* The form lives on the activity's ViewModel (see [TransferDraft]) so a tab switch or a
* theme/language recreation repaints it rather than starting over. The properties below are
* the fragment's view of it.
*/
private val draft: TransferDraft get() = viewModel.transferDraft
private var selectedAccount: BankAccount?
get() = draft.selectedAccount
set(value) { draft.selectedAccount = value }
private fun bmlSessionFor(account: BankAccount?) = bmlHandler().sessionFor(account)
/**
@@ -77,21 +88,36 @@ class TransferFragment : Fragment() {
private val mibHandler by lazy { MibTransferHandler(this) { selectedAccount } }
// Resolved recipient info — set after successful lookup or prefill
private var resolvedAccountNumber = ""
private var resolvedRecipientName = ""
private var resolvedBankName = ""
/** Last real profile/contact photo loaded into the "To" card (not an initials placeholder). */
private var loadedToPhoto: Bitmap? = null
private var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown
private var resolvedToOwnAccount: BankAccount? = null
private var resolvedAccountNumber: String
get() = draft.resolvedAccountNumber
set(value) { draft.resolvedAccountNumber = value }
private var resolvedRecipientName: String
get() = draft.resolvedRecipientName
set(value) { draft.resolvedRecipientName = value }
private var resolvedBankName: String
get() = draft.resolvedBankName
set(value) { draft.resolvedBankName = value }
private var loadedToPhoto: Bitmap?
get() = draft.loadedToPhoto
set(value) { draft.loadedToPhoto = value }
private var resolvedDestCurrency: String
get() = draft.resolvedDestCurrency
set(value) { draft.resolvedDestCurrency = value }
private var resolvedToOwnAccount: BankAccount?
get() = draft.resolvedToOwnAccount
set(value) { draft.resolvedToOwnAccount = value }
private var savedToSubtitle: String
get() = draft.toSubtitle
set(value) { draft.toSubtitle = value }
private var savedToColorHex: String
get() = draft.toColorHex
set(value) { draft.toColorHex = value }
private var savedToImageHash: String?
get() = draft.toImageHash
set(value) { draft.toImageHash = value }
// Form state preserved across view destroy/create when the fragment instance is cached
private var savedAmount = ""
private var savedRemarks = ""
private var savedToText = ""
private var savedToSubtitle = ""
private var savedToColorHex = "#607D8B"
private var savedToImageHash: String? = null
/** Set when this view applied the fragment's arguments, so the accounts observer may too. */
private var argsAppliedThisView = false
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
@@ -111,6 +137,7 @@ class TransferFragment : Fragment() {
viewModel = viewModel,
currentSource = { selectedAccount },
selectSource = ::selectSourceAccount,
clearSource = ::clearSourceAccount,
onStateChanged = { updateTransferButton() },
onTransferSuccess = { receipt, avatar ->
ReceiptStore.save(requireContext(), receipt)
@@ -177,13 +204,17 @@ class TransferFragment : Fragment() {
if (result.resultCode != Activity.RESULT_OK) return
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return
// BML Merchant Services payment link — resolve it to the QR its page would show
BmlMerchantTxnClient.parseTransactionId(raw)?.let {
binding.etTo.setText(it)
lookupBmlMerchantTransaction(it)
return
}
// BML card/gateway/POS QR — hand off to dedicated payment screen
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
if (bmlTarget != null) {
val fromCard = selectedAccount?.takeIf {
it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT"
}
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, fromCard?.accountNumber))
openBmlQr(bmlTarget)
return
}
@@ -247,6 +278,9 @@ class TransferFragment : Fragment() {
private const val ARG_REMARKS_PREFILL = "remarks_prefill"
private const val ARG_BML_QR_URL = "bml_qr_url"
private const val ARG_AUTO_SCAN = "auto_scan"
private const val ARG_BML_TXN_ID = "bml_txn_id"
/** Set once the arguments have been applied, so later views restore the draft instead. */
private const val ARG_APPLIED = "args_applied"
fun newInstanceWithAutoScan() = TransferFragment().apply {
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
@@ -259,6 +293,11 @@ class TransferFragment : Fragment() {
}
}
/** Opens on a BML Merchant Services transaction ID, which is resolved to its QR on load. */
fun newInstanceFromBmlTxn(transactionId: String) = TransferFragment().apply {
arguments = Bundle().apply { putString(ARG_BML_TXN_ID, transactionId) }
}
fun newInstanceFrom(account: BankAccount) = TransferFragment().apply {
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
}
@@ -304,6 +343,16 @@ class TransferFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// A screen opened with its own arguments (scanned QR, picked contact…) starts a fresh
// draft, once. Every later view — tab switch back, theme recreation — restores instead.
// The flag lives in the arguments so it survives the fragment being recreated too.
val args = arguments
argsAppliedThisView = args != null && !args.getBoolean(ARG_APPLIED, false)
if (argsAppliedThisView) {
viewModel.transferDraft = TransferDraft()
args!!.putBoolean(ARG_APPLIED, true)
}
qrLauncher = requireActivity().activityResultRegistry.register(
qrLauncherKey, viewLifecycleOwner, ActivityResultContracts.StartActivityForResult()
) { onQrScanned(it) }
@@ -367,6 +416,19 @@ class TransferFragment : Fragment() {
binding.etAmount.addTextChangedListener { updateTransferButton() }
if (argsAppliedThisView) applyArguments()
}
override fun onViewStateRestored(savedInstanceState: Bundle?) {
super.onViewStateRestored(savedInstanceState)
// Repaint here, not in onViewCreated: after a recreation the framework restores the
// EditTexts' old text in between, and that setText on the To field fires its "user
// edited the recipient" listener — which would hide a To card painted earlier.
if (!argsAppliedThisView) restoreFromDraft()
}
/** First view of a screen opened with arguments: prefill from them. */
private fun applyArguments() {
// Pre-select contact if navigated from contacts page or QR scan
arguments?.getString(ARG_ACCOUNT)?.let { account ->
prefillToDirectly(
@@ -380,15 +442,34 @@ class TransferFragment : Fragment() {
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
arguments?.getString(ARG_BML_QR_URL)?.let { bmlHandler().lookupQrMerchant(it) }
arguments?.getString(ARG_BML_QR_URL)?.let { openBmlQr(it) }
arguments?.getString(ARG_BML_TXN_ID)?.let {
// Shown in the To field so a failed lookup leaves the ID there to retry or correct.
binding.etTo.setText(it)
lookupBmlMerchantTransaction(it)
}
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
launchQrScanner()
}
}
// Restore form state when view is recreated on the cached no-args instance
if (arguments == null) {
if (resolvedAccountNumber.isNotEmpty()) {
/**
* Repaints a recreated view from [draft] — nothing is looked up again. The From card is
* repainted by the accounts observer; a BML QR lookup that never finished is retried there.
*/
private fun restoreFromDraft() {
// Amount first: a dynamic merchant QR overwrites and locks it below
if (draft.amount.isNotEmpty()) binding.etAmount.setText(draft.amount)
if (draft.remarks.isNotEmpty()) binding.etRemarks.setText(draft.remarks)
val bmlQr = draft.bmlQrInfo
val mfaisaQr = draft.mfaisaQrInfo
val mfaisaRecipient = draft.mfaisaRecipient
when {
bmlQr != null -> bmlHandler().showQrMerchant(bmlQr)
mfaisaQr != null -> mfaisaHandler().showQrMerchant(mfaisaQr)
mfaisaRecipient != null -> mfaisaHandler().showResolvedRecipient(mfaisaRecipient, saveRecent = false)
resolvedAccountNumber.isNotEmpty() -> {
val ownAccount = viewModel.accounts.value?.firstOrNull { it.accountNumber == resolvedAccountNumber }
if (ownAccount != null) {
showToCard(ownAccount)
@@ -404,22 +485,40 @@ class TransferFragment : Fragment() {
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE
if (savedToImageHash != null) loadToPhoto(savedToImageHash!!, isProfile = resolvedToOwnAccount != null)
} else if (savedToText.isNotEmpty()) {
binding.etTo.setText(savedToText, false)
val photo = loadedToPhoto
if (photo != null) {
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(photo)
} else {
savedToImageHash?.let { loadToPhoto(it, isProfile = resolvedToOwnAccount != null) }
}
}
if (savedAmount.isNotEmpty()) binding.etAmount.setText(savedAmount)
if (savedRemarks.isNotEmpty()) binding.etRemarks.setText(savedRemarks)
updateTransferButton()
draft.toText.isNotEmpty() -> binding.etTo.setText(draft.toText, false)
}
updateTransferButton()
}
/** Restores the To-input row to its default state when a QR lookup fails. */
internal fun resetToFieldVisibility() {
binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE
binding.btnScanQr.visibility = View.VISIBLE
/**
* Freezes the amount at a merchant's preset value. Unlike disabling the field this keeps it
* at full colour — it's the figure being paid, so it should read clearly — and a lock icon
* says why it can't be typed into.
*/
internal fun setAmountLocked(locked: Boolean) {
binding.etAmount.apply {
isFocusable = !locked
isFocusableInTouchMode = !locked
isCursorVisible = !locked
isLongClickable = !locked
if (locked) clearFocus()
}
binding.tilAmount.apply {
if (locked) {
endIconMode = com.google.android.material.textfield.TextInputLayout.END_ICON_CUSTOM
endIconDrawable = ContextCompat.getDrawable(requireContext(), R.drawable.ic_lock)
} else {
endIconMode = com.google.android.material.textfield.TextInputLayout.END_ICON_NONE
}
}
}
internal fun startLookupLoading() {
@@ -446,14 +545,7 @@ class TransferFragment : Fragment() {
}
private fun setupFromDropdown() {
binding.btnClearFromInfo.setOnClickListener {
selectedAccount = null
binding.tilAmount.prefixText = null
binding.cardFromInfo.visibility = View.GONE
binding.tilFrom.visibility = View.VISIBLE
binding.actvFrom.setText("", false)
updateTransferButton()
}
binding.btnClearFromInfo.setOnClickListener { clearSourceAccount() }
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
accountDropdownAdapter = AccountDropdownAdapter(requireContext(), accounts)
@@ -480,7 +572,7 @@ class TransferFragment : Fragment() {
updateTransferButton()
}
val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)
val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)?.takeIf { argsAppliedThisView }
if (fromNumber != null && selectedAccount == null) {
val match = accounts.firstOrNull { it.accountNumber == fromNumber }
if (match != null) {
@@ -492,7 +584,7 @@ class TransferFragment : Fragment() {
}
// Auto-select default account when arriving from contacts page (TO account already pre-filled)
if (selectedAccount == null && arguments?.getString(ARG_ACCOUNT) != null) {
if (selectedAccount == null && argsAppliedThisView && arguments?.getString(ARG_ACCOUNT) != null) {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
if (defaultNum != null) {
val defaultAcc = accounts.firstOrNull { it.accountNumber == defaultNum }
@@ -506,12 +598,9 @@ class TransferFragment : Fragment() {
}
// On a cold start (e.g. share intent), anyBmlSession() may be null when
// onViewCreated runs. Retry the lookup once sessions are available.
val pendingBmlQrUrl = arguments?.getString(ARG_BML_QR_URL)
if (pendingBmlQrUrl != null && !bmlHandler().qrLookupAttempted) {
val app = requireActivity().application as BasedBankApp
if (app.anyBmlSession() != null) bmlHandler().lookupQrMerchant(pendingBmlQrUrl)
}
// onViewCreated runs; a lookup can also have been cut off by leaving the tab.
// Retry it once sessions are available.
draft.pendingBmlQrTarget?.let { bmlHandler().lookupQrMerchant(it) }
// Re-render the from card when the view is recreated on a cached instance
if (selectedAccount != null && binding.cardFromInfo.visibility != View.VISIBLE) {
@@ -528,7 +617,7 @@ class TransferFragment : Fragment() {
binding.tilTo.hint = getString(R.string.ooredoo_phone)
binding.etTo.inputType = android.text.InputType.TYPE_CLASS_PHONE
// Any previously-resolved non-MFAISA recipient (or stale state) is no longer valid
if (resolvedAccountNumber.isNotBlank() && mfaisaHandler?.recipient == null) {
if (resolvedAccountNumber.isNotBlank() && draft.mfaisaRecipient == null) {
resolvedAccountNumber = ""
resolvedRecipientName = ""
resolvedDestCurrency = ""
@@ -541,8 +630,8 @@ class TransferFragment : Fragment() {
binding.tilTo.hint = getString(R.string.transfer_to)
binding.etTo.inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
// Drop any M-Faisa-resolved recipient when switching banks
if (mfaisaHandler?.recipient != null) {
mfaisaHandler?.clearState()
if (draft.mfaisaRecipient != null) {
mfaisaHandler().clearState()
resolvedAccountNumber = ""
resolvedRecipientName = ""
resolvedDestCurrency = ""
@@ -713,6 +802,15 @@ class TransferFragment : Fragment() {
updateTransferButton()
}
private fun clearSourceAccount() {
selectedAccount = null
binding.tilAmount.prefixText = null
binding.cardFromInfo.visibility = View.GONE
binding.tilFrom.visibility = View.VISIBLE
binding.actvFrom.setText("", false)
updateTransferButton()
}
private fun updateAmountPrefix(account: BankAccount) {
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
}
@@ -765,7 +863,41 @@ class TransferFragment : Fragment() {
setupContactDropdown()
}
/**
* Switches this screen into BML merchant-QR mode, keeping a selected BML card as the source.
* Done in place: reopening the screen for it rebuilt the whole form and made the To row
* vanish and reappear.
*/
private fun openBmlQr(bmlTarget: String) {
bmlHandler().lookupQrMerchant(bmlTarget)
}
/**
* A BML Merchant Services transaction ID (or its payment link) typed into the To field: fetch
* the QR the payment page would show and pay it like a scanned one.
*/
private fun lookupBmlMerchantTransaction(transactionId: String) {
startLookupLoading()
viewLifecycleOwner.lifecycleScope.launch {
val target = withContext(Dispatchers.IO) {
runCatching { BmlMerchantTxnClient().fetchQrPayload(transactionId) }
.getOrNull()?.let { PaymvQrParser.bmlQrPayTarget(it) }
}
if (_binding == null) return@launch
stopLookupLoading()
if (target == null) {
binding.tilTo.error = getString(R.string.transfer_bml_txn_lookup_failed)
return@launch
}
openBmlQr(target)
}
}
private fun searchTo() {
BmlMerchantTxnClient.parseTransactionId(binding.etTo.text?.toString().orEmpty())?.let {
lookupBmlMerchantTransaction(it)
return
}
// M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails)
if (selectedAccount?.bank == "MFAISA") {
mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty())
@@ -1396,6 +1528,10 @@ class TransferFragment : Fragment() {
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
dialog.setCancelable(false)
// From here until the outcome is dismissed the payment is in flight: a theme/language
// change waits rather than recreating the screen out from under it.
val guard = (activity as? HomeActivity)?.beginPayment(viewLifecycleOwner)
dialog.setOnDismissListener { guard?.end() }
val ctx = requireContext()
val dp = resources.displayMetrics.density
val spinner = CircularProgressDrawable(ctx).apply {
@@ -1556,6 +1692,7 @@ class TransferFragment : Fragment() {
binding.tilFrom.visibility = View.VISIBLE
binding.tilAmount.prefixText = null
binding.tilAmount.isEnabled = true
setAmountLocked(false)
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
binding.etAmount.setText("")
@@ -1657,15 +1794,14 @@ class TransferFragment : Fragment() {
override fun onDestroyView() {
super.onDestroyView()
// Persist form state so it can be restored when the view is recreated
savedAmount = binding.etAmount.text?.toString() ?: ""
savedRemarks = binding.etRemarks.text?.toString() ?: ""
savedToText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else ""
// The bank handlers hold binding refs; drop them so the next view gets fresh ones.
// Clearing also resets any in-progress OTP flow, which cannot sensibly resume.
draft.amount = binding.etAmount.text?.toString() ?: ""
draft.remarks = binding.etRemarks.text?.toString() ?: ""
draft.toText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else ""
// The bank handlers hold binding refs; drop them so the next view gets fresh ones. What
// they resolved lives in the draft; an in-progress BML OTP flow cannot sensibly resume.
bmlHandler?.clearState()
bmlHandler = null
fahipayHandler = null
mfaisaHandler?.clearState()
mfaisaHandler = null
// Unregistered automatically with viewLifecycleOwner; drop the stale handle.
qrLauncher = null
@@ -59,6 +59,8 @@ class BmlTransferHandler(
private val currentSource: () -> BankAccount?,
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
private val selectSource: (BankAccount) -> Unit,
/** Asks the fragment to drop the selected source and show the empty From picker. */
private val clearSource: () -> Unit,
/** Hook called whenever handler state changes in a way that affects the Send button. */
private val onStateChanged: () -> Unit,
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes balances. */
@@ -74,6 +76,18 @@ class BmlTransferHandler(
/** Business-profile OTP flow. NONE means the Send button behaves normally. */
private enum class OtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
private var otpState = OtpState.NONE
set(value) {
// The whole OTP flow counts as a payment in flight: a theme change mid-way would
// otherwise recreate the screen between initiate and confirm.
if (field == OtpState.NONE && value != OtpState.NONE) {
otpGuard = host?.beginPayment(fragment.viewLifecycleOwner)
} else if (value == OtpState.NONE) {
otpGuard?.end()
otpGuard = null
}
field = value
}
private var otpGuard: HomeActivity.PaymentGuard? = null
private var otpChannel: String? = null
private data class PendingTransfer(
@@ -93,14 +107,15 @@ class BmlTransferHandler(
)
private var pendingTransfer: PendingTransfer? = null
// Merchant QR state lives in the draft so it outlives this handler (dropped with the view).
private val draft get() = viewModel.transferDraft
/** Merchant QR payment mode (set when navigated from a card/gateway QR scan). */
var qrInfo: BmlQrPayInfo? = null
private set
val qrInfo: BmlQrPayInfo? get() = draft.bmlQrInfo
/** True for pay.bml.com.mv QRs, which need an extra pre-initiate step. */
private var gatewayQr = false
/** Prevents re-running the lookup after the user clears the merchant. */
var qrLookupAttempted = false
private set
private val gatewayQr: Boolean get() = draft.bmlGatewayQr
/** Stops the accounts observer re-firing a lookup that is already running on this view. */
private var qrLookupInFlight = false
// ─── Public API the fragment calls ───────────────────────────────────────
@@ -155,10 +170,11 @@ class BmlTransferHandler(
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
fun clearQrMerchant() {
draft.pendingBmlQrTarget = null
if (qrInfo == null) return
qrInfo = null
gatewayQr = false
binding.tilAmount.isEnabled = true
draft.bmlQrInfo = null
draft.bmlGatewayQr = false
fragment.setAmountLocked(false)
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
binding.etAmount.setText("")
@@ -173,18 +189,25 @@ class BmlTransferHandler(
// ─── Merchant QR ─────────────────────────────────────────────────────────
/**
* Resolves a card/gateway/POS QR to its merchant and switches the screen into QR-pay mode.
* Until it finishes the QR stays in [TransferDraft.pendingBmlQrTarget], which the fragment
* retries once sessions load (cold start) or when the view comes back (tab switched away
* mid-lookup).
*/
fun lookupQrMerchant(qrUrl: String) {
qrLookupAttempted = true
// Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount and
// need the extra pre-initiate POST; ebanking qrpay URLs do not.
gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
// Captured so a lookup finishing after a fresh draft replaced this one can't leak into it
val draft = this.draft
draft.pendingBmlQrTarget = qrUrl
if (qrLookupInFlight) return
val session = app.anyBmlSession() ?: return
qrLookupInFlight = true
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
// Lock the "To" input row while loading
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
// The To row stays on screen with a spinner while loading and is only swapped for the
// merchant card once there is a merchant to show — hiding it up front left a gap that
// made the form jump twice.
fragment.startLookupLoading()
host?.setRefreshing(true)
fragment.viewLifecycleOwner.lifecycleScope.launch {
@@ -192,19 +215,32 @@ class BmlTransferHandler(
runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
}
host?.setRefreshing(false)
qrLookupInFlight = false
if (fragment.view == null) return@launch
if (draft !== viewModel.transferDraft) return@launch
fragment.stopLookupLoading()
// Superseded: cleared meanwhile, or another QR was opened while this one ran
val latest = draft.pendingBmlQrTarget
if (latest != qrUrl) {
latest?.let { lookupQrMerchant(it) }
return@launch
}
draft.pendingBmlQrTarget = null
val info = result.getOrNull()
if (info == null) {
// An expired or rejected QR is BML telling us something specific — show its own
// wording and stay put with the To row restored, rather than bouncing the user out
// of the screen they just scanned from.
// wording and stay put with the To row as it was, rather than bouncing the user
// out of the screen they just scanned from.
val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message
?: ctx.getString(R.string.bml_qr_lookup_failed)
Toast.makeText(ctx, message, Toast.LENGTH_LONG).show()
fragment.resetToFieldVisibility()
onStateChanged()
return@launch
}
qrInfo = info
draft.bmlQrInfo = info
// Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount
// and need the extra pre-initiate POST; ebanking qrpay URLs do not.
draft.bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
if (info.amount == 0.0) {
RecentsCache.save(ctx, RecentPick(
accountNumber = "bmlqr:$qrUrl",
@@ -216,7 +252,13 @@ class BmlTransferHandler(
))
}
// Auto-select the user's default BML card if no card was pre-selected
// Hide the To row before touching the source: repainting the From card re-syncs the
// picker/scan buttons to the To row's visibility.
hideToRow()
// Only a BML card can pay a merchant QR — drop any other source, then auto-select
// the user's default card if no card was pre-selected
if (currentSource()?.let { isCard(it) } == false) clearSource()
if (currentSource() == null) {
val defaultNum = CredentialStore(ctx).getDefaultCardAccountNumber()
if (defaultNum != null) {
@@ -229,29 +271,44 @@ class BmlTransferHandler(
}
}
// Show merchant in the "To" card — clear button hidden (can't change recipient for QR)
binding.tvToAccountName.text = info.merchantName
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(fragment.makeInitialsBitmap(info.merchantName, "#0066A1"))
binding.cardToInfo.visibility = View.VISIBLE
// Pre-fill amount if dynamic QR
if (info.amount > 0.0) {
binding.etAmount.setText("%.2f".format(info.amount))
binding.tilAmount.isEnabled = false
}
// Remarks not applicable for merchant QR payments
binding.tilRemarks.isEnabled = false
binding.tilRemarks.alpha = 0.4f
onStateChanged()
showQrMerchant(info)
}
}
/**
* Paints a looked-up merchant into the "To" card and puts the form in QR-pay mode. Also how a
* recreated view restores it — no network involved.
*/
fun showQrMerchant(info: BmlQrPayInfo) {
hideToRow()
// Clear button hidden (can't change recipient for QR)
binding.tvToAccountName.text = info.merchantName
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(fragment.makeInitialsBitmap(info.merchantName, "#0066A1"))
binding.cardToInfo.visibility = View.VISIBLE
// Pre-fill amount if dynamic QR
if (info.amount > 0.0) {
binding.etAmount.setText("%.2f".format(info.amount))
fragment.setAmountLocked(true)
}
// Remarks not applicable for merchant QR payments
binding.tilRemarks.isEnabled = false
binding.tilRemarks.alpha = 0.4f
onStateChanged()
}
private fun hideToRow() {
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
}
/**
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog and
* reports the outcome inside it — there is no receipt screen for merchant payments.
@@ -54,8 +54,9 @@ class FahipayTransferHandler(
private val ctx get() = fragment.requireContext()
/** The service picked for the current recipient; null until a lookup resolves one. */
var service: FahipayService? = null
private set
var service: FahipayService?
get() = viewModel.transferDraft.fahipayService
private set(value) { viewModel.transferDraft.fahipayService = value }
/** How the confirm dialog names the destination, or "" when nothing is selected. */
val destinationLabel: String get() = service?.destinationLabel.orEmpty()
@@ -68,16 +68,29 @@ class MfaisaTransferHandler(
private val ctx get() = fragment.requireContext()
private val host get() = fragment.activity as? HomeActivity
// Resolved state lives in the draft so it outlives this handler (dropped with the view).
private val draft get() = viewModel.transferDraft
/** Set to the resolved recipient after a successful search; null otherwise. */
var recipient: MfaisaTransferClient.Recipient? = null
private set
var recipient: MfaisaTransferClient.Recipient?
get() = draft.mfaisaRecipient
private set(value) { draft.mfaisaRecipient = value }
/** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */
var qrInfo: MfaisaQrPayClient.QrMerchant? = null
private set
var qrInfo: MfaisaQrPayClient.QrMerchant?
get() = draft.mfaisaQrInfo
private set(value) { draft.mfaisaQrInfo = value }
private var lookupInFlight = false
/** Held from initiate until the OTP flow ends, so a theme change can't recreate mid-way. */
private var transferGuard: HomeActivity.PaymentGuard? = null
private fun endTransferFlow() {
transferGuard?.end()
transferGuard = null
}
// ─── Public API the fragment calls ───────────────────────────────────────
/** Whether the recipient lookup has resolved — gates the Send button. */
@@ -154,6 +167,8 @@ class MfaisaTransferHandler(
binding.btnTransfer.isEnabled = false
(fragment.activity as? HomeActivity)?.setRefreshing(true)
endTransferFlow()
transferGuard = host?.beginPayment(fragment.viewLifecycleOwner)
fragment.viewLifecycleOwner.lifecycleScope.launch {
val refId = try {
@@ -161,6 +176,7 @@ class MfaisaTransferHandler(
} catch (e: Exception) {
(fragment.activity as? HomeActivity)?.setRefreshing(false)
binding.btnTransfer.isEnabled = true
endTransferFlow()
showError(e)
return@launch
}
@@ -183,7 +199,7 @@ class MfaisaTransferHandler(
fun clearQrMerchant() {
if (qrInfo == null) return
qrInfo = null
binding.tilAmount.isEnabled = true
fragment.setAmountLocked(false)
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
binding.etAmount.setText("")
@@ -209,10 +225,8 @@ class MfaisaTransferHandler(
// Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually
if (currentSource()?.bank != "MFAISA") selectSource(source)
// Lock the "To" input row while loading
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
// The To row stays up with a spinner until there is a merchant to swap in
fragment.startLookupLoading()
host?.setRefreshing(true)
fragment.viewLifecycleOwner.lifecycleScope.launch {
@@ -227,9 +241,10 @@ class MfaisaTransferHandler(
} catch (_: Exception) { null }
}
host?.setRefreshing(false)
if (fragment.view == null) return@launch
fragment.stopLookupLoading()
if (merchant == null) {
Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
fragment.resetToFieldVisibility()
return@launch
}
qrInfo = merchant
@@ -248,26 +263,37 @@ class MfaisaTransferHandler(
))
}
// Show merchant in the "To" card — clear button is the only way to back out
binding.tvToAccountName.text = merchant.merchantName
binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}"
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
binding.cardToInfo.visibility = View.VISIBLE
// Pre-fill + lock amount if the QR is dynamic
val dynamicAmount = merchant.txnAmount?.toDoubleOrNull()
if (dynamicAmount != null && dynamicAmount > 0.0) {
binding.etAmount.setText("%.2f".format(dynamicAmount))
binding.tilAmount.isEnabled = false
}
onRecipientChanged()
showQrMerchant(merchant)
}
}
/**
* Paints a looked-up merchant into the "To" card and locks a dynamic amount. Also how a
* recreated view restores it — no network involved.
*/
fun showQrMerchant(merchant: MfaisaQrPayClient.QrMerchant) {
// Clear button is the only way to back out
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
binding.tvToAccountName.text = merchant.merchantName
binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}"
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
binding.cardToInfo.visibility = View.VISIBLE
// Pre-fill + lock amount if the QR is dynamic
val dynamicAmount = merchant.txnAmount?.toDoubleOrNull()
if (dynamicAmount != null && dynamicAmount > 0.0) {
binding.etAmount.setText("%.2f".format(dynamicAmount))
fragment.setAmountLocked(true)
}
onRecipientChanged()
}
/**
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog —
* the /initiateNewBuy + /confirmNewBuy pair does NOT require OTP for wallet QR pay
@@ -397,7 +423,8 @@ class MfaisaTransferHandler(
currentSource()?.takeIf { it.bank == "MFAISA" }
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" }
private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) {
/** Paints [r] into the "To" card; a recreated view restores it with [saveRecent] off. */
fun showResolvedRecipient(r: MfaisaTransferClient.Recipient, saveRecent: Boolean = true) {
// Reuse the same recipient card the fragment uses for other banks. The fragment owns the
// card view, so we just populate its text fields and toggle visibility.
binding.tvToAccountName.text = r.name.ifBlank { r.msisdn }
@@ -413,7 +440,7 @@ class MfaisaTransferHandler(
binding.btnScanQr.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE
RecentsCache.save(ctx, RecentPick(
if (saveRecent) RecentsCache.save(ctx, RecentPick(
accountNumber = r.msisdn,
displayName = r.name.ifBlank { r.msisdn },
subtitle = "Ooredoo M-Faisa · ${r.msisdn}",
@@ -470,7 +497,7 @@ class MfaisaTransferHandler(
refId: String,
errorMsg: String?
) {
val view = fragment.view ?: return
val view = fragment.view ?: run { endTransferFlow(); return }
val dp = ctx.resources.displayMetrics.density
val colorMuted = MaterialColors.getColor(
view, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
@@ -559,6 +586,7 @@ class MfaisaTransferHandler(
.setNegativeButton(R.string.cancel) { d, _ ->
d.dismiss()
binding.btnTransfer.isEnabled = true
endTransferFlow()
}
.setCancelable(false)
.show()
@@ -583,6 +611,7 @@ class MfaisaTransferHandler(
try {
withContext(Dispatchers.IO) { confirmWithRetry(source, refId, otp) }
(fragment.activity as? HomeActivity)?.setRefreshing(false)
endTransferFlow()
val receipt = TransferReceiptData(
bank = "MFAISA",
amount = amountValue,
@@ -607,6 +636,7 @@ class MfaisaTransferHandler(
} catch (e: Exception) {
(fragment.activity as? HomeActivity)?.setRefreshing(false)
binding.btnTransfer.isEnabled = true
endTransferFlow()
showError(e)
}
}
@@ -0,0 +1,52 @@
package sh.sar.basedbank.ui.home.transfer
import android.graphics.Bitmap
import sh.sar.basedbank.api.bml.BmlQrPayInfo
import sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient
import sh.sar.basedbank.api.models.BankAccount
/**
* Everything the Transfer screen has filled in or resolved so far: source, recipient, form
* text and any loaded merchant QR.
*
* Kept on [sh.sar.basedbank.ui.home.HomeViewModel] rather than on the fragment so it outlives
* both the view (switching tabs) and the fragment instance (a theme or language change
* recreates the activity) — the screen is repainted from here instead of re-running lookups.
* A new Transfer screen opened with its own arguments (a scanned QR, a contact) starts a fresh
* draft.
*/
class TransferDraft {
var selectedAccount: BankAccount? = null
// Resolved recipient — set after a successful lookup or prefill
var resolvedAccountNumber = ""
var resolvedRecipientName = ""
var resolvedBankName = ""
/** Last real profile/contact photo loaded into the "To" card (not an initials placeholder). */
var loadedToPhoto: Bitmap? = null
var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown
var resolvedToOwnAccount: BankAccount? = null
var toSubtitle = ""
var toColorHex = "#607D8B"
var toImageHash: String? = null
// Form text, captured when the view goes away
var amount = ""
var remarks = ""
var toText = ""
// BML merchant QR
var bmlQrInfo: BmlQrPayInfo? = null
/** True for pay.bml.com.mv and POS QRs, which need an extra pre-initiate step. */
var bmlGatewayQr = false
/** A BML QR whose lookup has not finished — no session yet, or the view went away mid-way. */
var pendingBmlQrTarget: String? = null
// M-Faisa
var mfaisaRecipient: MfaisaTransferClient.Recipient? = null
var mfaisaQrInfo: MfaisaQrPayClient.QrMerchant? = null
// Fahipay
var fahipayService: FahipayService? = null
}
@@ -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 -->
<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>
+24
View File
@@ -296,6 +296,7 @@
<!-- BML QR Pay -->
<string name="bml_qr_looking_up">Looking up merchant…</string>
<string name="bml_qr_lookup_failed">Could not load merchant details</string>
<string name="transfer_bml_txn_lookup_failed">Could not load BML payment for this transaction ID</string>
<string name="bml_qr_payment_success">Payment Successful</string>
<string name="bml_qr_select_account">Select a BML account to pay from</string>
@@ -386,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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" baseProfile="basic" id="Layer_1"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512"
xml:space="preserve">
<path fill="#1A73E8" d="M440,255.99997v0.00006C440,273.12085,426.12085,287,409.00003,287H302l-46-93.01001l49.6507-85.9951
c8.56021-14.82629,27.51834-19.9065,42.34518-11.34724l0.00586,0.0034c14.82776,8.55979,19.90875,27.51928,11.34857,42.34682
L309.70001,225h99.30002C426.12085,225,440,238.87917,440,255.99997z"/>
<path fill="#EA4335" d="M348.00174,415.34897l-0.00586,0.00339c-14.82684,8.55927-33.78497,3.47903-42.34518-11.34723L256,318.01001
l-49.65065,85.99509c-8.5602,14.82629-27.51834,19.90652-42.34517,11.34729l-0.00591-0.00342
c-14.82777-8.55978-19.90875-27.51929-11.34859-42.34683L202.29999,287L256,285l53.70001,2l49.6503,86.00214
C367.91049,387.82968,362.8295,406.78918,348.00174,415.34897z"/>
<path fill="#FBBC04" d="M256,193.98999L242,232l-39.70001-7l-49.6503-86.00212
c-8.56017-14.82755-3.47919-33.78705,11.34859-42.34684l0.00591-0.00341c14.82683-8.55925,33.78497-3.47903,42.34517,11.34726
L256,193.98999z"/>
<path fill="#34A853" d="M248,225l-36,62H102.99997C85.87916,287,72,273.12085,72,256.00003v-0.00006
C72,238.87917,85.87916,225,102.99997,225H248z"/>
<polygon fill="#185DB7" points="309.70001,287 202.29999,287 256,193.98999 "/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+6 -5
View File
@@ -17,11 +17,12 @@ You get your seed in one of two ways:
### Setup
1. [Set up BML](01-setup-bml.md)
2. [Set up MIB](02-setup-mib.md)
| [<img src="../../../logos/bml_logo.png" alt="BML" height="64">](01-setup-bml.md) | [<img src="../../../logos/mib_logo.png" alt="MIB" height="64">](02-setup-mib.md) |
|:---:|:---:|
| [1. Set up BML](01-setup-bml.md) | [2. Set up MIB](02-setup-mib.md) |
### Export
3. [Export from Google Authenticator](03-export-googleauthenticator.md)
4. [Export from Microsoft Authenticator](04-export-microsoft.md)
5. [Export from Bitwarden](05-export-bitwarden.md)
| [<img src="../../../logos/google_authenticator_logo.svg" alt="Google Authenticator" height="64">](03-export-googleauthenticator.md) | [<img src="../../../logos/microsoft_authenticator_logo.png" alt="Microsoft Authenticator" height="64">](04-export-microsoft.md) | [<img src="../../../logos/bitwarden_logo.png" alt="Bitwarden" height="64">](05-export-bitwarden.md) |
|:---:|:---:|:---:|
| [3. Export from Google Authenticator](03-export-googleauthenticator.md) | [4. Export from Microsoft Authenticator](04-export-microsoft.md) | [5. Export from Bitwarden](05-export-bitwarden.md) |
@@ -0,0 +1 @@
- Pay with BML Transaction ID (On BML Pay supported transactions"
@@ -0,0 +1,4 @@
- Fixed payments restarting or showing as failed when rotating or resizing the app, and the To box flickering when loading a merchant
- Transfer screen now keeps your form and merchant when switching tabs or changing theme/language, and waits for payments to finish before applying them
- Fixed-amount merchant QR or Gateway now show the amount clearly with a lock icon instead of greying it out
- App lock icon does not go behind navigation bar in landscape mode