Compare commits

...
Author SHA1 Message Date
shihaam 877147959a Release v1.0.33 2026-10-01 06:21:59 +05:00
shihaam 68583465de Release v1.0.33 2026-10-01 06:17:26 +05:00
shihaam 1bf63ed55b Release v1.0.33 2026-10-01 06:11:03 +05:00
shihaam 3350c84a33 press and hold to update verified card 2026-10-01 06:10:22 +05:00
shihaam 449c27ced2 update docs: card payments 2026-10-01 06:06:57 +05:00
shihaam 3c5d3ff883 add warning when paying via card 2026-10-01 06:06:41 +05:00
shihaam 25b5c80c49 inital tests for pay via card 2026-10-01 06:00:23 +05:00
shihaam 2b2fd59543 Gateway payments via card, Step 1: verify cards 2026-10-01 05:08:17 +05:00
shihaam b97d0c5a18 Release v1.0.33 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 2026-09-30 21:52:17 +05:00
shihaam 9f40c56b49 Fix UX issues with rotation/resize window and merchant info loading for card payments 2026-09-30 21:50:57 +05:00
shihaam 0747fbdbfd add logos to top seed docs: png microsoft 2026-09-29 12:24:49 +05:00
shihaam 528e9eeef8 add logos to top seed docs 2026-09-29 12:22:22 +05:00
shihaam 0e7f329a4b release v1.0.31 2026-09-27 00:31:12 +05:00
shihaam 2082e8fd0c Pay with BML Transaction ID 2026-09-27 00:28:12 +05:00
shihaam 97a0cb218f release v1.0.30 2026-09-26 21:29:16 +05:00
shihaam 0158d6dcd8 Always show fullscreen recipt toggle 2026-09-26 21:28:29 +05:00
shihaam 320eaa2ffb release v1.0.29 2026-09-26 20:43:34 +05:00
shihaam 1886113ae7 new feature: long press OTP card in OTP codes page to export or update seed 2026-09-26 20:43:14 +05:00
shihaam 41e7bc70e9 update docs 2026-09-26 19:56:26 +05:00
shihaam d786609bd1 update docs 2026-09-26 19:51:33 +05:00
shihaam 2246e5929f update docs 2026-09-26 19:50:57 +05:00
shihaam d0eee817ec update docs 2026-09-26 19:34:24 +05:00
shihaam af414914c7 update docs 2026-09-26 17:53:08 +05:00
shihaam ec5b791a45 update docs 2026-09-26 17:51:01 +05:00
shihaam dd4aed0f94 update docs 2026-09-26 17:48:14 +05:00
shihaam fd4cdfecac update docs 2026-09-26 17:47:38 +05:00
shihaam 92f5af76e6 update docs 2026-09-26 17:39:32 +05:00
shihaam bc81255b31 update docs 2026-09-26 17:35:28 +05:00
shihaam acd11ef3eb update docs 2026-09-26 17:22:11 +05:00
shihaam fc778f2a90 update docs 2026-09-26 17:20:30 +05:00
shihaam 778bcc4d75 update docs 2026-09-26 17:14:46 +05:00
shihaam 97c8033014 update docs 2026-09-26 17:12:00 +05:00
shihaam 58d43f33d6 update docs 2026-09-26 17:10:04 +05:00
shihaam af791fc5ad update docs 2026-09-26 17:00:58 +05:00
shihaam d8ef3a63c6 add totp docs 2026-09-26 16:56:35 +05:00
shihaam fbbfdd8537 release v1.0.28 2026-09-26 07:00:38 +05:00
shihaam 169c3c144c redesigned full screen for MIB recipt 2026-09-26 06:58:27 +05:00
shihaam 71ef7a5b55 MIB recipt redesign to mimick new MIB app and dark mode support 2026-09-26 06:46:19 +05:00
shihaam 32563ea398 redesigned full screen for BML recipt 2026-09-26 04:46:42 +05:00
shihaam 41ce8a65ad bml recipt dark theme support 2026-09-26 04:28:19 +05:00
shihaam c7d3efa64e update docs: update payMV QR Design 2026-09-26 04:06:06 +05:00
shihaam 2688118f52 update payMV QR Design 2026-09-26 04:05:36 +05:00
116 changed files with 4847 additions and 537 deletions
+12 -3
View File
@@ -24,11 +24,20 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "version_code=$VERSION_CODE" >> $GITHUB_OUTPUT
if git tag -l | grep -q "^v${VERSION}$"; then
echo "Tag v${VERSION} already exists, skipping"
BEFORE="${{ github.event.before }}"
if [ -z "$BEFORE" ] || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
BEFORE="HEAD~1"
fi
PREV_VERSION_CODE=$(git show "${BEFORE}:app/build.gradle.kts" 2>/dev/null | grep 'versionCode = ' | sed 's/.*versionCode = \([0-9]*\).*/\1/')
if [ "$VERSION_CODE" = "$PREV_VERSION_CODE" ]; then
echo "versionCode unchanged (${VERSION_CODE}), skipping"
echo "should_release=false" >> $GITHUB_OUTPUT
elif git tag -l | grep -q "^v${VERSION}$"; then
echo "versionCode changed (${PREV_VERSION_CODE} -> ${VERSION_CODE}) but tag v${VERSION} already exists; bump versionName"
exit 1
else
echo "New version detected: v${VERSION}"
echo "New versionCode detected: ${PREV_VERSION_CODE} -> ${VERSION_CODE} (v${VERSION})"
echo "should_release=true" >> $GITHUB_OUTPUT
fi
+5 -1
View File
@@ -12,12 +12,16 @@ A native Android client for Maldivian banking services. It is a pure client: req
- Android 8.0+ (API 26)
- Existing accounts with MIB, BML, or Fahipay
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank. See [how to get your TOTP seed](docs/thijooree/faq/totpseed/README.md)
## Download APK
[Gitea Releases](https://git.shihaam.dev/shihaam/thijooree/releases)
[Telegram Channel](https://t.me/s/thijooreeapks)
## FAQ
Common questions and setup guides are in the [FAQ](docs/thijooree/faq/README.md).
## Privacy
No data ever leaves your device except the API calls to the banking services themselves. See the [security audit](docs/thijooree/AI_SECURITY_CHECK.md) for a full list of every server the app connects to.
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 36
versionCode = 28
versionName = "1.0.27"
versionCode = 34
versionName = "1.0.33"
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" />
@@ -7,6 +7,8 @@ import java.util.concurrent.TimeUnit
internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
internal val BML_USER_AGENT = "bml-mobile-banking/348 (${Build.MANUFACTURER}; Android ${Build.VERSION.RELEASE}; ${Build.MODEL})"
/** Browser User-Agent used for BML's web/Cloudflare-fronted endpoints (login, merchant pay page, ACS). */
internal val BML_WEB_USER_AGENT = "Mozilla/5.0 (Android ${Build.VERSION.RELEASE}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
internal const val BML_APP_VERSION = "2.1.44.348"
internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
@@ -27,7 +27,7 @@ class BmlLoginFlow {
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
private val APP_USER_AGENT = "bml-mobile-banking/348 (${android.os.Build.MANUFACTURER}; Android ${android.os.Build.VERSION.RELEASE}; ${android.os.Build.MODEL})"
private val APP_VERSION = "2.1.44.348"
private val WEB_USER_AGENT = "Mozilla/5.0 (Android ${android.os.Build.VERSION.RELEASE}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
private val WEB_USER_AGENT = BML_WEB_USER_AGENT
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
private val cookieJar = object : CookieJar {
@@ -0,0 +1,301 @@
package sh.sar.basedbank.api.bml
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.FormBody
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient.Companion.API_BASE
import java.security.KeyFactory
import java.security.spec.MGF1ParameterSpec
import java.security.spec.X509EncodedKeySpec
import java.util.concurrent.TimeUnit
import javax.crypto.Cipher
import javax.crypto.spec.OAEPParameterSpec
import javax.crypto.spec.PSource
import android.util.Base64
/**
* Pays a BML Merchant Services payment link by card, for merchants that don't have BML Pay
* enabled. It performs the same request sequence the link's own card form (Pomelo JS) and the
* issuer's 3-D Secure page perform in a browser:
*
* 1. `GET public-client/credentials/<id>` (auth header: the page's `pomeloJsKey`) → RSA public
* key + Pomelo API key.
* 2. `POST api.pay.pomelopay.com/bin-lookup` with the PAN, CVV and `YYMM` expiry, each
* RSA-OAEP(SHA-1) encrypted with that key → a card `tokenId`.
* 3. `POST public-client/transactions/next-action` RATE_OPTIONS, polling while the server says
* WAIT, until it returns a `3dsUrl`.
* 4. The 3-D Secure challenge on BML's Wibmo ACS: the render page auto-posts the `creq`, we pick
* the "Authenticator" channel and submit the BML token's TOTP. The ACS then auto-posts the
* result to the Mastercard gateway, which posts it back to BML's `mpgsNotification`.
* 5. Poll next-action until TRANSACTION_CONFIRMED.
*
* Every call blocks, so run it on an IO thread. Use one instance per payment — it keeps the ACS
* session cookies.
*/
class BmlMerchantCardPayClient {
data class Card(
val pan: String,
val expiryMonth: String, // "07"
val expiryYear: String, // "28"
val cvv: String,
val holderName: String
)
sealed class Result {
object Success : Result()
data class Failure(val message: String) : Result()
}
private val cookies = mutableMapOf<String, MutableList<Cookie>>()
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(45, TimeUnit.SECONDS)
// Credentials/next-action tolerate okhttp, but the Cloudflare-fronted ACS does not — send a
// browser UA on everything (only when the caller didn't set one).
.addInterceptor { chain ->
val req = chain.request()
chain.proceed(
if (req.header("User-Agent") == null)
req.newBuilder().header("User-Agent", BML_WEB_USER_AGENT).build()
else req
)
}
.cookieJar(object : CookieJar {
override fun saveFromResponse(url: HttpUrl, newCookies: List<Cookie>) {
val list = cookies.getOrPut(url.host) { mutableListOf() }
for (c in newCookies) { list.removeAll { it.name == c.name }; list.add(c) }
}
override fun loadForRequest(url: HttpUrl): List<Cookie> =
cookies.values.flatten().filter { it.matches(url) }
})
.build()
/**
* Runs the whole payment. [otp] returns the current BML token code; it is called again with
* `retry = true` if the ACS rejects a code (it can expire between generating and submitting).
*/
fun pay(page: BmlMerchantTxnClient.PayPage, card: Card, otp: (retry: Boolean) -> String): Result {
val pk = page.pomeloKey ?: return Result.Failure("This merchant doesn't accept card payments")
val txnId = page.transactionId
runCatching { BmlMerchantTxnClient().announceBrowser(txnId) }
// 1-2. Credentials, then tokenise the card with Pomelo
val creds = getJson("$API_BASE/public-client/credentials/$txnId", pk)
val keyInfo = creds.getJSONObject("publicKey")
val publicKey = parsePublicKey(keyInfo.getString("publicKeyPem"))
val binBody = JSONObject()
.put("encryptedCardNumber", encrypt(publicKey, card.pan))
.put("encryptedCardSecurityCode", encrypt(publicKey, card.cvv))
.put("encryptedCardExpiry", encrypt(publicKey, card.expiryYear + card.expiryMonth))
.put("externalId", txnId)
.put("cardHolderName", card.holderName)
.put("encryptedCardExpiryMonth", card.expiryMonth)
.put("encryptedCardExpiryYear", card.expiryYear)
.put("encSerialId", keyInfo.getString("publicKeyId"))
val binReq = Request.Builder()
.url(creds.optString("binLookupUrl").ifBlank { "https://api.pay.pomelopay.com/bin-lookup" })
.post(binBody.toString().toRequestBody(JSON))
.header("tenant", "bankofmaldives")
.header("x-api-key", creds.getString("apiKey"))
.header("x-tenant-id", creds.optString("tid"))
.build()
val bin = execJson(binReq)
val tokenId = bin.optString("tokenId").ifBlank { return Result.Failure("Card was not accepted") }
// 3. Rate options → poll while WAIT → 3-D Secure URL
val cardFields = JSONObject()
.put("transactionId", txnId)
.put("tokenId", tokenId)
.put("bin8", bin.optString("bin8"))
.put("cardBrand", bin.optString("brand"))
for ((from, to) in listOf("issuer" to "cardIssuer", "country" to "cardCountry",
"cardCategory" to "cardCategory", "isCommercial" to "isCommercial",
"isPrepaid" to "isPrepaid", "isReloadable" to "isReloadable", "paddedPan" to "paddedPan")) {
if (bin.has(from) && !bin.isNull(from)) cardFields.put(to, bin.get(from))
}
var action = nextAction(pk, copy(cardFields).put("action", "RATE_OPTIONS").withBrowserInfo())
val resolved = setOf("WAIT", "POLL", "TRANSACTION_CONFIRMED", "TRANSACTION_FAILED")
if (action.optString("action") !in resolved && action.optString("3dsUrl").isBlank()) {
action = nextAction(pk, copy(cardFields).put("action", "THREEDS").withBrowserInfo())
}
var threeDsUrl: String? = null
for (attempt in 0..MAX_POLLS) {
when (action.optString("action")) {
"TRANSACTION_CONFIRMED" -> return Result.Success
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
}
threeDsUrl = action.optString("3dsUrl").ifBlank { null }
if (threeDsUrl != null) break
if (attempt == MAX_POLLS) return Result.Failure("Timed out waiting for the bank")
Thread.sleep(POLL_MS)
action = poll(pk, txnId)
}
// 4. 3-D Secure challenge (handles the authenticator channel + TOTP)
runThreeDs(threeDsUrl!!, otp)?.let { return it }
// 5. Wait for the gateway's verdict to reach BML
repeat(MAX_POLLS * 2) {
when (poll(pk, txnId).optString("action")) {
"TRANSACTION_CONFIRMED" -> return Result.Success
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
}
Thread.sleep(POLL_MS / 2)
}
return Result.Failure("Payment status unknown — check with the merchant before retrying")
}
/** Drives the ACS challenge. Returns null on success, or a Failure to stop the payment. */
private fun runThreeDs(threeDsUrl: String, otp: (Boolean) -> String): Result? {
// render-tds: an auto-submitting form (with an explicit action) that posts the creq to the
// issuer's ACS. The ACS's own channel/OTP forms carry no action attribute — their JS posts
// back to this same creq URL — so it is the fallback action for everything that follows.
var form = AcsForm.parse(execText(get(threeDsUrl)), null)
?: return Result.Failure("Couldn't start card authentication")
val acsUrl = form.action
var html = execText(form.toRequest())
// Channel picker (Mobile / Email / Authenticator). The BML token is the "token" channel.
if (html.contains("name=\"destValue\"")) {
form = AcsForm.parse(html, acsUrl) ?: return Result.Failure("Unexpected authentication page")
form.fields["destValue"] = "token"
form.fields["selectChannel"] = "token"
form.fields["authMethod"] = "OOB"
form.fields["otpDest"] = ""
form.fields["formReqType"] = "SUBMIT"
html = execText(form.toRequest())
}
// OTP entry. Submit the token code; if it expired, ask for a fresh one once and retry.
var retry = false
for (attempt in 0..1) {
form = AcsForm.parse(html, acsUrl) ?: break
if (!form.fields.containsKey("otpValue")) break
form.fields["otpValue"] = otp(retry)
form.fields["formReqType"] = "SUBMIT"
html = execText(form.toRequest())
if (!html.contains("incorrect", true) && !html.contains("expired", true)) break
retry = true
}
// On success the ACS returns an auto-posting form to the gateway; follow it (and the
// gateway's own auto-post back to BML) so the verdict is recorded before we poll.
repeat(3) {
val next = AcsForm.parse(html, acsUrl) ?: return null
if (next.fields.keys.none { it == "cres" || it == "order.id" }) return null
html = execText(next.toRequest())
}
return null
}
// ── next-action helpers ──────────────────────────────────────────────────
private fun nextAction(pk: String, body: JSONObject): JSONObject =
execJson(Request.Builder()
.url("$API_BASE/public-client/transactions/next-action")
.post(body.toString().toRequestBody(JSON))
.header("Authorization", pk)
.build())
private fun poll(pk: String, txnId: String): JSONObject =
nextAction(pk, JSONObject().put("action", "POLL").put("transactionId", txnId))
private fun JSONObject.withBrowserInfo(): JSONObject = this
.put("javaEnabled", false).put("javascriptEnabled", true)
.put("language", "en-US").put("colorDepth", 24)
.put("screenHeight", 1850).put("screenWidth", 1080)
.put("tz", java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis()) / -60000)
.put("userAgent", "Mozilla/5.0 (Android ${android.os.Build.VERSION.RELEASE}; Mobile)")
private fun copy(o: JSONObject) = JSONObject(o.toString())
// ── HTTP ─────────────────────────────────────────────────────────────────
private fun get(url: String) = Request.Builder().url(url).build()
private fun getJson(url: String, auth: String): JSONObject =
execJson(Request.Builder().url(url).header("Authorization", auth).header("Accept", "application/json").build())
private fun execJson(request: Request): JSONObject = client.newCall(request).execute().use { r ->
val text = r.body?.string().orEmpty()
if (!r.isSuccessful) throw Exception("Request failed (HTTP ${r.code})")
if (text.isBlank()) JSONObject() else JSONObject(text)
}
private fun execText(request: Request): String = client.newCall(request).execute().use { r ->
r.body?.string().orEmpty()
}
// ── RSA-OAEP(SHA-1), matching the Pomelo JS crypto.subtle config ──────────
private fun parsePublicKey(pem: String): java.security.PublicKey {
val der = Base64.decode(pem
.replace("-----BEGIN PUBLIC KEY-----", "")
.replace("-----END PUBLIC KEY-----", "")
.replace(Regex("\\s"), ""), Base64.DEFAULT)
return KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(der))
}
private fun encrypt(key: java.security.PublicKey, value: String): String {
val cipher = Cipher.getInstance("RSA/ECB/OAEPPadding")
cipher.init(Cipher.ENCRYPT_MODE, key, OAEPParameterSpec(
"SHA-1", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT))
return Base64.encodeToString(cipher.doFinal(value.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP)
}
/**
* One `application/x-www-form-urlencoded` form scraped from an ACS HTML page: its POST target
* plus every `<input>` name/value. [fields] is mutable so the caller can fill in the chosen
* channel and the OTP before re-submitting.
*/
private class AcsForm(val action: String, val fields: MutableMap<String, String>) {
fun toRequest(): Request {
val body = FormBody.Builder()
for ((k, v) in fields) body.add(k, v)
return Request.Builder().url(action).post(body.build()).build()
}
companion object {
private val FORM = Regex("<form\\b[^>]*>", RegexOption.IGNORE_CASE)
private val ACTION = Regex("action\\s*=\\s*[\"']([^\"']+)[\"']", RegexOption.IGNORE_CASE)
private val INPUT = Regex("<input\\b[^>]*>", RegexOption.IGNORE_CASE)
private val NAME = Regex("name\\s*=\\s*[\"']([^\"']+)[\"']", RegexOption.IGNORE_CASE)
private val VALUE = Regex("value\\s*=\\s*[\"']([^\"']*)[\"']", RegexOption.IGNORE_CASE)
/**
* The first `<form>` and its inputs. The form's `action` is used when present;
* otherwise [defaultAction] (the ACS pages set it via JS to the current creq URL).
* Null only when there is no form, or no action at all.
*/
fun parse(html: String, defaultAction: String?): AcsForm? {
val form = FORM.find(html) ?: return null
val action = ACTION.find(form.value)?.groupValues?.get(1)?.let { unescape(it) }
?: defaultAction ?: return null
val fields = linkedMapOf<String, String>()
for (m in INPUT.findAll(html)) {
val name = NAME.find(m.value)?.groupValues?.get(1) ?: continue
fields[unescape(name)] = unescape(VALUE.find(m.value)?.groupValues?.get(1) ?: "")
}
return AcsForm(action, fields)
}
private fun unescape(s: String) = s
.replace("&amp;", "&").replace("&quot;", "\"")
.replace("&#34;", "\"").replace("&#39;", "'").replace("&lt;", "<").replace("&gt;", ">")
}
}
companion object {
private val JSON = "application/json".toMediaType()
private const val POLL_MS = 5_000L
private const val MAX_POLLS = 10
}
}
@@ -0,0 +1,161 @@
package sh.sar.basedbank.api.bml
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
/**
* BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/<id>`),
* e.g. the bill links Fenaka sends. Merchants with BML Pay enabled get their QR's text fetched so it
* can go through the regular BML QR payment flow; card-only merchants are paid by
* [BmlMerchantCardPayClient] instead — [fetchPayPage] tells the two apart.
*/
class BmlMerchantTxnClient {
private val client = newBmlApiClient()
/** What the payment page knows about a transaction, from its embedded `window.appData`. */
data class PayPage(
val transactionId: String,
val merchantName: String,
val merchantAddress: String,
/** Major units (the page's amounts are in cents). */
val amount: Double,
val currency: String,
val state: String,
/** BML Pay (`bml_mpos`) is offered: pay through [fetchQrPayload] and the QR flow. */
val supportsBmlPay: Boolean,
/** Card entry (MPGS via Pomelo) is offered: pay with [BmlMerchantCardPayClient]. */
val supportsCard: Boolean,
/** `pk_production_…` key the page's card form authenticates with. */
val pomeloKey: String?
) {
val isPaid get() = state == "CONFIRMED"
}
/**
* Loads `/<id>/paynow`. The page is server-rendered with everything inline: the transaction,
* the merchant, `availableProviders` (lists `bml_mpos` when BML Pay is enabled — empty for
* card-only merchants) and the card form's `pomeloJsKey` / `pomeloJsProviders`.
*/
fun fetchPayPage(transactionId: String): PayPage {
val request = Request.Builder()
.url("$PAGE_ORIGIN/$transactionId/paynow")
// The page host is behind Cloudflare, which 403s non-browser User-Agents.
.header("User-Agent", BML_WEB_USER_AGENT)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "en-US,en;q=0.9")
.build()
val html = client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw Exception("Payment page failed (HTTP ${response.code})")
response.body?.string().orEmpty()
}
val start = html.indexOf(APP_DATA_PREFIX).takeIf { it >= 0 }
?.let { it + APP_DATA_PREFIX.length } ?: throw Exception("Payment page has no app data")
val end = html.indexOf("</script>", start).takeIf { it >= 0 } ?: throw Exception("Payment page has no app data")
val data = JSONObject(html.substring(start, end))
val txn = data.optJSONObject("transaction") ?: throw Exception("Payment page has no transaction")
val merchant = data.optJSONObject("merchant")
val providers = data.optJSONArray("availableProviders") ?: JSONArray()
val bmlPay = (0 until providers.length()).any {
val p = providers.optJSONObject(it)
p?.optString("value") == PROVIDER_BML && p.optBoolean("enabled", true)
}
val pomeloProviders = data.optJSONArray("pomeloJsProviders") ?: JSONArray()
val pomeloKey = data.optString("pomeloJsKey").ifBlank { null }
val card = pomeloKey != null && (0 until pomeloProviders.length()).any { pomeloProviders.optString(it) == "mpgs" }
val cents = if (txn.isNull("payAmount")) txn.optLong("amount") else txn.optLong("payAmount")
return PayPage(
transactionId = transactionId,
merchantName = merchant?.optString("tradingName")?.ifBlank { null }
?: merchant?.optString("registeredName").orEmpty(),
merchantAddress = listOfNotNull(
merchant?.optString("address1")?.ifBlank { null },
merchant?.optString("city")?.ifBlank { null }
).joinToString(", "),
amount = cents / 100.0,
currency = txn.optString("payCurrency").ifBlank { txn.optString("currency", "MVR") },
state = txn.optString("state"),
supportsBmlPay = bmlPay,
supportsCard = card,
pomeloKey = pomeloKey
)
}
/**
* 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")
}
/** The PATCHes the page sends on load: register this "browser" and clear any FX selection. */
fun announceBrowser(transactionId: String) {
patch(transactionId, JSONObject().put("activeBrowserId", "${transactionId}_${System.currentTimeMillis()}"))
patch(transactionId, JSONObject().put("fx", "reset"))
}
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 {
internal const val API_BASE = "https://api.merchants.bankofmaldives.com.mv"
internal const val PAGE_ORIGIN = "https://transaction.merchants.bankofmaldives.com.mv"
private const val APP_DATA_PREFIX = "window.appData = "
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()
}
}
}
@@ -50,6 +50,11 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
}
.build()
/** Swap the seed used for silent re-login after the user replaces it on the OTP screen. */
fun updateOtpSeed(otpSeed: String) {
if (storedOtpSeed != null) storedOtpSeed = otpSeed
}
// ─── Public entry point ───────────────────────────────────────────────────
/**
@@ -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()
}
@@ -1,14 +1,25 @@
package sh.sar.basedbank.ui.home
import android.app.Activity
import android.content.ClipData
import android.content.ClipDescription
import android.content.ClipboardManager
import android.content.Context
import android.content.DialogInterface
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import android.os.PersistableBundle
import android.text.Editable
import android.text.TextWatcher
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.PopupMenu
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
@@ -19,14 +30,22 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.mib.MibProfileClient
import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.databinding.DialogOtpExportSeedBinding
import sh.sar.basedbank.databinding.DialogOtpUpdateSeedBinding
import sh.sar.basedbank.databinding.FragmentOtpBinding
import sh.sar.basedbank.databinding.ItemOtpCardBinding
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.OtpauthParser
import sh.sar.basedbank.util.Totp
class OtpFragment : Fragment() {
@@ -34,7 +53,35 @@ class OtpFragment : Fragment() {
private var _binding: FragmentOtpBinding? = null
private val binding get() = _binding!!
private data class OtpEntry(val bank: String, val name: String?, val seed: String)
private data class OtpEntry(
val bank: String, val loginId: String, val account: String, val name: String?, val seed: String
)
private val entries = mutableListOf<OtpEntry>()
private var adapter: OtpAdapter? = null
/** Seed field of the open "Update seed" dialog, filled by the QR scanner result. */
private var scanTarget: android.widget.EditText? = null
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val target = scanTarget ?: return@registerForActivityResult
val found = OtpauthParser.parse(raw)
when {
found.isEmpty() -> Toast.makeText(requireContext(), "No OTP data found in QR", Toast.LENGTH_SHORT).show()
found.size == 1 -> target.setText(found[0].secret)
else -> {
val labels = found.map { e ->
if (e.issuer.isNotBlank()) "${e.issuer} (${e.name})" else e.name.ifBlank { e.secret.take(8) + "…" }
}.toTypedArray()
MaterialAlertDialogBuilder(requireContext())
.setTitle("Choose account")
.setItems(labels) { _, i -> target.setText(found[i].secret) }
.show()
}
}
}
private inner class OtpAdapter(private val entries: List<OtpEntry>) :
RecyclerView.Adapter<OtpAdapter.VH>() {
@@ -56,6 +103,8 @@ class OtpFragment : Fragment() {
)
update(b, entry.seed)
b.root.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
// Long-press opens the seed menu (export / update)
b.root.setOnLongClickListener { showSeedMenu(it, holder.bindingAdapterPosition); true }
b.btnCopyOtp.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
b.btnCopyNextOtp.setOnClickListener { copyCode(it.context, b.tvNextOtpCode.text, "Next OTP copied") }
}
@@ -91,6 +140,170 @@ class OtpFragment : Fragment() {
}
}
private fun showSeedMenu(anchor: View, position: Int) {
if (position == RecyclerView.NO_POSITION) return
val popup = PopupMenu(anchor.context, anchor)
popup.menu.add(0, 1, 0, "Export seed")
popup.menu.add(0, 2, 1, "Update seed")
popup.setOnMenuItemClickListener { item ->
val entry = entries.getOrNull(position) ?: return@setOnMenuItemClickListener false
when (item.itemId) {
1 -> showExportDialog(entry)
2 -> showUpdateDialog(position)
}
true
}
popup.show()
}
private fun entryTitle(entry: OtpEntry) = "${entry.bank} · ${entry.name ?: entry.account}"
// ── Export ───────────────────────────────────────────────────────────────
private fun showExportDialog(entry: OtpEntry) {
val ctx = requireContext()
val d = DialogOtpExportSeedBinding.inflate(layoutInflater)
val uri = OtpauthParser.buildUri(entry.bank, entry.seed)
d.tvSeed.text = entry.seed.chunked(4).joinToString(" ")
renderQr(uri, (220 * resources.displayMetrics.density).toInt())?.let { d.ivSeedQr.setImageBitmap(it) }
d.btnCopySeed.setOnClickListener { copySensitive(ctx, entry.seed, "Seed copied") }
MaterialAlertDialogBuilder(ctx)
.setTitle(entryTitle(entry))
.setView(d.root)
.setPositiveButton("Done", null)
.show()
}
private fun renderQr(content: String, size: Int): Bitmap? = try {
val hints = mapOf(
EncodeHintType.MARGIN to 0,
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
)
val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
val pixels = IntArray(size * size) { i -> if (matrix[i % size, i / size]) Color.BLACK else Color.WHITE }
Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888)
} catch (_: Exception) { null }
/** Copy a secret, flagged sensitive so Android 13+ hides it in the clipboard preview. */
private fun copySensitive(context: Context, text: String, message: String) {
val clip = ClipData.newPlainText("OTP seed", text)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
clip.description.extras = PersistableBundle().apply {
putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
}
}
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
// ── Update ───────────────────────────────────────────────────────────────
private fun showUpdateDialog(position: Int) {
val entry = entries.getOrNull(position) ?: return
val ctx = requireContext()
val d = DialogOtpUpdateSeedBinding.inflate(layoutInflater)
d.tvSeedWarning.text = "Saving will replace the current seed for this login, and the old one " +
"can't be recovered. If you might still need it, export it first."
var newSeed: String? = null
// Same behaviour as the sign-in screen's preview card
val preview = d.cardOtp
preview.root.setOnClickListener { copyCode(it.context, preview.tvOtpCode.text, "OTP copied") }
fun refreshPreview() {
val seed = newSeed
try {
if (seed == null) throw IllegalArgumentException()
preview.tvOtpCode.text = Totp.generate(seed)
preview.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
preview.otpTimer.max = 30
preview.otpTimer.progress = 30 - (System.currentTimeMillis() / 1000L % 30).toInt()
preview.root.visibility = View.VISIBLE
} catch (_: Exception) {
preview.root.visibility = View.INVISIBLE
}
}
val dialog = MaterialAlertDialogBuilder(ctx)
.setTitle("Update seed · ${entry.bank}")
.setView(d.root)
.setPositiveButton("Replace", null)
.setNegativeButton("Cancel", null)
.create()
fun validate() {
val raw = d.etNewSeed.text?.toString().orEmpty()
newSeed = OtpauthParser.resolveSecret(raw)
d.tilNewSeed.error = when {
raw.isBlank() -> null
newSeed == null -> "Not a valid TOTP seed"
newSeed == entry.seed -> "This is already the current seed"
else -> null
}
if (newSeed == entry.seed) newSeed = null
dialog.getButton(DialogInterface.BUTTON_POSITIVE)?.isEnabled = newSeed != null
refreshPreview()
}
d.etNewSeed.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) = validate()
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
})
d.btnScanNewSeed.setOnClickListener {
scanTarget = d.etNewSeed
qrLauncher.launch(Intent(ctx, QrScannerActivity::class.java))
}
val ticker = viewLifecycleOwner.lifecycleScope.launch {
while (isActive) { refreshPreview(); delay(1_000) }
}
dialog.setOnDismissListener {
ticker.cancel()
if (scanTarget === d.etNewSeed) scanTarget = null
}
dialog.setOnShowListener {
val replace = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
replace.isEnabled = false
replace.setOnClickListener {
val seed = newSeed ?: return@setOnClickListener
confirmReplace(entry) {
saveSeed(position, seed)
dialog.dismiss()
}
}
}
dialog.show()
}
private fun confirmReplace(entry: OtpEntry, onConfirm: () -> Unit) {
MaterialAlertDialogBuilder(requireContext())
.setTitle("Delete old seed?")
.setMessage("The current seed for ${entryTitle(entry)} will be replaced and can't be " +
"recovered afterwards.")
.setPositiveButton("Replace") { _, _ -> onConfirm() }
.setNegativeButton("Cancel", null)
.show()
}
private fun saveSeed(position: Int, seed: String) {
val entry = entries.getOrNull(position) ?: return
val store = CredentialStore(requireContext())
when (entry.bank) {
"MIB" -> {
store.updateMibOtpSeed(entry.loginId, seed)
// The live flow keeps the seed in memory for silent re-login
(requireActivity().application as BasedBankApp).mibFlowFor(entry.loginId).updateOtpSeed(seed)
}
"BML" -> store.updateBmlOtpSeed(entry.loginId, seed)
}
entries[position] = entry.copy(seed = seed)
adapter?.notifyItemChanged(position)
Toast.makeText(requireContext(), "Seed updated", Toast.LENGTH_SHORT).show()
}
private fun copyCode(context: Context, text: CharSequence, message: String) {
val code = text.toString().replace(" ", "")
if (code.isEmpty() || code.contains('-')) return
@@ -115,16 +328,19 @@ class OtpFragment : Fragment() {
for (loginId in store.getMibLoginIds()) {
val creds = store.loadMibCredentials(loginId) ?: continue
val name = store.loadMibFullName(loginId)
tagged.add(CredentialStore.loginKey("mib", loginId) to OtpEntry("MIB", name, creds.otpSeed))
tagged.add(CredentialStore.loginKey("mib", loginId) to
OtpEntry("MIB", loginId, creds.username, name, creds.otpSeed))
}
for (loginId in store.getBmlLoginIds()) {
val creds = store.loadBmlCredentials(loginId) ?: continue
val name = store.loadBmlUserProfile(loginId)?.fullName
tagged.add(CredentialStore.loginKey("bml", loginId) to OtpEntry("BML", name?.takeIf { it.isNotBlank() }, creds.otpSeed))
tagged.add(CredentialStore.loginKey("bml", loginId) to
OtpEntry("BML", loginId, creds.username, name?.takeIf { it.isNotBlank() }, creds.otpSeed))
}
val entries = tagged.sortedBy { rank(it.first) }.map { it.second }.toMutableList()
entries.clear()
entries.addAll(tagged.sortedBy { rank(it.first) }.map { it.second })
val adapter = OtpAdapter(entries)
val adapter = OtpAdapter(entries).also { this.adapter = it }
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
binding.emptyState.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE
@@ -147,8 +363,7 @@ class OtpFragment : Fragment() {
mobile = profile.mobile,
enrolled = profile.enrolled
))
val seed = store.loadMibCredentials(loginId)?.otpSeed
val idx = entries.indexOfFirst { it.seed == seed }
val idx = entries.indexOfFirst { it.bank == "MIB" && it.loginId == loginId }
if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true }
}
}
@@ -168,8 +383,7 @@ class OtpFragment : Fragment() {
idCard = info.idCard,
birthdate = info.birthdate
))
val seed = store.loadBmlCredentials(loginId)?.otpSeed
val idx = entries.indexOfFirst { it.seed == seed }
val idx = entries.indexOfFirst { it.bank == "BML" && it.loginId == loginId }
if (idx >= 0) { entries[idx] = entries[idx].copy(name = info.fullName); changed = true }
}
}
@@ -192,6 +406,8 @@ class OtpFragment : Fragment() {
override fun onDestroyView() {
super.onDestroyView()
adapter = null
scanTarget = null
_binding = null
}
}
@@ -7,12 +7,15 @@ import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.text.TextPaint
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.FileProvider
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
@@ -65,7 +68,18 @@ class PayMvQrFragment : Fragment() {
private data class QrTarget(val accountNumber: String, val name: String, val bank: String)
private fun currentTarget(): QrTarget? = contactTarget
?: selectedAccount?.let { QrTarget(it.accountNumber, it.accountBriefName, it.bank) }
?: selectedAccount?.let { QrTarget(it.accountNumber, qrHolderName(it), it.bank) }
/** Name printed on the card and put in the payload (tag 59). */
private fun qrHolderName(account: BankAccount): String = when {
// Fahipay's brief name is the generic "Fahipay Wallet"; the holder's name is on the profile
account.bank == "FAHIPAY" -> account.profileName.takeIf { it.isNotBlank() && it != "Fahipay" }
?: CredentialStore(requireContext())
.loadFahipayUserProfile(sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag))
?.fullName?.takeIf { it.isNotBlank() }
?: account.accountBriefName
else -> account.accountBriefName
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
contactTarget = arguments?.let { args ->
@@ -146,11 +160,13 @@ class PayMvQrFragment : Fragment() {
"FAHIPAY" -> "FAHIMVMV"
else -> "MADVMVMV"
}
val amountFormatted = binding.etAmount.text?.toString()?.trim()
?.replace(",", "")
val amountRaw = binding.etAmount.text?.toString()?.trim()?.replace(",", "")
val amountFormatted = amountRaw
?.toDoubleOrNull()
?.takeIf { it > 0 }
?.let { "%.2f".format(it) }
// BML shows the amount on the card as typed (no forced decimals)
val amountDisplay = amountRaw?.takeIf { amountFormatted != null }
val ctx = requireContext()
val account = selectedAccount
@@ -165,17 +181,28 @@ class PayMvQrFragment : Fragment() {
when {
m.startsWith("+") -> m
m.length == 7 -> "+960$m"
m.length == 10 && m.startsWith("960") -> "+$m" // Fahipay stores 960XXXXXXX
else -> m
}
}
} else null
val purpose = binding.etReference.text?.toString()?.trim()
?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default)
?.takeIf { it.isNotBlank() }
// The reference (62/05) is also printed vertically beside the QR, as each bank does
val reference = when (target.bank) {
// BML: base-32 account number followed by the amount as typed
"BML" -> ((target.accountNumber.toBigIntegerOrNull()?.toString(32)?.uppercase() ?: "") +
(amountDisplay ?: "")).take(25).ifEmpty { generateReference(9) }
"FAHIPAY" -> "P" + generateReference(9) // Fahipay's own references are P + 9 chars
else -> generateReference(9)
}
val bmp = withContext(Dispatchers.Default) {
val payload = buildQrPayload(target.accountNumber, target.name, acquirer, amountFormatted, mobile, purpose)
renderQrCard(ctx, target, payload, amountFormatted)
val payload = buildQrPayload(target.accountNumber, target.name, acquirer, amountFormatted, mobile, purpose, reference, target.bank)
if (target.bank == "FAHIPAY") renderFahipayQrCard(ctx, target, payload, reference)
else renderQrCard(ctx, target, payload, reference)
}
if (_binding == null) return
generatedBitmap = bmp
@@ -194,14 +221,19 @@ class PayMvQrFragment : Fragment() {
acquirer: String,
amountStr: String?,
mobile: String?,
purpose: String
purpose: String?,
ref: String,
bank: String
): String {
fun tlv(tag: String, value: String): String {
val len = value.length
return tag + (if (len < 10) "0$len" else "$len") + value
}
val format = tlv("00", "01")
val poi = tlv("01", "11")
// Fahipay's own QRs are dynamic (12) when they carry an amount and mask the amount
// as "***" when they don't; its scanner may reject QRs that differ
val fahipay = bank == "FAHIPAY"
val poi = tlv("01", if (fahipay && !amountStr.isNullOrBlank()) "12" else "11")
val sub00 = tlv("00", "mv.favara.mpqr")
val sub01 = tlv("01", acquirer)
val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app
@@ -211,21 +243,28 @@ class PayMvQrFragment : Fragment() {
val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10)
val mcc = tlv("52", "0000")
val currency = tlv("53", "462")
val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else ""
val amountTLV = when {
!amountStr.isNullOrBlank() -> tlv("54", amountStr)
fahipay -> tlv("54", "***")
else -> ""
}
val country = tlv("58", "MV")
val name = tlv("59", accountName.take(25))
val ref = generateReference()
val addlData = tlv("62", tlv("05", ref) + tlv("08", purpose))
val name = tlv("59", accountName.uppercase().take(25))
// Fahipay's QRs always carry a city ("LD" + 4 digits) and default the purpose to PAYMENT
val city = if (fahipay) tlv("60", "LD" + (0..9999).random().toString().padStart(4, '0')) else ""
val purposeText = purpose?.takeIf { it.isNotBlank() } ?: if (fahipay) "PAYMENT" else null
val purposeTLV = if (purposeText != null) tlv("08", purposeText) else ""
val addlData = tlv("62", tlv("05", ref) + purposeTLV)
val timestamp = java.time.LocalDateTime.now()
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.00000"))
val tag80 = tlv("80", tlv("00", "mv.favara.mpqr") + tlv("01", timestamp))
val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + addlData + tag80 + "6304"
val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + city + addlData + tag80 + "6304"
return prefix + crc16(prefix)
}
private fun generateReference(): String {
private fun generateReference(length: Int): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return (1..9).map { chars.random() }.joinToString("")
return (1..length).map { chars.random() }.joinToString("")
}
private fun crc16(data: String): String {
@@ -242,88 +281,127 @@ class PayMvQrFragment : Fragment() {
// ── QR card rendering ────────────────────────────────────────────────────
/**
* Replicates the BML app's ReceiveCard (React Native, v2.1.47) 1:1. All measurements are in
* dp, as in BML's StyleSheet, laid out for BML's reference screen width and drawn at
* [PX_PER_DP] pixels per dp.
*/
private fun renderQrCard(
ctx: Context,
target: QrTarget,
qrPayload: String,
amountStr: String?
qrId: String
): Bitmap {
val W = 900
val H = 1080
val outerCorner = 48f
val boxBlue = Color.parseColor("#2272B7")
val footerBlue = Color.parseColor("#1A5799")
val boxL = 24f; val boxT = 110f; val boxR = 876f; val boxB = 962f
val sw = SCREEN_WIDTH_DP
fun px(dp: Float) = dp * PX_PER_DP
val mmaBlue = Color.parseColor("#0E5CA4")
val bm = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888)
val cardW = sw - 48f // screen's horizontal margins, spacing[5] each side
val qrSize = sw * 0.5f
val railW = sw / 1.85f
val namePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = px(14f)
typeface = Typeface.DEFAULT
textAlign = Paint.Align.CENTER
}
val footerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
textSize = px(sw * 0.046f)
typeface = ResourcesCompat.getFont(ctx, R.font.sofia_pro_bold) ?: Typeface.DEFAULT_BOLD
letterSpacing = 1.2f / (sw * 0.046f) // RN letterSpacing is in dp, Paint's is in em
textAlign = Paint.Align.CENTER
}
// Android RN Text lines include font padding: line height = bottom - top
fun lineHeight(p: Paint) = p.fontMetrics.let { it.bottom - it.top } / PX_PER_DP
// --- Vertical layout (dp, card-local) ---
val topCardTop = 2f
val brandTop = topCardTop + 32f // brandRow marginTop spacing[6]
val logoBoxW = sw * 0.38f // bml-logo-paymv box: 0.38·sw wide
val logoBoxH = logoBoxW * 0.1116751269035533f
val payMvBoxW = sw * 0.2f // paymv-logo box: 0.2·sw wide
val payMvBoxH = payMvBoxW * 0.17333333333333334f
val brandH = maxOf(logoBoxH, payMvBoxH)
val nameText = target.name.uppercase()
val hasName = nameText.isNotBlank()
val qrCardTop = brandTop + brandH + if (hasName) 24f else 32f
val nameTop = qrCardTop + 8f + 12f // qrCard paddingTop spacing[2], name marginTop spacing[3]
val nameH = if (hasName) lineHeight(namePaint) else 0f
val qrTop = if (hasName) nameTop + nameH + 16f else qrCardTop + 37f
val qrCardBottom = qrTop + qrSize + 37f // paddingBottom spacing[6] + 5
val topCardBottom = qrCardBottom + 24f + 8f // qrCard marginBottom, topCard paddingBottom
val footerLineH = lineHeight(footerPaint)
val cardH = topCardBottom + 12f + footerLineH + 12f + 2f
val bm = Bitmap.createBitmap(px(cardW).toInt(), px(cardH).toInt(), Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// Clip to outer rounded card shape
val outerPath = Path()
outerPath.addRoundRect(RectF(0f, 0f, W.toFloat(), H.toFloat()), outerCorner, outerCorner, Path.Direction.CW)
canvas.clipPath(outerPath)
canvas.drawColor(Color.WHITE)
// --- Bank logo top-left ---
val logoRes = when (target.bank) {
"BML" -> R.drawable.bml_logo_vector
"MIB" -> R.drawable.mib_faisanet_logo
else -> R.drawable.fahipay_logo_long
// captureWrapper: mmaBlue, radius 20 — shows as a 2dp border around the white top card
val outerPath = Path().apply {
addRoundRect(RectF(0f, 0f, px(cardW), px(cardH)), px(20f), px(20f), Path.Direction.CW)
}
canvas.clipPath(outerPath)
canvas.drawColor(mmaBlue)
// topCard: white, 2dp inset, top corners 18
paint.color = Color.WHITE
val r = px(18f)
canvas.drawPath(Path().apply {
addRoundRect(
RectF(px(2f), px(topCardTop), px(cardW - 2f), px(topCardBottom)),
floatArrayOf(r, r, r, r, 0f, 0f, 0f, 0f), Path.Direction.CW
)
}, paint)
// --- brandRow: "BANK OF MALDIVES" wordmark left, "PayMV QR" right, 40dp side margins ---
val rowL = 2f + 40f
val rowR = cardW - 2f - 40f
val rowCenterY = brandTop + brandH / 2
val logoRes = if (target.bank == "BML") R.drawable.bml_logo_paymv else R.drawable.mib_faisanet_logo
AppCompatResources.getDrawable(ctx, logoRes)?.let { d ->
val nW = d.intrinsicWidth.coerceAtLeast(1)
val nH = d.intrinsicHeight.coerceAtLeast(1)
val maxW = 180f; val maxH = 76f
val scale = minOf(maxW / nW, maxH / nH)
// resizeMode "contain" inside the logo box, left-aligned in the row
val scale = minOf(px(logoBoxW) / nW, px(logoBoxH) / nH)
val lW = (nW * scale).toInt()
val lH = (nH * scale).toInt()
val lTop = ((boxT - lH) / 2).toInt().coerceAtLeast(10)
d.setBounds(24, lTop, 24 + lW, lTop + lH)
val lLeft = (px(rowL) + (px(logoBoxW) - lW) / 2f).toInt()
val lTop = (px(rowCenterY) - lH / 2f).toInt()
d.setBounds(lLeft, lTop, lLeft + lW, lTop + lH)
d.draw(canvas)
}
// --- "PayMV QR" top-right ---
paint.color = Color.parseColor("#1A1A2E")
paint.textSize = 36f
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
// BML draws the paymv-logo image (fills its box exactly), nudged down 1dp
paint.color = mmaBlue
paint.typeface = footerPaint.typeface
paint.textAlign = Paint.Align.RIGHT
canvas.drawText("PayMV QR", W - 28f, 66f, paint)
paint.textSize = px(payMvBoxH)
paint.textSize *= px(payMvBoxW) / paint.measureText("PayMV QR")
val payMvBounds = Rect().also { paint.getTextBounds("PayMV QR", 0, 8, it) }
canvas.drawText(
"PayMV QR", px(rowR),
px(rowCenterY + 1f) - payMvBounds.exactCenterY(), paint
)
// --- Blue rounded box ---
paint.color = boxBlue
paint.textAlign = Paint.Align.LEFT
canvas.drawRoundRect(RectF(boxL, boxT, boxR, boxB), 36f, 36f, paint)
// --- qrCard: mmaBlue, radius 16, 40dp side margins ---
val qrCardL = 2f + 40f
val qrCardR = cardW - 2f - 40f
paint.color = mmaBlue
canvas.drawRoundRect(RectF(px(qrCardL), px(qrCardTop), px(qrCardR), px(qrCardBottom)), px(16f), px(16f), paint)
// Account name (white, bold, uppercase, auto-scaled to fit)
paint.color = Color.WHITE
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
paint.textAlign = Paint.Align.CENTER
val nameText = target.name.uppercase()
paint.textSize = 36f
val maxNameW = boxR - boxL - 48f
if (paint.measureText(nameText) > maxNameW) {
paint.textSize = 36f * maxNameW / paint.measureText(nameText)
}
val nameBaseline = boxT + 68f
canvas.drawText(nameText, W / 2f, nameBaseline, paint)
// Optional amount below name
val qrTopY: Float
if (!amountStr.isNullOrBlank()) {
paint.textSize = 28f
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
val amtBaseline = nameBaseline + 42f
canvas.drawText("MVR $amountStr", W / 2f, amtBaseline, paint)
qrTopY = amtBaseline + 20f
} else {
qrTopY = nameBaseline + 26f
if (hasName) {
val maxNameW = px(qrCardR - qrCardL)
if (namePaint.measureText(nameText) > maxNameW) {
namePaint.textSize *= maxNameW / namePaint.measureText(nameText)
}
canvas.drawText(nameText, px(cardW / 2), px(nameTop) - namePaint.fontMetrics.top, namePaint)
}
// QR code — white modules on the same blue as the box background
val availH = boxB - qrTopY - 24f
val qrPx = minOf(availH, boxR - boxL - 48f).toInt().coerceAtMost(700).coerceAtLeast(200)
val qrLeft = ((W - qrPx) / 2).toFloat()
// QR — white modules on mmaBlue, ECL M, no quiet zone (react-native-qrcode-svg defaults)
val qrPx = px(qrSize).toInt()
val qrLeft = px(cardW / 2) - qrPx / 2f
try {
val hints = mapOf(
EncodeHintType.MARGIN to 0,
@@ -333,23 +411,150 @@ class PayMvQrFragment : Fragment() {
val pixels = IntArray(qrPx * qrPx)
for (y in 0 until qrPx) {
for (x in 0 until qrPx) {
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else boxBlue
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else mmaBlue
}
}
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
canvas.drawBitmap(qrBm, qrLeft, qrTopY, null)
canvas.drawBitmap(qrBm, qrLeft, px(qrTop), null)
qrBm.recycle()
} catch (_: Exception) { /* skip if encoding fails */ }
// --- Dark blue footer ---
paint.color = footerBlue
paint.textAlign = Paint.Align.LEFT
canvas.drawRect(RectF(0f, 970f, W.toFloat(), H.toFloat()), paint)
// qrIdRail: the reference, rotated -90°, beside the QR's right edge
if (qrId.isNotEmpty()) {
val idPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLACK
alpha = (255 * 0.8f).toInt()
textSize = px(10f)
typeface = Typeface.DEFAULT
textAlign = Paint.Align.CENTER
}
val qrRight = cardW / 2 + qrSize / 2
val cx = px(qrRight + railW / 1.37f - railW / 2)
val cy = px(qrTop + (qrSize + railW / 1.5f) / 2)
val idText = TextUtils.ellipsize(qrId, TextPaint(idPaint), px(railW), TextUtils.TruncateAt.END).toString()
canvas.save()
canvas.rotate(-90f, cx, cy)
val fm = idPaint.fontMetrics
canvas.drawText(idText, cx, cy - (fm.bottom + fm.top) / 2, idPaint)
canvas.restore()
}
// --- footer: SofiaPro-Bold, letterSpacing 1.2 ---
canvas.drawText(
"MALDIVES NATIONAL QR", px(cardW / 2),
px(topCardBottom + 12f) - footerPaint.fontMetrics.top, footerPaint
)
return bm
}
/**
* Replicates the card Fahipay's server renders for PayMV QR (api/app/qr/), measured
* in pixels on its 1240×1322 image. Fonts follow the BML card (Sofia Pro Bold, Roboto),
* except the vertical reference, which is Montserrat as on Fahipay's.
*/
private fun renderFahipayQrCard(
ctx: Context,
target: QrTarget,
qrPayload: String,
reference: String
): Bitmap {
val w = 1240f
val h = 1322f
val blue = Color.parseColor("#005DA3")
val sofiaBold = ResourcesCompat.getFont(ctx, R.font.sofia_pro_bold) ?: Typeface.DEFAULT_BOLD
val montserrat = ResourcesCompat.getFont(ctx, R.font.montserrat_regular) ?: Typeface.DEFAULT
val bm = Bitmap.createBitmap(w.toInt(), h.toInt(), Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// Blue card with a 6px border around the white area; footer is the blue below it
canvas.clipPath(Path().apply {
addRoundRect(RectF(0f, 0f, w, h), 50f, 50f, Path.Direction.CW)
})
canvas.drawColor(blue)
paint.color = Color.WHITE
paint.textSize = 32f
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
paint.textAlign = Paint.Align.CENTER
canvas.drawText("MALDIVES NATIONAL QR", W / 2f, 1038f, paint)
canvas.drawPath(Path().apply {
addRoundRect(
RectF(6f, 6f, w - 6f, 1174f),
floatArrayOf(44f, 44f, 44f, 44f, 0f, 0f, 0f, 0f), Path.Direction.CW
)
}, paint)
// Square app icon, then the "FahiPay" wordmark, in one row
AppCompatResources.getDrawable(ctx, R.drawable.fahipay_logo)?.let { d ->
d.setBounds(94, 94, 163, 163)
d.draw(canvas)
}
AppCompatResources.getDrawable(ctx, R.drawable.fahipay_logo_long)?.let { d ->
val lH = 48
val lW = (lH * d.intrinsicWidth.toFloat() / d.intrinsicHeight.coerceAtLeast(1)).toInt()
d.setBounds(178, 104, 178 + lW, 104 + lH)
d.draw(canvas)
}
// Sized so capitals match the reference's cap heights; positions below are cap tops
fun textPaint(tf: Typeface, capH: Float, spacingEm: Float, align: Paint.Align) =
Paint(Paint.ANTI_ALIAS_FLAG).apply {
typeface = tf
letterSpacing = spacingEm
textAlign = align
textSize = 100f
val h = Rect().also { getTextBounds("H", 0, 1, it) }.height().coerceAtLeast(1)
textSize = 100f * capH / h
}
fun Paint.capHeight() = Rect().also { getTextBounds("H", 0, 1, it) }.height()
// "PayMV QR" top-right
val payMvPaint = textPaint(sofiaBold, 30f, 0f, Paint.Align.RIGHT).apply { color = blue }
canvas.drawText("PayMV QR", 1147f, 101f + 30f, payMvPaint)
// Blue QR panel
paint.color = blue
canvas.drawRoundRect(RectF(166f, 218f, 1074f, 1126f), 55f, 55f, paint)
// Account name
val nameText = target.name.uppercase()
if (nameText.isNotBlank()) {
val namePaint = textPaint(Typeface.DEFAULT, 30f, 0f, Paint.Align.CENTER).apply { color = Color.WHITE }
val maxNameW = 1074f - 166f - 80f
if (namePaint.measureText(nameText) > maxNameW) {
namePaint.textSize *= maxNameW / namePaint.measureText(nameText)
}
canvas.drawText(nameText, 619f, 314f + namePaint.capHeight(), namePaint)
}
// QR — white modules on blue, no quiet zone
val qrPx = 562
try {
val hints = mapOf(
EncodeHintType.MARGIN to 0,
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
)
val matrix = QRCodeWriter().encode(qrPayload, BarcodeFormat.QR_CODE, qrPx, qrPx, hints)
val pixels = IntArray(qrPx * qrPx)
for (y in 0 until qrPx) {
for (x in 0 until qrPx) {
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else blue
}
}
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
canvas.drawBitmap(qrBm, 338f, 417f, null)
qrBm.recycle()
} catch (_: Exception) { /* skip if encoding fails */ }
// Reference, blue, reading bottom-to-top in the white margin right of the panel,
// starting level with y=1087 and with its baseline at x=1171
val refPaint = textPaint(montserrat, 27f, 0.005f, Paint.Align.LEFT).apply { color = blue }
canvas.save()
canvas.rotate(-90f, 1171f, 1087f)
canvas.drawText(reference, 1171f, 1087f, refPaint)
canvas.restore()
// Footer
val footerPaint = textPaint(sofiaBold, 55.5f, 1.2f / 25.76f, Paint.Align.CENTER).apply { color = Color.WHITE }
canvas.drawText("MALDIVES NATIONAL QR", w / 2, 1220f + 55.5f, footerPaint)
return bm
}
@@ -437,6 +642,10 @@ class PayMvQrFragment : Fragment() {
private const val ARG_ACCOUNT_NAME = "account_name"
private const val ARG_BANK = "bank"
/** BML's layout reference: the screen width (dp) its ReceiveCard sizes were captured at. */
private const val SCREEN_WIDTH_DP = 560f
private const val PX_PER_DP = 2f
/** QR for a contact's account. [bank] is "BML" / "MIB" / "FAHIPAY", as on [BankAccount.bank]. */
fun forContact(accountNumber: String, name: String, bank: String) = PayMvQrFragment().apply {
arguments = Bundle().apply {
@@ -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,253 @@ class CardsFragment : Fragment() {
}
}
binding.btnBlock.setOnClickListener(wip)
binding.btnVerify.setOnClickListener {
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
// Already-verified cards: a tap only informs; long-press re-verifies to update.
if (VerifiedCardStore.isVerified(requireContext(), cardItemKey(item))) {
Toast.makeText(requireContext(), R.string.card_verify_already, Toast.LENGTH_SHORT).show()
} else {
onVerifyClicked(item)
}
}
binding.btnVerify.setOnLongClickListener {
cards.getOrNull(currentCardPosition)?.let { onVerifyClicked(it) }
true
}
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 +650,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 +692,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 +888,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 +1215,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 +1293,10 @@ class CardsFragment : Fragment() {
}
fun onBackPressed(): Boolean {
if (isVerifyMode) {
setVerifyMode(false)
return true
}
if (isTapMode) {
setTapMode(false)
return true
@@ -1031,6 +1310,7 @@ class CardsFragment : Fragment() {
override fun onPause() {
super.onPause()
if (isVerifyMode) stopVerifyReader()
if (isTapMode) {
BmlHostCardEmulatorService.clearToken()
BmlHostCardEmulatorService.onTransactionComplete = null
@@ -1039,7 +1319,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 +1329,7 @@ class CardsFragment : Fragment() {
}
override fun onDestroyView() {
if (isVerifyMode) stopVerifyReader()
tapAnimView?.stopAnimation()
tapAnimView = null
BmlHostCardEmulatorService.clearToken()
@@ -1055,7 +1338,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 +1353,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
@@ -167,6 +167,12 @@ class SettingsAppearanceFragment : Fragment() {
val isDark = prefs.getString("theme", "system") == "dark"
updatePitchBlackState(isDark)
// Receipts
binding.switchFullscreenReceipt.isChecked = prefs.getBoolean("always_fullscreen_receipt", false)
binding.switchFullscreenReceipt.setOnCheckedChangeListener { _, checked ->
prefs.edit().putBoolean("always_fullscreen_receipt", checked).apply()
}
// Accent color
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
binding.accentToggle.check(when (savedPreset) {
@@ -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,19 +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 = ""
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>()
@@ -109,6 +137,7 @@ class TransferFragment : Fragment() {
viewModel = viewModel,
currentSource = { selectedAccount },
selectSource = ::selectSourceAccount,
clearSource = ::clearSourceAccount,
onStateChanged = { updateTransferButton() },
onTransferSuccess = { receipt, avatar ->
ReceiptStore.save(requireContext(), receipt)
@@ -175,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
}
@@ -245,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) }
@@ -257,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) }
}
@@ -302,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) }
@@ -365,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(
@@ -378,15 +442,36 @@ 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 bmlCardMerchant = draft.bmlCardMerchant
val mfaisaQr = draft.mfaisaQrInfo
val mfaisaRecipient = draft.mfaisaRecipient
when {
bmlCardMerchant != null -> bmlHandler().showCardMerchant(bmlCardMerchant)
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)
@@ -402,22 +487,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() {
@@ -444,14 +547,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)
@@ -459,7 +555,7 @@ class TransferFragment : Fragment() {
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
val picked = accountDropdownAdapter?.getAccount(position) ?: return@setOnItemClickListener
if (bmlHandler().hasQrMerchant) {
if (bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant) {
val isCard = picked.profileType == "BML_PREPAID" || picked.profileType == "BML_CREDIT" || picked.profileType == "BML_DEBIT"
if (!isCard) {
Toast.makeText(requireContext(), "Unsupported for BML QR — select a card", Toast.LENGTH_SHORT).show()
@@ -478,7 +574,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) {
@@ -490,7 +586,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 }
@@ -504,12 +600,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) {
@@ -526,7 +619,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 = ""
@@ -539,8 +632,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 = ""
@@ -711,6 +804,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 "
}
@@ -726,6 +828,7 @@ class TransferFragment : Fragment() {
binding.btnClearToInfo.setOnClickListener {
bmlHandler().clearQrMerchant()
bmlHandler().clearCardMerchant()
mfaisaHandler().clearQrMerchant()
resolvedAccountNumber = ""
resolvedRecipientName = ""
@@ -763,7 +866,55 @@ 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 {
// Load the payment page first: it says whether the merchant takes BML Pay (QR flow)
// or only cards (Pomelo + 3-D Secure flow).
val page = withContext(Dispatchers.IO) {
runCatching { BmlMerchantTxnClient().fetchPayPage(transactionId) }.getOrNull()
}
if (_binding == null) return@launch
if (page != null && !page.supportsBmlPay && page.supportsCard) {
stopLookupLoading()
bmlHandler().payCardMerchant(page)
return@launch
}
// BML Pay (or unknown): resolve the QR and pay it like a scanned merchant QR.
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())
@@ -1110,6 +1261,12 @@ class TransferFragment : Fragment() {
return
}
// BML card-only merchant payment (no BML Pay) — verified card + 3-D Secure
if (bmlHandler().hasCardMerchant) {
bmlHandler().submitCardPayment()
return
}
val src = selectedAccount ?: run {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
@@ -1168,6 +1325,8 @@ class TransferFragment : Fragment() {
val destDisplay = binding.tvToAccountName.text?.toString() ?: resolvedAccountNumber
val bankNameCapture = resolvedBankName
val capturedToAvatar = (binding.ivToPhoto.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
// The MIB receipt only takes a real photo; it draws its own initials placeholder otherwise
val capturedToPhoto = capturedToAvatar?.takeIf { it === loadedToPhoto }
val destCurrency = resolvedDestCurrency.ifBlank {
allAccounts.firstOrNull { it.accountNumber == resolvedAccountNumber }
@@ -1205,7 +1364,7 @@ class TransferFragment : Fragment() {
val activity = requireActivity() as HomeActivity
activity.triggerRefresh()
dialog.dismiss()
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToPhoto))
} else if (!ok) {
dialog.dismiss()
if (msg == "CONNECTIVITY") {
@@ -1392,6 +1551,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 {
@@ -1532,7 +1695,7 @@ class TransferFragment : Fragment() {
private fun updateTransferButton() {
if (bmlHandler().isOtpFlowActive) return
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
val recipientReady = bmlHandler().hasQrMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank()
val recipientReady = bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant || mfaisaHandler().hasQrMerchant || resolvedAccountNumber.isNotBlank()
val hasAll = selectedAccount != null && recipientReady && amount > 0
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
val errors = viewModel.connectivityErrors.value ?: emptySet()
@@ -1544,6 +1707,7 @@ class TransferFragment : Fragment() {
internal fun clearForm() {
bmlHandler().resetOtpState()
bmlHandler().clearQrMerchant()
bmlHandler().clearCardMerchant()
mfaisaHandler?.clearState()
mfaisaHandler?.clearQrMerchant()
selectedAccount = null
@@ -1552,6 +1716,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("")
@@ -1581,6 +1746,7 @@ class TransferFragment : Fragment() {
if (_binding != null) {
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(bitmap)
loadedToPhoto = bitmap
}
}
}
@@ -1652,15 +1818,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
@@ -1738,7 +1903,7 @@ class TransferFragment : Fragment() {
b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance
b.root.alpha = when {
inactive -> 0.4f
bmlHandler().hasQrMerchant && !isCard -> 0.35f
(bmlHandler().hasQrMerchant || bmlHandler().hasCardMerchant) && !isCard -> 0.35f
else -> 1f
}
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
@@ -14,6 +14,8 @@ data class TransferReceiptData(
// MIB receipt fields
val mibReferenceNo: String = "",
val mibTransactionDate: String = "",
val mibFromProfileName: String = "",
val mibTransactionType: String = "", // "Own Transfer", "MIB Transfer", "Quick Transfer"
// BML receipt fields
val bmlFromName: String = "",
val bmlReference: String = "",
@@ -24,6 +24,7 @@ import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton
@@ -33,6 +34,8 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.databinding.DialogReceiptFullscreenBmlBinding
import sh.sar.basedbank.databinding.DialogReceiptFullscreenMibBinding
import sh.sar.basedbank.databinding.FragmentReceiptBmlBinding
import sh.sar.basedbank.databinding.FragmentReceiptMfaisaBinding
import sh.sar.basedbank.databinding.FragmentReceiptMibBinding
@@ -61,6 +64,8 @@ class TransferReceiptFragment : Fragment() {
private const val ARG_REMARKS = "remarks"
private const val ARG_MIB_REF = "mib_ref"
private const val ARG_MIB_DATE = "mib_date"
private const val ARG_MIB_FROM_PROFILE = "mib_from_profile"
private const val ARG_MIB_TXN_TYPE = "mib_txn_type"
private const val ARG_BML_FROM_NAME = "bml_from_name"
private const val ARG_BML_REFERENCE = "bml_reference"
private const val ARG_BML_TIMESTAMP = "bml_timestamp"
@@ -89,6 +94,8 @@ class TransferReceiptFragment : Fragment() {
putString(ARG_REMARKS, data.remarks)
putString(ARG_MIB_REF, data.mibReferenceNo)
putString(ARG_MIB_DATE, data.mibTransactionDate)
putString(ARG_MIB_FROM_PROFILE, data.mibFromProfileName)
putString(ARG_MIB_TXN_TYPE, data.mibTransactionType)
putString(ARG_BML_FROM_NAME, data.bmlFromName)
putString(ARG_BML_REFERENCE, data.bmlReference)
putString(ARG_BML_TIMESTAMP, data.bmlTimestamp)
@@ -161,6 +168,15 @@ class TransferReceiptFragment : Fragment() {
view.findViewById<MaterialButton>(R.id.btnSave).setOnClickListener {
saveReceipt()
}
val alwaysFullScreen = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getBoolean("always_fullscreen_receipt", false)
if (alwaysFullScreen) {
// The normal page is skipped: keep it laid out (share/save capture its card) but hidden,
// and leave the receipt entirely when the full-screen view is closed
view.alpha = 0f
view.post { if (_receiptCard != null) showFullScreenReceipt(closePageOnDismiss = true) }
}
}
// ── Data binding ──────────────────────────────────────────────────────────
@@ -168,43 +184,64 @@ class TransferReceiptFragment : Fragment() {
private fun bindMib(binding: FragmentReceiptMibBinding) {
val args = requireArguments()
val fromLabel = args.getString(ARG_FROM_LABEL, "")
val fromColor = args.getString(ARG_FROM_COLOR, "#FE860E")
val fromProfileHash = args.getString(ARG_FROM_PROFILE_HASH)
val toLabel = args.getString(ARG_TO_LABEL, "")
val currency = args.getString(ARG_CURRENCY, "MVR")
val amount = args.getString(ARG_AMOUNT, "")
// From avatar: initials first, then load profile image if hash available
binding.ivFromAvatar.setImageBitmap(makeInitialsBitmap(fromLabel, fromColor))
binding.ivFromAvatar.setImageBitmap(makeMibInitialsBitmap(fromLabel))
binding.tvFromLabel.text = fromLabel
if (fromProfileHash != null) {
loadProfileImage(fromProfileHash, isProfile = true) { binding.ivFromAvatar.setImageBitmap(it) }
loadProfileImage(fromProfileHash, isProfile = true) { binding.ivFromAvatar.setImageBitmap(circleCrop(it)) }
}
// To avatar: use already-rendered bitmap from TransferFragment if available
val toAvatar = pendingToAvatarBitmap
if (toAvatar != null) {
binding.ivToAvatar.setImageBitmap(toAvatar)
binding.ivToAvatar.setImageBitmap(circleCrop(toAvatar))
} else {
binding.ivToAvatar.setImageBitmap(makeInitialsBitmap(toLabel, "#607D8B"))
binding.ivToAvatar.setImageBitmap(makeMibInitialsBitmap(toLabel))
}
binding.tvToLabel.text = toLabel
binding.tvAmount.text = "$currency $amount"
val toBank = args.getString(ARG_TO_BANK, "")
val rawDate = args.getString(ARG_MIB_DATE, "")
binding.tvReferenceNo.text = args.getString(ARG_MIB_REF, "")
binding.tvToAccount.text = args.getString(ARG_TO_ACCOUNT, "")
binding.tvToBank.text = args.getString(ARG_TO_BANK, "")
binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "")
binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "")
binding.tvFromName.text = args.getString(ARG_MIB_FROM_PROFILE, "").ifBlank { fromLabel }
binding.tvToAccount.text = listOf(toLabel, args.getString(ARG_TO_ACCOUNT, ""))
.filter { it.isNotBlank() }.joinToString("\n")
binding.tvToBank.text = toBank
// Receipts saved before the type was recorded fall back to a guess from the bank
binding.tvTransactionType.text = args.getString(ARG_MIB_TXN_TYPE, "").ifBlank {
if (toBank == "MIB") "MIB Transfer" else "Quick Transfer"
}
binding.tvTransactionDate.text = formatMibDate(rawDate, "dd MMM yyyy HH:mm")
binding.tvValueDate.text = formatMibDate(rawDate, "dd MMM yyyy")
binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
.takeUnless { it.isNullOrBlank() || it.trim() == "-" } ?: "N/A"
copyOnLongClick(
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount,
binding.tvReferenceNo, binding.tvToAccount, binding.tvToBank,
binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount, binding.tvStatus,
binding.tvReferenceNo, binding.tvFromName, binding.tvToAccount, binding.tvToBank,
binding.tvTransactionType, binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
)
}
/** Reformats the MIB transfer date ("2026-05-16 15:10:25") to [pattern]; raw text if unparseable. */
private fun formatMibDate(raw: String, pattern: String): String {
if (raw.isBlank()) return ""
val out = DateTimeFormatter.ofPattern(pattern, Locale.US)
for (inPattern in listOf("yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "dd MMM yyyy HH:mm")) {
try {
return java.time.LocalDateTime.parse(raw.trim(), DateTimeFormatter.ofPattern(inPattern, Locale.US)).format(out)
} catch (_: Exception) { }
}
return raw
}
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
val app = requireActivity().application as BasedBankApp
val sess = app.anyMibSession() ?: return
@@ -375,8 +412,13 @@ class TransferReceiptFragment : Fragment() {
* applied to fit small viewports and doesn't pick up overlapping siblings.
*/
private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) {
val view = _receiptCard ?: run { callback(null); return }
if (view.width == 0 || view.height == 0) { callback(null); return }
val shown = _receiptCard ?: run { callback(null); return }
if (shown.width == 0 || shown.height == 0) { callback(null); return }
// BML: the preview follows the app theme, but shared/saved images are always light
val view = if (arguments?.getString(ARG_BANK, "MIB") == "BML") {
inflateLightBmlCard(shown.width)
} else shown
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
@@ -385,6 +427,27 @@ class TransferReceiptFragment : Fragment() {
callback(bitmap)
}
/** Inflates and lays out an offscreen BML receipt card with light-mode resources. */
private fun inflateLightBmlCard(widthPx: Int): View {
val config = android.content.res.Configuration(resources.configuration).apply {
uiMode = (uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK.inv()) or
android.content.res.Configuration.UI_MODE_NIGHT_NO
}
val lightCtx = android.view.ContextThemeWrapper(requireContext(), R.style.Theme_BasedBank).apply {
applyOverrideConfiguration(config)
}
val binding = FragmentReceiptBmlBinding.inflate(LayoutInflater.from(lightCtx))
bindBml(binding)
val card = binding.receiptCard
(card.parent as? ViewGroup)?.removeView(card)
card.measure(
View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
)
card.layout(0, 0, card.measuredWidth, card.measuredHeight)
return card
}
private fun formatBmlTimestamp(raw: String): String {
if (raw.isBlank()) return ""
return try {
@@ -394,26 +457,46 @@ class TransferReceiptFragment : Fragment() {
}
}
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
/** Center-crops [src] to a square and masks it to a circle. */
private fun circleCrop(src: Bitmap): Bitmap {
val size = minOf(src.width, src.height)
val out = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply {
shader = android.graphics.BitmapShader(src, android.graphics.Shader.TileMode.CLAMP, android.graphics.Shader.TileMode.CLAMP).apply {
setLocalMatrix(android.graphics.Matrix().apply {
setTranslate(-(src.width - size) / 2f, -(src.height - size) / 2f)
})
}
}
Canvas(out).drawCircle(size / 2f, size / 2f, size / 2f, paint)
return out
}
/** MIB receipt placeholder: up to two initials in #1168F3 on a #C6E1FD circle. */
private fun makeMibInitialsBitmap(name: String): Bitmap {
val sizePx = (resources.displayMetrics.density * 52).toInt()
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.color = bgColor
paint.color = Color.parseColor("#C6E1FD")
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
paint.color = Color.WHITE
paint.textSize = sizePx * 0.42f
paint.color = Color.parseColor("#1168F3")
paint.textSize = sizePx * 0.36f
paint.textAlign = Paint.Align.CENTER
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
paint.typeface = android.graphics.Typeface.DEFAULT_BOLD
val initials = name.split(Regex("\\s+"))
.mapNotNull { word -> word.firstOrNull { it.isLetterOrDigit() }?.uppercaseChar() }
.take(2).joinToString("").ifEmpty { "?" }
val metrics = paint.fontMetrics
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
canvas.drawText(initials, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
return bm
}
private fun showFullScreenReceipt() {
private fun showFullScreenReceipt(closePageOnDismiss: Boolean = false) {
val ctx = requireContext()
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
if (bank == "BML") { showBmlFullScreenReceipt(closePageOnDismiss); return }
if (bank == "MIB") { showMibFullScreenReceipt(closePageOnDismiss); return }
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
val scrollView = android.widget.ScrollView(ctx).apply {
@@ -455,6 +538,7 @@ class TransferReceiptFragment : Fragment() {
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
android.content.res.Configuration.UI_MODE_NIGHT_NO
insetsCtrl.isAppearanceLightStatusBars = isLight
if (closePageOnDismiss) closeReceiptPage()
}
dialog.show()
dialog.window?.let { win ->
@@ -466,6 +550,121 @@ class TransferReceiptFragment : Fragment() {
}
}
/**
* BML full-screen receipt: status bar stays visible, top bar with back button,
* edge-to-edge card right under it, BML-styled Save/Share buttons directly below the card.
* Follows the app theme (light/dark).
*/
private fun showBmlFullScreenReceipt(closePageOnDismiss: Boolean) {
val ctx = requireContext()
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
val page = DialogReceiptFullscreenBmlBinding.inflate(layoutInflater)
val card = FragmentReceiptBmlBinding.inflate(layoutInflater).also { bindBml(it) }.receiptCard
(card.parent as? ViewGroup)?.removeView(card)
page.cardHolder.addView(card, 0, android.widget.LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
))
page.btnBack.setOnClickListener { dialog.dismiss() }
if (closePageOnDismiss) dialog.setOnDismissListener { closeReceiptPage() }
page.btnSaveFull.setOnClickListener { saveReceipt() }
page.btnShareFull.setOnClickListener { shareReceipt() }
val topBasePadding = page.topBar.paddingTop
val bottomBasePadding = page.bottomBar.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(page.root) { _, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
page.topBar.updatePadding(top = topBasePadding + bars.top)
page.bottomBar.updatePadding(bottom = bottomBasePadding + bars.bottom)
page.root.updatePadding(left = bars.left, right = bars.right)
insets
}
dialog.setContentView(page.root)
dialog.window?.let { win ->
win.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
@Suppress("DEPRECATION")
win.statusBarColor = Color.TRANSPARENT
@Suppress("DEPRECATION")
win.navigationBarColor = Color.TRANSPARENT
val isLight = (resources.configuration.uiMode and
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
android.content.res.Configuration.UI_MODE_NIGHT_NO
androidx.core.view.WindowInsetsControllerCompat(win, win.decorView).apply {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
}
dialog.show()
}
/**
* MIB full-screen receipt: edge-to-edge card whose green header runs under the
* (visible) status bar, a floating close button top-right just below the status bar,
* and MIB-styled Share/Save buttons pinned to the bottom. Follows the app theme.
*/
private fun showMibFullScreenReceipt(closePageOnDismiss: Boolean) {
val ctx = requireContext()
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
val page = DialogReceiptFullscreenMibBinding.inflate(layoutInflater)
val cardBinding = FragmentReceiptMibBinding.inflate(layoutInflater).also { bindMib(it) }
val card = cardBinding.receiptCard
(card.parent as? ViewGroup)?.removeView(card)
page.cardHolder.addView(card, ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
))
page.btnClose.setOnClickListener { dialog.dismiss() }
if (closePageOnDismiss) dialog.setOnDismissListener { closeReceiptPage() }
page.btnShareFull.setOnClickListener { shareReceipt() }
page.btnSaveFull.setOnClickListener { saveReceipt() }
val header = cardBinding.receiptHeader
val headerBaseHeight = header.layoutParams.height
val headerBasePadding = header.paddingTop
val closeBaseMargin = (page.btnClose.layoutParams as ViewGroup.MarginLayoutParams).topMargin
val bottomBasePadding = page.bottomBar.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(page.root) { _, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
// Grow the green header under the status bar, keeping its content below it
header.layoutParams = header.layoutParams.apply { height = headerBaseHeight + bars.top }
header.updatePadding(top = headerBasePadding + bars.top)
page.btnClose.layoutParams = (page.btnClose.layoutParams as ViewGroup.MarginLayoutParams)
.apply { topMargin = closeBaseMargin + bars.top }
page.bottomBar.updatePadding(bottom = bottomBasePadding + bars.bottom)
page.root.updatePadding(left = bars.left, right = bars.right)
insets
}
dialog.setContentView(page.root)
dialog.window?.let { win ->
win.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
@Suppress("DEPRECATION")
win.statusBarColor = Color.TRANSPARENT
@Suppress("DEPRECATION")
win.navigationBarColor = Color.TRANSPARENT
val isLight = (resources.configuration.uiMode and
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
android.content.res.Configuration.UI_MODE_NIGHT_NO
androidx.core.view.WindowInsetsControllerCompat(win, win.decorView).apply {
// Status bar sits over the green header, so always use light icons
isAppearanceLightStatusBars = false
isAppearanceLightNavigationBars = isLight
}
}
dialog.show()
}
/** Pops this receipt off the back stack, returning to whatever screen opened it. */
private fun closeReceiptPage() {
if (!isAdded || parentFragmentManager.isStateSaved) return
parentFragmentManager.popBackStack()
}
private fun copyOnLongClick(vararg views: android.widget.TextView) {
for (tv in views) {
tv.setOnLongClickListener {
@@ -15,6 +15,7 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlMerchantCardPayClient
import sh.sar.basedbank.api.bml.BmlOtpChannel
import sh.sar.basedbank.api.bml.BmlQrPayClient
import sh.sar.basedbank.api.bml.BmlQrPayInfo
@@ -59,6 +60,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 +77,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 +108,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 +171,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 +190,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 +216,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 +253,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 +272,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.
@@ -360,6 +418,176 @@ class BmlTransferHandler(
}
}
// ─── Card-only merchant payment (no BML Pay) ─────────────────────────────
/** A card-only BML merchant is loaded — the fragment treats it like the QR merchant mode. */
val hasCardMerchant: Boolean get() = cardMerchant != null
private val cardMerchant get() = draft.bmlCardMerchant
/** A verified BML card we also hold a login (OTP seed) for — can go through the 3-D Secure step. */
private fun verifiedCardCandidates(): List<BankAccount> {
val store = CredentialStore(ctx)
val verifiedKeys = sh.sar.basedbank.util.VerifiedCardStore.keys(ctx)
return (viewModel.accounts.value ?: emptyList())
.filter { isCard(it) && verifiedKeys.contains("bml:${it.accountNumber}") }
.filter { store.loadBmlCredentials(it.loginTag.removePrefix("bml_"))?.otpSeed != null }
}
/**
* Loads a BML Merchant Services link whose merchant has no BML Pay into the Transfer screen as
* a card payment: paints the merchant as the recipient, locks the amount, and limits the source
* to the user's verified BML cards. Send then runs the Pomelo + 3-D Secure flow.
*/
fun payCardMerchant(page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage) {
if (page.isPaid) {
Toast.makeText(ctx, R.string.bml_card_pay_already_paid, Toast.LENGTH_LONG).show()
return
}
if (verifiedCardCandidates().isEmpty()) {
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
return
}
draft.bmlCardMerchant = page
showCardMerchant(page)
// Default to a verified card if nothing suitable is already selected.
if (currentSource()?.let { isCardVerified(it) } != true) {
clearSource()
val candidates = verifiedCardCandidates()
val default = CredentialStore(ctx).getDefaultCardAccountNumber()
(candidates.firstOrNull { it.accountNumber == default } ?: candidates.firstOrNull())
?.let { selectSource(it) }
}
}
private fun isCardVerified(account: BankAccount): Boolean =
isCard(account) && sh.sar.basedbank.util.VerifiedCardStore.isVerified(ctx, "bml:${account.accountNumber}") &&
CredentialStore(ctx).loadBmlCredentials(account.loginTag.removePrefix("bml_"))?.otpSeed != null
/** Paints the loaded card-only merchant into the "To" card and locks the amount. */
fun showCardMerchant(page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage) {
hideToRow()
binding.tvToAccountName.text = page.merchantName
binding.tvToBankBic.text = page.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(page.merchantName, "#0066A1"))
binding.cardToInfo.visibility = View.VISIBLE
binding.etAmount.setText("%.2f".format(page.amount))
fragment.setAmountLocked(true)
binding.tilRemarks.isEnabled = false
binding.tilRemarks.alpha = 0.4f
onStateChanged()
}
/** Drops the loaded card merchant and unlocks the amount/remarks fields. */
fun clearCardMerchant() {
if (cardMerchant == null) return
draft.bmlCardMerchant = null
fragment.setAmountLocked(false)
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
binding.etAmount.setText("")
}
/** Confirm-then-pay for the loaded card merchant, using the selected verified card. */
fun submitCardPayment() {
val page = cardMerchant ?: return
val src = currentSource()
if (src == null || !isCardVerified(src)) {
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
return
}
confirmCardMerchant(page, src)
}
private fun confirmCardMerchant(
page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage,
src: BankAccount
) {
val fromTypeLabel = sh.sar.basedbank.util.AccountListParser.from(src)?.typeLabel
?: sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(src.accountTypeName)
val fromDetail = listOfNotNull("BML", fromTypeLabel.ifBlank { null }).joinToString(" · ")
val warnings = listOf(
"⚠ ${page.merchantName} does not support BML Pay. This transaction will be paid via card. " +
"Card payments can be less reliable, and this can take up to a minute to complete. " +
"Please keep the app open and don't retry if it seems slow."
)
val confirmView = fragment.buildTransferConfirmView(
amountCurrency = page.currency,
amountValue = "%.2f".format(page.amount),
fromName = src.accountBriefName,
fromNumber = src.accountNumber,
fromDetail = fromDetail,
toName = page.merchantName,
toNumber = "",
toDetail = page.merchantAddress.ifBlank { "BML Merchant" },
warningTexts = warnings
)
fragment.showConfirmWithBiometric(
title = ctx.getString(R.string.transfer),
customView = confirmView,
biometricSubtitle = "${page.currency} ${"%.2f".format(page.amount)} → ${page.merchantName}",
onConfirmed = { dialog, frame ->
fragment.showProcessingInDialog(dialog, frame)
executeCardMerchant(page, src, dialog, frame)
}
)
}
private fun executeCardMerchant(
page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage,
src: BankAccount,
dialog: AlertDialog,
frame: android.widget.FrameLayout
) {
val stored = sh.sar.basedbank.util.VerifiedCardStore.load(ctx, "bml:${src.accountNumber}")
val loginId = src.loginTag.removePrefix("bml_")
val otpSeed = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
val expiry = stored?.expiry?.split("/") // "MM/YY"
if (stored == null || otpSeed == null || expiry?.size != 2) {
dialog.dismiss()
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
return
}
val card = sh.sar.basedbank.api.bml.BmlMerchantCardPayClient.Card(
pan = stored.pan,
expiryMonth = expiry[0].padStart(2, '0'),
expiryYear = expiry[1].takeLast(2),
cvv = stored.cvv,
holderName = src.accountBriefName
)
fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching {
BmlMerchantCardPayClient().pay(page, card) { _ -> Totp.generate(otpSeed) }
}.getOrElse {
BmlMerchantCardPayClient.Result.Failure(it.message ?: "Payment failed")
}
}
if (fragment.view == null) return@launch
when (result) {
is BmlMerchantCardPayClient.Result.Success -> fragment.showSuccessInDialog(
dialog, frame,
amountCurrency = page.currency,
amountValue = "%.2f".format(page.amount),
fromName = src.accountBriefName,
toName = page.merchantName
) {
fragment.clearForm()
host?.triggerRefresh()
}
is BmlMerchantCardPayClient.Result.Failure -> {
dialog.dismiss()
Toast.makeText(ctx, result.message, Toast.LENGTH_LONG).show()
}
}
}
}
// ─── Personal-profile transfer (token OTP, no user interaction) ──────────
/**
@@ -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)
}
}
@@ -129,6 +129,15 @@ class MibTransferHandler(
else -> bankName.ifBlank { "LOCAL" }
}
}
val isOwnAccount = isDestMib && app.mibAccounts.any {
it.accountNumber == destAccount && it.loginTag == src.loginTag &&
(src.profileId.isBlank() || it.profileId == src.profileId)
}
val transactionType = when {
isOwnAccount -> "Own Transfer"
isDestMib -> "MIB Transfer"
else -> "Quick Transfer"
}
return try {
// Switch to the profile that owns the source account
if (src.profileId.isNotBlank()) {
@@ -160,7 +169,9 @@ class MibTransferHandler(
toBank = toBank,
remarks = remarks,
mibReferenceNo = result.trxId,
mibTransactionDate = result.date
mibTransactionDate = result.date,
mibFromProfileName = src.profileName,
mibTransactionType = transactionType
)
Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt)
} else {
@@ -0,0 +1,55 @@
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 card-only merchant payment (merchant without BML Pay, paid by verified card + 3-D Secure)
var bmlCardMerchant: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage? = null
// 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
}
@@ -129,8 +129,8 @@ class CredentialsFragment : Fragment() {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
binding.cardOtp.setOnClickListener {
val code = binding.tvOtpCode.text.toString().replace(" ", "")
binding.cardOtp.root.setOnClickListener {
val code = binding.cardOtp.tvOtpCode.text.toString().replace(" ", "")
if (code.isNotEmpty()) {
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
@@ -198,12 +198,12 @@ class CredentialsFragment : Fragment() {
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
val seed = resolveOtpSeed(otpSeedRaw)
if (seed.isEmpty()) {
binding.cardOtp.visibility = View.INVISIBLE
binding.cardOtp.root.visibility = View.INVISIBLE
return
}
val password = binding.etPassword.text.toString()
if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) {
binding.cardOtp.visibility = View.INVISIBLE
binding.cardOtp.root.visibility = View.INVISIBLE
return
}
try {
@@ -211,13 +211,13 @@ class CredentialsFragment : Fragment() {
val secondsInPeriod = (System.currentTimeMillis() / 1000L % 30).toInt()
val remaining = 30 - secondsInPeriod
binding.tvOtpCode.text = otp
binding.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
binding.otpTimer.max = 30
binding.otpTimer.progress = remaining
binding.cardOtp.visibility = View.VISIBLE
binding.cardOtp.tvOtpCode.text = otp
binding.cardOtp.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
binding.cardOtp.otpTimer.max = 30
binding.cardOtp.otpTimer.progress = remaining
binding.cardOtp.root.visibility = View.VISIBLE
} catch (e: Exception) {
binding.cardOtp.visibility = View.INVISIBLE
binding.cardOtp.root.visibility = View.INVISIBLE
}
}
@@ -100,6 +100,11 @@ class CredentialStore(context: Context) {
.apply()
}
/** Replaces only the stored TOTP seed; the old seed is overwritten and cannot be recovered. */
fun updateMibOtpSeed(loginId: String, otpSeed: String) {
prefs.edit().putString("mib_${loginId}_enc_otp_seed", encrypt(otpSeed, getOrCreateKey())).apply()
}
fun loadMibCredentials(loginId: String): MibCredentials? {
val key = getOrCreateKey()
val encHash = prefs.getString("mib_${loginId}_enc_password_hash", null) ?: return null
@@ -230,6 +235,11 @@ class CredentialStore(context: Context) {
.apply()
}
/** Replaces only the stored TOTP seed; the old seed is overwritten and cannot be recovered. */
fun updateBmlOtpSeed(loginId: String, otpSeed: String) {
prefs.edit().putString("bml_${loginId}_enc_otp_seed", encrypt(otpSeed, getOrCreateKey())).apply()
}
fun loadBmlCredentials(loginId: String): BmlCredentials? {
val key = getOrCreateKey()
val encUsername = prefs.getString("bml_${loginId}_enc_username", null) ?: return null
@@ -13,6 +13,27 @@ object OtpauthParser {
else -> emptyList()
}
/**
* Normalise user input into a bare Base32 secret: accepts an otpauth:// link or a raw
* secret with spaces/dashes. Returns null if the result isn't usable as a TOTP seed.
*/
fun resolveSecret(input: String): String? {
val raw = input.trim()
val secret = if (raw.startsWith("otpauth://")) Uri.parse(raw).getQueryParameter("secret") ?: return null else raw
val clean = secret.replace("\\s".toRegex(), "").replace("-", "").trimEnd('=').uppercase()
if (clean.isEmpty() || !clean.all { it in 'A'..'Z' || it in '2'..'7' }) return null
// Too short to be a real seed, e.g. a pasted 6-digit OTP code
if (clean.length < 8) return null
return clean
}
/**
* Build the shortest otpauth://totp link authenticator apps accept: just a label and the
* secret. SHA1, 6 digits and a 30s period are the spec defaults, so they're left out.
*/
fun buildUri(label: String, secret: String): String =
"otpauth://totp/" + Uri.encode(label) + "?secret=$secret"
private fun parseStandard(raw: String): OtpEntry? {
val uri = Uri.parse(raw)
val secret = uri.getQueryParameter("secret") ?: return null
@@ -40,6 +40,8 @@ object ReceiptStore {
remarks = o.optString("remarks"),
mibReferenceNo = o.optString("mibReferenceNo"),
mibTransactionDate = o.optString("mibTransactionDate"),
mibFromProfileName = o.optString("mibFromProfileName"),
mibTransactionType = o.optString("mibTransactionType"),
bmlFromName = o.optString("bmlFromName"),
bmlReference = o.optString("bmlReference"),
bmlTimestamp = o.optString("bmlTimestamp"),
@@ -76,6 +78,8 @@ object ReceiptStore {
put("remarks", d.remarks)
put("mibReferenceNo", d.mibReferenceNo)
put("mibTransactionDate", d.mibTransactionDate)
put("mibFromProfileName", d.mibFromProfileName)
put("mibTransactionType", d.mibTransactionType)
put("bmlFromName", d.bmlFromName)
put("bmlReference", d.bmlReference)
put("bmlTimestamp", d.bmlTimestamp)
@@ -0,0 +1,65 @@
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)
/** All stored card keys (e.g. "bml:<accountNumber>"). */
fun keys(context: Context): Set<String> = prefs(context).all.keys
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,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Dark-mode replacement for drawable/bottom_receipt_wave.jpg (988x48):
receipt background above the teeth, footer colour below. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="329dp"
android:height="16dp"
android:viewportWidth="988"
android:viewportHeight="48">
<path
android:fillColor="@color/bml_receipt_bg"
android:pathData="M0,0 L988,0 L988,48 L0,48 Z" />
<path
android:fillColor="@color/bml_receipt_footer"
android:pathData="M0,48 L0,27 L24.08,8.28 Q30.88,3 37.67,8.28 L54.96,21.72 Q61.75,27 68.54,21.72 L85.83,8.28 Q92.63,3 99.42,8.28 L116.71,21.72 Q123.5,27 130.29,21.72 L147.58,8.28 Q154.38,3 161.17,8.28 L178.46,21.72 Q185.25,27 192.04,21.72 L209.33,8.28 Q216.13,3 222.92,8.28 L240.21,21.72 Q247,27 253.79,21.72 L271.08,8.28 Q277.88,3 284.67,8.28 L301.96,21.72 Q308.75,27 315.54,21.72 L332.83,8.28 Q339.63,3 346.42,8.28 L363.71,21.72 Q370.5,27 377.29,21.72 L394.58,8.28 Q401.38,3 408.17,8.28 L425.46,21.72 Q432.25,27 439.04,21.72 L456.33,8.28 Q463.13,3 469.92,8.28 L487.21,21.72 Q494,27 500.79,21.72 L518.08,8.28 Q524.88,3 531.67,8.28 L548.96,21.72 Q555.75,27 562.54,21.72 L579.83,8.28 Q586.63,3 593.42,8.28 L610.71,21.72 Q617.5,27 624.29,21.72 L641.58,8.28 Q648.38,3 655.17,8.28 L672.46,21.72 Q679.25,27 686.04,21.72 L703.33,8.28 Q710.13,3 716.92,8.28 L734.21,21.72 Q741,27 747.79,21.72 L765.08,8.28 Q771.88,3 778.67,8.28 L795.96,21.72 Q802.75,27 809.54,21.72 L826.83,8.28 Q833.63,3 840.42,8.28 L857.71,21.72 Q864.5,27 871.29,21.72 L888.58,8.28 Q895.38,3 902.17,8.28 L919.46,21.72 Q926.25,27 933.04,21.72 L950.33,8.28 Q957.13,3 963.92,8.28 L988,27 L988,48 Z" />
</vector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:startColor="#2E7D32"
android:centerColor="#43A047"
android:endColor="#66BB6A"
android:angle="315"
android:type="linear" />
</shape>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material "credit_score": card with a check mark -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M20,4H4C2.89,4 2.01,4.89 2.01,6L2,18c0,1.11 0.89,2 2,2h5v-2H4v-6h18V6C22,4.89 21.11,4 20,4zM20,8H4V6h16V8zM14.93,19.17l-2.83,-2.83l-1.41,1.41L14.93,22L22,14.93l-1.41,-1.41L14.93,19.17z" />
</vector>
@@ -0,0 +1,13 @@
<?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:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M15,4 L7,12 L15,20" />
</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,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- MIB full-screen receipt close button: tinted-black disc, themed ring, white X -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:viewportWidth="32"
android:viewportHeight="32">
<path
android:fillColor="#4D000000"
android:strokeColor="@color/mib_receipt_close_ring"
android:strokeWidth="1.5"
android:pathData="M16,1.25 A14.75,14.75 0 1,1 16,30.75 A14.75,14.75 0 1,1 16,1.25 Z" />
<path
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:pathData="M11,11 L21,21 M21,11 L11,21" />
</vector>
@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Full MIB logo (mark + "MALDIVES ISLAMIC BANK" wordmark); wordmark follows the receipt footer text color -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="187.6dp"
android:height="22dp"
android:viewportWidth="779.5"
android:viewportHeight="91.4">
<path
android:fillColor="#FFFFFF"
android:strokeColor="#A8AAAC"
android:strokeWidth="3.8833"
android:strokeMiterLimit="10"
android:pathData="M64.6,89.2H26.7c-13.5,0-24.5-11-24.5-24.5v-38c0-13.5,11-24.5,24.5-24.5h37.9c13.5,0,24.5,11,24.5,24.5v37.9 C89.1,78.2,78.2,89.2,64.6,89.2z" />
<path
android:fillColor="#1E2859"
android:pathData="M49.4,28.7c1.7-1.7,3.5-3.3,5.4-4.8c1.9,1.5,3.7,3.1,5.4,4.8c9,9.2,14.4,21.7,14.4,35.3c0,0.7,0,1.5-0.1,2.2 H57.6l10.9-6.8c-0.5-6-2.4-11.8-5.6-17.2c-2.2-3.7-5-7-8.2-9.7c-3.2,2.7-5.9,6-8.2,9.7c-3.2,5.3-5,11.2-5.6,17.2l4.6,6.8H35 c0-0.7-0.1-1.5-0.1-2.2C34.9,50.3,40.4,37.9,49.4,28.7z" />
<path
android:fillColor="#006A4D"
android:pathData="M41.9,28.7c-1.7-1.7-3.5-3.3-5.4-4.8c-1.9,1.5-3.7,3.1-5.4,4.8c-9,9.2-14.4,21.7-14.4,35.3 c0,0.7,0,1.5,0.1,2.2h16.9l-10.9-6.8c0.5-6,2.4-11.8,5.6-17.2c2.2-3.7,5-7,8.2-9.7c3.2,2.7,5.9,6,8.2,9.7c3.2,5.3,5,11.2,5.6,17.2 l-4.6,6.8h10.6c0-0.7,0.1-1.5,0.1-2.2C56.3,50.3,50.8,37.9,41.9,28.7z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M146.4,49.5c-0.2-5.3-0.5-11.7-0.5-17.2h-0.2c-1.3,5-3,10.5-4.9,15.7l-6,17.8h-5.8l-5.5-17.5 c-1.6-5.2-3-10.8-4.1-15.9h-0.1c-0.2,5.4-0.4,11.9-0.7,17.5l-0.9,16.5h-7l2.7-41h9.9l5.4,16.5c1.5,4.8,2.7,9.7,3.8,14.2h0.2 c1.1-4.4,2.5-9.5,4.1-14.3l5.7-16.4h9.7l2.4,41h-7.3L146.4,49.5z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M170,54.6l-3.5,11.6h-7.7l13.1-41h9.6l13.3,41h-8L183,54.6H170z M181.7,49l-3.2-10.1 c-0.8-2.5-1.5-5.3-2.1-7.7h-0.1c-0.6,2.4-1.2,5.2-1.9,7.7L171.2,49H181.7z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M200.5,25.2h7.5V60h16.9v6.3h-24.3V25.2z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M230.5,25.8c3.3-0.5,7.5-0.9,11.9-0.9c7.7,0,13,1.6,16.7,4.7c4,3.2,6.4,8.1,6.4,15.1c0,7.3-2.5,12.8-6.4,16.3 c-4.1,3.7-10.6,5.6-18.6,5.6c-4.4,0-7.7-0.2-10.1-0.5V25.8z M237.9,60.5c1,0.2,2.6,0.2,4.1,0.2c9.7,0.1,15.5-5.3,15.5-15.7 c0.1-9.1-5.2-14.2-14.5-14.2c-2.4,0-4.1,0.2-5.1,0.4V60.5z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M279.5,25.2v41H272v-41H279.5z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M297.4,66.2l-13.3-41h8.2l5.6,18.6c1.6,5.2,2.9,10,4,15h0.1c1.1-4.9,2.6-9.9,4.2-14.8l6-18.7h8l-14.2,41 H297.4z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M347.6,48.1h-15.5v12h17.3v6.1h-24.8v-41h23.8v6.1h-16.4V42h15.5V48.1z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M355.8,58.1c2.4,1.4,6.1,2.6,9.9,2.6c4.8,0,7.5-2.3,7.5-5.6c0-3.1-2.1-4.9-7.3-6.8c-6.8-2.4-11.1-6-11.1-11.9 c0-6.7,5.6-11.8,14.5-11.8c4.4,0,7.7,1,9.9,2.1l-1.8,6c-1.5-0.8-4.3-1.9-8.2-1.9c-4.7,0-6.8,2.6-6.8,4.9c0,3.2,2.4,4.6,7.8,6.8 c7.1,2.7,10.6,6.3,10.6,12.2c0,6.6-5,12.3-15.6,12.3c-4.3,0-8.8-1.2-11.1-2.6L355.8,58.1z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M407.6,25.2v41h-7.5v-41H407.6z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M416,58.1c2.4,1.4,6.1,2.6,9.9,2.6c4.8,0,7.5-2.3,7.5-5.6c0-3.1-2.1-4.9-7.3-6.8c-6.8-2.4-11.1-6-11.1-11.9 c0-6.7,5.6-11.8,14.5-11.8c4.4,0,7.7,1,9.9,2.1l-1.8,6c-1.5-0.8-4.3-1.9-8.2-1.9c-4.7,0-6.8,2.6-6.8,4.9c0,3.2,2.4,4.6,7.8,6.8 c7.1,2.7,10.6,6.3,10.6,12.2c0,6.6-5,12.3-15.6,12.3c-4.3,0-8.8-1.2-11.1-2.6L416,58.1z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M447.8,25.2h7.5V60h16.9v6.3h-24.3V25.2z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M486.2,54.6l-3.5,11.6H475l13.1-41h9.6l13.3,41h-8l-3.7-11.6H486.2z M498,49l-3.2-10.1 c-0.8-2.5-1.5-5.3-2.1-7.7h-0.1c-0.6,2.4-1.2,5.2-1.9,7.7L487.4,49H498z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M551.1,49.5c-0.2-5.3-0.5-11.7-0.5-17.2h-0.2c-1.3,5-3,10.5-4.9,15.7l-6,17.8h-5.8l-5.5-17.5 c-1.6-5.2-3-10.8-4.1-15.9h-0.1c-0.2,5.4-0.4,11.9-0.7,17.5l-0.9,16.5h-7l2.7-41h9.9l5.4,16.5c1.5,4.8,2.7,9.7,3.8,14.2h0.2 c1.1-4.4,2.5-9.5,4.1-14.3l5.7-16.4h9.7l2.4,41h-7.3L551.1,49.5z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M574.3,25.2v41h-7.5v-41H574.3z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M612.3,65c-1.8,0.9-5.7,1.8-10.6,1.8c-13,0-20.9-8.2-20.9-20.6c0-13.5,9.4-21.7,21.9-21.7c4.9,0,8.5,1,10,1.8 l-1.6,6c-1.9-0.9-4.6-1.6-8-1.6c-8.3,0-14.4,5.2-14.4,15.1c0,9,5.3,14.8,14.3,14.8c3,0,6.2-0.6,8.2-1.5L612.3,65z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M631.3,25.8c2.4-0.5,6.7-0.9,10.9-0.9c5.5,0,8.9,0.7,11.7,2.6c2.6,1.5,4.3,4.2,4.3,7.7c0,3.8-2.4,7.2-6.8,8.9 v0.1c4.3,1.1,8.3,4.5,8.3,10.2c0,3.7-1.6,6.5-4,8.5c-2.9,2.6-7.7,3.8-15.2,3.8c-4.1,0-7.3-0.3-9.2-0.5V25.8z M638.7,42h3.8 c5.2,0,8.1-2.4,8.1-5.9c0-3.8-2.9-5.6-7.7-5.6c-2.2,0-3.5,0.1-4.3,0.3V42z M638.7,60.8c1,0.1,2.3,0.2,4,0.2c4.8,0,9.1-1.8,9.1-6.9 c0-4.7-4.1-6.7-9.3-6.7h-3.7V60.8z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M674.3,54.6l-3.5,11.6h-7.7l13.1-41h9.6l13.3,41h-8l-3.7-11.6H674.3z M686.1,49l-3.2-10.1 c-0.8-2.5-1.5-5.3-2.1-7.7h-0.1c-0.6,2.4-1.2,5.2-1.9,7.7L675.5,49H686.1z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M704.8,66.2v-41h8.5l10.6,17.6c2.7,4.6,5.1,9.3,7,13.7h0.1c-0.5-5.5-0.7-10.8-0.7-17V25.2h6.9v41h-7.7 l-10.7-18c-2.6-4.5-5.4-9.6-7.4-14.2l-0.2,0.1c0.3,5.3,0.4,10.7,0.4,17.5v14.7H704.8z" />
<path
android:fillColor="@color/mib_receipt_footer_text"
android:pathData="M745.9,25.2h7.4v18.9h0.2c1-1.6,2-3,3-4.4l10.7-14.4h9.2l-14.1,17.5l15,23.5h-8.8L757,47.5l-3.7,4.4v14.4 h-7.4V25.2z" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- MIB receipt header: solid green under the official app's translucent palm texture -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/mib_receipt_amount" />
<item>
<bitmap
android:src="@drawable/mib_receipt_texture"
android:gravity="fill" />
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.
Binary file not shown.
@@ -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>
@@ -0,0 +1,63 @@
<?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:gravity="center_horizontal"
android:orientation="vertical"
android:paddingHorizontal="24dp"
android:paddingTop="8dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:text="Scan this with your authenticator app, or copy the seed. Please keep it private, since anyone who has it can generate your OTP codes."
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<!-- Always black-on-white so scanners read it in dark mode too -->
<com.google.android.material.card.MaterialCardView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:cardBackgroundColor="@android:color/white"
app:cardCornerRadius="16dp"
app:strokeWidth="0dp">
<ImageView
android:id="@+id/ivSeedQr"
android:layout_width="220dp"
android:layout_height="220dp"
android:layout_margin="12dp"
android:contentDescription="OTP seed QR code" />
</com.google.android.material.card.MaterialCardView>
<TextView
android:id="@+id/tvSeed"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:fontFamily="monospace"
android:gravity="center"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
android:textIsSelectable="true" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnCopySeed"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:text="Copy seed"
app:icon="@drawable/ic_copy" />
</LinearLayout>
</ScrollView>
@@ -0,0 +1,98 @@
<?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="8dp">
<!-- Warning: the old seed is overwritten -->
<com.google.android.material.card.MaterialCardView
style="@style/Widget.Material3.CardView.Filled"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:cardBackgroundColor="?attr/colorErrorContainer"
app:cardCornerRadius="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp">
<ImageView
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginEnd="12dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_info"
app:tint="?attr/colorOnErrorContainer" />
<TextView
android:id="@+id/tvSeedWarning"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnErrorContainer" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilNewSeed"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="New OTP seed"
app:helperText="Base32 secret or otpauth:// link">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etNewSeed"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:imeOptions="actionDone"
android:inputType="textNoSuggestions|textVisiblePassword"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnScanNewSeed"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:contentDescription="@string/scan_otp_qr"
android:tooltipText="@string/scan_otp_qr"
app:icon="@drawable/ic_qr_scan" />
</LinearLayout>
<!-- Live preview of the new seed's code, same card as the sign-in screen -->
<include
android:id="@+id/cardOtp"
layout="@layout/view_otp_preview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp" />
</LinearLayout>
</ScrollView>
@@ -0,0 +1,122 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Full-screen BML receipt, modelled on docs/bmlapi/tmp/recipt_full_*.jpg.
The receipt card is inserted at the top of cardHolder, directly followed by
the Save/Share buttons; the rest of the screen shows the footer colour. -->
<LinearLayout
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="match_parent"
android:orientation="vertical"
android:background="@color/bml_receipt_footer">
<!-- Top bar — status bar inset is added as top padding in code -->
<FrameLayout
android:id="@+id/topBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/bml_receipt_bg">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="48dp">
<ImageButton
android:id="@+id/btnBack"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="start|center_vertical"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_chevron_back"
app:tint="@color/bml_receipt_amount"
android:contentDescription="Back" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="Transfer successful"
android:textSize="17sp"
android:textColor="@color/bml_receipt_amount"
android:fontFamily="@font/sofia_pro" />
</FrameLayout>
</FrameLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/bml_receipt_divider" />
<ScrollView
android:id="@+id/scroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:overScrollMode="never"
android:scrollbars="none">
<LinearLayout
android:id="@+id/cardHolder"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<!-- receipt card goes here (index 0) -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/bml_receipt_bottom_divider" />
<!-- Bottom actions — nav bar inset is added to bottom padding in code -->
<LinearLayout
android:id="@+id/bottomBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@color/bml_receipt_footer"
android:paddingHorizontal="20dp"
android:paddingTop="20dp"
android:paddingBottom="16dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSaveFull"
style="@style/Widget.Material3.Button"
android:layout_width="match_parent"
android:layout_height="44dp"
android:insetTop="0dp"
android:insetBottom="0dp"
android:text="Save receipt"
android:textAllCaps="false"
android:letterSpacing="0"
android:textSize="17sp"
android:textColor="#FFFFFF"
android:fontFamily="@font/sofia_pro"
app:backgroundTint="@color/bml_red"
app:cornerRadius="10dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnShareFull"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent"
android:layout_height="44dp"
android:layout_marginTop="8dp"
android:insetTop="0dp"
android:insetBottom="0dp"
android:text="Share receipt"
android:textAllCaps="false"
android:letterSpacing="0"
android:textSize="17sp"
android:textColor="@color/bml_receipt_message"
android:fontFamily="@font/sofia_pro"
app:rippleColor="@color/bml_receipt_divider" />
</LinearLayout>
</LinearLayout>
</ScrollView>
</LinearLayout>
@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Full-screen MIB receipt. The receipt card is inserted into cardHolder and its green
header is extended under the status bar in code; the close button floats over the
header just below the status bar. Share/Save sit pinned at the bottom of the screen. -->
<FrameLayout
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="match_parent"
android:background="@color/mib_receipt_bg">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<ScrollView
android:id="@+id/scroll"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:overScrollMode="never"
android:scrollbars="none">
<FrameLayout
android:id="@+id/cardHolder"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</ScrollView>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/mib_receipt_divider" />
<!-- Bottom actions — nav bar inset is added to bottom padding in code -->
<LinearLayout
android:id="@+id/bottomBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="20dp"
android:paddingTop="20dp"
android:paddingBottom="16dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnShareFull"
style="@style/Widget.Material3.Button"
android:layout_width="match_parent"
android:layout_height="44dp"
android:insetTop="0dp"
android:insetBottom="0dp"
android:text="Share Receipt"
android:textAllCaps="false"
android:letterSpacing="0"
android:textSize="17sp"
android:textColor="#FFFFFF"
app:backgroundTint="@color/mib_blue"
app:cornerRadius="10dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSaveFull"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="match_parent"
android:layout_height="44dp"
android:layout_marginTop="8dp"
android:insetTop="0dp"
android:insetBottom="0dp"
android:text="Save Receipt"
android:textAllCaps="false"
android:letterSpacing="0"
android:textSize="17sp"
android:textColor="@color/mib_blue"
app:backgroundTint="@color/mib_receipt_bg"
app:strokeColor="@color/mib_blue"
app:strokeWidth="1dp"
app:cornerRadius="10dp" />
</LinearLayout>
</LinearLayout>
<!-- Close (back) button — status bar inset is added to top margin in code -->
<ImageButton
android:id="@+id/btnClose"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="top|end"
android:layout_marginTop="4dp"
android:layout_marginEnd="8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_mib_receipt_close"
android:contentDescription="Close" />
</FrameLayout>
@@ -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>
@@ -130,89 +130,13 @@
android:maxLength="6" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.card.MaterialCardView
<include
android:id="@+id/cardOtp"
layout="@layout/view_otp_preview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:visibility="invisible"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="?attr/colorSecondaryContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Current OTP"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSecondaryContainer" />
<TextView
android:id="@+id/tvOtpCode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:textColor="?attr/colorOnSecondaryContainer"
android:letterSpacing="0.15"
android:fontFamily="monospace" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:orientation="vertical"
android:gravity="end"
android:alpha="0.7">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Next"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSecondaryContainer" />
<TextView
android:id="@+id/tvNextOtpCode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSecondaryContainer"
android:letterSpacing="0.1"
android:fontFamily="monospace" />
</LinearLayout>
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/otpTimer"
android:layout_width="32dp"
android:layout_height="32dp"
app:indicatorSize="32dp"
app:trackThickness="3dp"
app:indicatorColor="?attr/colorOnSecondaryContainer"
app:trackColor="?attr/colorSecondaryContainer"
android:indeterminate="false" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/tvError"
@@ -24,7 +24,7 @@
android:layout_height="wrap_content"
android:layout_marginHorizontal="24dp"
android:orientation="vertical"
android:background="#FFFFFF">
android:background="@color/bml_receipt_bg">
<!-- BML icon (bmlicon.jpg, centered, ~52dp ≈ 146/1080*360dp) -->
<ImageView
@@ -32,7 +32,7 @@
android:layout_height="52dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="20dp"
android:src="@drawable/bml_icon"
android:src="@drawable/bml_logo_vector"
android:scaleType="fitCenter"
android:adjustViewBounds="true"
android:contentDescription="@null" />
@@ -47,7 +47,7 @@
android:layout_marginBottom="20dp"
android:id="@+id/tvMessage"
android:textSize="14sp"
android:textColor="#2D2D2D"
android:textColor="@color/bml_receipt_message"
android:fontFamily="@font/nunito_sans"
android:gravity="center" />
@@ -75,7 +75,7 @@
android:layout_gravity="center"
android:layout_marginBottom="13dp"
android:textSize="42sp"
android:textColor="#242424"
android:textColor="@color/bml_receipt_amount"
android:fontFamily="@font/sofia_pro"
android:gravity="center" />
@@ -109,66 +109,66 @@
<!-- Status (value = green #8BC155) -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Status" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Status" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvStatus" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="SUCCESS" android:textSize="15sp" android:textColor="#8BC155" android:fontFamily="@font/sofia_pro" />
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
<!-- Message (value wraps if long) -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Message" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Message" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvMessageRow" android:layout_width="0dp" android:layout_weight="1.4" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
<!-- Reference -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reference" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reference" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvReference" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" />
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
<!-- Transaction date -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Transaction date" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Transaction date" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvTransactionDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" />
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
<!-- From (value uppercase) -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="From" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="From" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvFrom" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:textAllCaps="false" />
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
<!-- To (stacked name + account) -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="vertical" android:gravity="end">
<TextView android:id="@+id/tvToName" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
<TextView android:id="@+id/tvToAccount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="2dp" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
</LinearLayout>
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" />
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" />
<!-- Amount (value = green #8BC155) -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Amount" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Amount" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvAmountRow" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#8BC155" android:fontFamily="@font/sofia_pro" />
</LinearLayout>
<!-- Remarks (hidden when empty) -->
<View android:id="@+id/remarksDivider" android:layout_width="match_parent" android:layout_height="1dp" android:background="#E9E9E9" android:visibility="gone" />
<View android:id="@+id/remarksDivider" android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/bml_receipt_divider" android:visibility="gone" />
<LinearLayout android:id="@+id/remarksRow" android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:gravity="center_vertical" android:paddingVertical="13dp" android:visibility="gone">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Remarks" android:textSize="13sp" android:textColor="#000000" android:fontFamily="@font/sofia_pro" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Remarks" android:textSize="13sp" android:textColor="@color/bml_receipt_label" android:fontFamily="@font/sofia_pro" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvRemarks" android:layout_width="0dp" android:layout_weight="1.4" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#808080" android:fontFamily="@font/sofia_pro" android:gravity="end" />
</LinearLayout>
@@ -199,7 +199,7 @@
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:background="#F5F5F5"
android:background="@color/bml_receipt_footer"
android:paddingVertical="22dp">
<TextView
+87 -119
View File
@@ -15,7 +15,7 @@
android:overScrollMode="never"
android:scrollbars="none">
<!-- Renderable receipt card (header grows to fill remaining space) -->
<!-- Renderable receipt card -->
<LinearLayout
android:id="@+id/receiptCard"
android:layout_width="match_parent"
@@ -23,76 +23,35 @@
android:layout_marginHorizontal="40dp"
android:orientation="vertical">
<!-- Green header — fills all space not taken by body -->
<!-- Green header — from/to avatars and names -->
<FrameLayout
android:id="@+id/receiptHeader"
android:layout_width="match_parent"
android:layout_height="200dp"
android:background="@drawable/trx_success_bg">
android:layout_height="160dp"
android:background="@drawable/mib_receipt_header_bg">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:gravity="center">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="TRANSACTION RECEIPT"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:letterSpacing="0.08"
android:layout_marginBottom="22dp" />
<ImageView
android:layout_width="64dp"
android:layout_height="64dp"
android:src="@drawable/ic_receipt_check"
android:contentDescription="@null" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="SUCCESSFUL"
android:textColor="#FFFFFF"
android:textSize="16sp"
android:letterSpacing="0.05"
android:layout_marginTop="18dp" />
</LinearLayout>
</FrameLayout>
<!-- White body — fixed size content -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="#FFFFFF"
android:paddingTop="16dp">
<!-- Avatars row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="horizontal"
android:gravity="center"
android:paddingHorizontal="24dp"
android:paddingBottom="14dp">
android:paddingHorizontal="24dp">
<LinearLayout
android:layout_width="0dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<com.google.android.material.imageview.ShapeableImageView
<!-- Plain ImageView: bitmaps are circle-cropped in code (ShapeableImageView's
hardware-layer mask doesn't render on the scaled card or in captures) -->
<ImageView
android:id="@+id/ivFromAvatar"
android:layout_width="52dp"
android:layout_height="52dp"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
android:scaleType="fitCenter"
android:contentDescription="@null" />
<TextView
android:id="@+id/tvFromLabel"
@@ -100,7 +59,9 @@
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textSize="12sp"
android:textColor="#565656"
android:maxWidth="110dp"
android:textStyle="bold"
android:textColor="#FFFFFF"
android:gravity="center"
android:maxLines="2" />
@@ -109,22 +70,25 @@
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginHorizontal="12dp"
android:layout_marginHorizontal="10dp"
android:src="@drawable/ic_arrow_right"
android:tint="#FFFFFF"
android:contentDescription="@null" />
<LinearLayout
android:layout_width="0dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="center">
<com.google.android.material.imageview.ShapeableImageView
<!-- Plain ImageView: bitmaps are circle-cropped in code (ShapeableImageView's
hardware-layer mask doesn't render on the scaled card or in captures) -->
<ImageView
android:id="@+id/ivToAvatar"
android:layout_width="52dp"
android:layout_height="52dp"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
android:scaleType="fitCenter"
android:contentDescription="@null" />
<TextView
android:id="@+id/tvToLabel"
@@ -132,7 +96,9 @@
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textSize="12sp"
android:textColor="#565656"
android:maxWidth="110dp"
android:textStyle="bold"
android:textColor="#FFFFFF"
android:gravity="center"
android:maxLines="2" />
@@ -140,81 +106,92 @@
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
</FrameLayout>
<!-- Total Amount -->
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="TOTAL AMOUNT"
android:textSize="13sp"
android:textColor="#a0a2a1"
android:letterSpacing="0.08"
android:gravity="center"
android:paddingTop="10dp"
android:paddingBottom="4dp" />
<!-- Body -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="@color/mib_receipt_bg">
<TextView
android:id="@+id/tvAmount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="34sp"
android:textColor="#f14f0f"
android:textSize="24sp"
android:textStyle="bold"
android:textColor="@color/mib_receipt_amount"
android:gravity="center"
android:paddingBottom="10dp" />
android:paddingTop="14dp" />
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
<TextView
android:id="@+id/tvStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Success"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/mib_receipt_amount"
android:gravity="center"
android:paddingBottom="6dp" />
<!-- Reference # -->
<!-- Transaction # -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reference #" android:textSize="15sp" android:textColor="#a0a2a1" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvReferenceNo" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Transaction#" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
<TextView android:id="@+id/tvReferenceNo" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
<!-- To Account -->
<!-- From (sender profile name) -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To Account" android:textSize="15sp" android:textColor="#a0a2a1" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvToAccount" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="From" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
<TextView android:id="@+id/tvFromName" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
<!-- To Bank -->
<!-- To Account (recipient name + account number) -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="To Bank" android:textSize="15sp" android:textColor="#a0a2a1" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvToBank" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="To Account" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
<TextView android:id="@+id/tvToAccount" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
<!-- Bank -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Bank" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
<TextView android:id="@+id/tvToBank" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
<!-- Transaction Type -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Transaction Type" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
<TextView android:id="@+id/tvTransactionType" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
<!-- Transaction Date -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Transaction Date" android:textSize="15sp" android:textColor="#a0a2a1" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvTransactionDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Transaction Date" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
<TextView android:id="@+id/tvTransactionDate" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
<!-- Value Date -->
<!-- Processed Date -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Value Date" android:textSize="15sp" android:textColor="#a0a2a1" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvValueDate" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Processed Date" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
<TextView android:id="@+id/tvValueDate" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
</LinearLayout>
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="@color/mib_receipt_divider" android:layout_marginHorizontal="20dp" />
<!-- Purpose -->
<!-- Remarks -->
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" android:paddingHorizontal="20dp" android:paddingVertical="11dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Purpose" android:textSize="15sp" android:textColor="#a0a2a1" />
<View android:layout_width="0dp" android:layout_height="1dp" android:layout_weight="1" />
<TextView android:id="@+id/tvPurpose" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="15sp" android:textColor="#565656" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:text="Remarks" android:textSize="15sp" android:textColor="@color/mib_receipt_label" />
<TextView android:id="@+id/tvPurpose" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:gravity="end" android:textSize="15sp" android:textColor="@color/mib_receipt_value" />
</LinearLayout>
<!-- MIB footer -->
<View android:layout_width="match_parent" android:layout_height="1dp" android:background="#CAC4D0" android:layout_marginHorizontal="20dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -224,19 +201,10 @@
<ImageView
android:layout_width="wrap_content"
android:layout_height="28dp"
android:src="@drawable/mib_logo"
android:layout_height="22dp"
android:src="@drawable/mib_logo_full"
android:adjustViewBounds="true"
android:contentDescription="@null" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:text="MALDIVES ISLAMIC BANK"
android:textSize="13sp"
android:textColor="#000000"
android:letterSpacing="0.05" />
android:contentDescription="Maldives Islamic Bank" />
</LinearLayout>
@@ -109,7 +109,7 @@
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="16dp"
android:src="@drawable/bml_icon"
android:src="@drawable/bml_logo_vector"
android:scaleType="fitCenter"
android:contentDescription="BML" />
@@ -304,6 +304,34 @@
</com.google.android.material.button.MaterialButtonToggleGroup>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_receipts"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="24dp"
android:layout_marginBottom="12dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_always_fullscreen_receipt"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchFullscreenReceipt"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
</ScrollView>
@@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Live TOTP preview card, shared by the sign-in screen and the OTP screen's "Update seed" dialog -->
<com.google.android.material.card.MaterialCardView
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"
android:visibility="invisible"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="?attr/colorSecondaryContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="12dp"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Current OTP"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSecondaryContainer" />
<TextView
android:id="@+id/tvOtpCode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:textColor="?attr/colorOnSecondaryContainer"
android:letterSpacing="0.15"
android:fontFamily="monospace" />
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="16dp"
android:orientation="vertical"
android:gravity="end"
android:alpha="0.7">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Next"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSecondaryContainer" />
<TextView
android:id="@+id/tvNextOtpCode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSecondaryContainer"
android:letterSpacing="0.1"
android:fontFamily="monospace" />
</LinearLayout>
<com.google.android.material.progressindicator.CircularProgressIndicator
android:id="@+id/otpTimer"
android:layout_width="32dp"
android:layout_height="32dp"
app:indicatorSize="32dp"
app:trackThickness="3dp"
app:indicatorColor="?attr/colorOnSecondaryContainer"
app:trackColor="?attr/colorSecondaryContainer"
android:indeterminate="false" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- BML receipt — dark mode -->
<color name="bml_receipt_bg">#191A1C</color>
<color name="bml_receipt_message">#DEDEE0</color>
<color name="bml_receipt_amount">#DEDEE0</color>
<color name="bml_receipt_label">#DEDEE0</color>
<color name="bml_receipt_footer">#000000</color>
<color name="bml_receipt_divider">#3D3E40</color>
<color name="bml_receipt_bottom_divider">#1E1F21</color>
<!-- MIB receipt — dark mode -->
<color name="mib_receipt_bg">#1A1A3C</color>
<color name="mib_receipt_label">#FFFFFF</color>
<color name="mib_receipt_value">#FFFFFF</color>
<color name="mib_receipt_divider">#33335C</color>
<color name="mib_receipt_footer_text">#FFFFFF</color>
<color name="mib_receipt_close_ring">#000000</color>
</resources>
+20
View File
@@ -5,4 +5,24 @@
<color name="seed_secondary">#9AD141</color>
<color name="color_unpaid">#E85D04</color>
<color name="ic_logo_background">#E8B547</color>
<!-- BML receipt (dark variants in values-night/colors.xml) -->
<color name="bml_receipt_bg">#FFFFFF</color>
<color name="bml_receipt_message">#2D2D2D</color>
<color name="bml_receipt_amount">#242424</color>
<color name="bml_receipt_label">#000000</color>
<color name="bml_receipt_footer">#F5F5F5</color>
<color name="bml_receipt_divider">#E9E9E9</color>
<color name="bml_receipt_bottom_divider">#EBEBEB</color>
<color name="bml_red">#E21B23</color>
<!-- MIB receipt (dark variants in values-night/colors.xml) -->
<color name="mib_receipt_bg">#FFFFFF</color>
<color name="mib_receipt_label">#000000</color>
<color name="mib_receipt_value">#000000</color>
<color name="mib_receipt_divider">#CAC4D0</color>
<color name="mib_receipt_footer_text">#000000</color>
<color name="mib_receipt_amount">#1EA833</color>
<color name="mib_receipt_close_ring">#FFFFFF</color>
<color name="mib_blue">#006FFC</color>
</resources>
+29 -1
View File
@@ -127,7 +127,6 @@
<string name="paymvqr_save_failed">Failed to save image</string>
<string name="paymvqr_include_phone">Include phone number</string>
<string name="paymvqr_reference_hint">Reference (optional)</string>
<string name="paymvqr_reference_default">PayMV QR Transfer</string>
<!-- Toolbar -->
<string name="action_lock">Lock app</string>
@@ -158,6 +157,8 @@
<string name="theme_dark">Dark</string>
<string name="settings_pitch_black">Pitch Black</string>
<string name="settings_accent_color">Accent Color</string>
<string name="settings_receipts">Receipts</string>
<string name="settings_always_fullscreen_receipt">Always show full screen receipt</string>
<string name="accent_blue">Blue</string>
<string name="accent_orange">Red</string>
<string name="accent_green">Green</string>
@@ -295,6 +296,9 @@
<!-- 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_card_pay_no_verified">No verified card available. Verify a BML card first in Manage Card.</string>
<string name="bml_card_pay_already_paid">This payment has already been completed.</string>
<string name="bml_qr_payment_success">Payment Successful</string>
<string name="bml_qr_select_account">Select a BML account to pay from</string>
@@ -385,6 +389,30 @@
<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_already">Card already verified. Press and hold to update.</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>
+260
View File
@@ -0,0 +1,260 @@
# Merchant Card Payment (no BML Pay)
BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/<id>`,
e.g. the bill links Fenaka and Fahipay send) are paid one of two ways depending on what the
merchant has enabled:
| Merchant capability | How it is paid | Doc |
|---|---|---|
| **BML Pay** (`bml_mpos`) enabled | Fetch the merchant's QR text, pay it via the normal QR flow | [QR Payment](13-qr-payment.md) |
| **Card only** (no BML Pay) | Enter card details → Pomelo tokenise → MPGS + 3-D Secure | **this doc** |
The payment page is a React app (Pomelo Pay, white-labelled as "Bank of Maldives Merchant
Services"). The card flow here replays the exact requests that page and the issuer's 3-D Secure
challenge make in a browser. Reconstructed from `docs/bmlapi/tmp/bmlpaywithid-verifiedcard.har`.
> ⚠️ This flow is **scraped browser/ACS traffic**, not a stable API. See
> [Fragility](#fragility--what-can-break) before relying on it.
---
## Hosts
| Purpose | Base URL | Notes |
|---|---|---|
| Payment page (`/paynow`) | `https://transaction.merchants.bankofmaldives.com.mv` | Behind Cloudflare — **browser User-Agent required** |
| Merchant API | `https://api.merchants.bankofmaldives.com.mv` | Tolerates non-browser UA |
| Card tokenisation (Pomelo CDE) | `https://api.pay.pomelopay.com` | `bin-lookup` |
| 3-D Secure ACS (Wibmo) | `https://secure-acs2ui-bk2-<dc>.wibmo.com` | Behind Cloudflare; `<dc>` varies (e.g. `indmum-mumrdc`, `indblr-blrtdc`) |
| Card scheme gateway | `https://ap.gateway.mastercard.com` | MPGS |
---
## Detecting the merchant type
`GET /<id>/paynow` returns server-rendered HTML with everything inline in a
`window.appData = {…}` script. Parse that JSON (the code reads between `window.appData = ` and the
next `</script>`):
| `window.appData` field | Meaning |
|---|---|
| `transaction.state` | `QR_CODE_GENERATED` normally; `CONFIRMED` if already paid |
| `transaction.payAmount` / `transaction.amount` | Amount in **cents** (payAmount preferred; falls back to amount) |
| `transaction.payCurrency` / `transaction.currency` | e.g. `MVR` |
| `merchant.tradingName` / `registeredName` | Display name |
| `availableProviders[]` | Contains `{value:"bml_mpos", enabled:true}` **iff BML Pay is enabled** |
| `pomeloJsProviders[]` | Contains `"mpgs"` when card entry is offered |
| `pomeloJsKey` | `pk_production_…` — the card form's auth token (a JWT carrying the merchant id) |
**Decision:** `supportsBmlPay = availableProviders` contains an enabled `bml_mpos`;
`supportsCard = pomeloJsKey present && pomeloJsProviders` contains `mpgs`.
Route to the card flow only when **`!supportsBmlPay && supportsCard`**.
> The `/paynow` host is fronted by Cloudflare and returns **403** to the `okhttp/*` User-Agent.
> Send a browser UA (`BML_WEB_USER_AGENT`) + `Accept: text/html…`. The `api.merchants…` host is
> not UA-gated, which is why the PATCHes below work with the default client.
---
## Flow overview
```
GET /<id>/paynow → window.appData (merchant type, pomeloJsKey)
PATCH transactions/<id> {activeBrowserId} ─┐ announce browser
PATCH transactions/<id> {fx:"reset"} ─┘
GET public-client/credentials/<id> → RSA public key + Pomelo apiKey
POST api.pay.pomelopay.com/bin-lookup → tokenId (card encrypted here)
POST public-client/transactions/next-action RATE_OPTIONS → WAIT
POST …next-action POLL (every 5s) → THREEDS + 3dsUrl
GET <3dsUrl> (modirum/render-tds) → auto-POST form (creq → ACS)
POST <ACS creq url> creq → OTP channel picker
POST <ACS creq url> destValue=token… → OTP entry page
POST <ACS creq url> otpValue=<token TOTP> → auto-POST form (cres → gateway)
POST <gateway callback> cres → auto-POST form (→ mpgsNotification)
POST transactions/mpgsNotification/<id> → records the verdict
POST …next-action POLL → TRANSACTION_CONFIRMED
```
---
## 1. Announce browser
Two unauthenticated PATCHes the page sends on load (needed by `fx`/state bookkeeping). `Origin` /
`Referer` are the transaction host.
```
PATCH https://api.merchants.bankofmaldives.com.mv/transactions/<id>
Content-Type: application/json
{"activeBrowserId":"<id>_<epoch-millis>"}
```
```
PATCH …/transactions/<id>
{"fx":"reset"}
```
---
## 2. Credentials
```
GET https://api.merchants.bankofmaldives.com.mv/public-client/credentials/<id>
Authorization: <pomeloJsKey> # the pk_production_… from the page
```
```json
{
"publicKey": {
"publicKeyId": "3edf1db0-…",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjAN…\n-----END PUBLIC KEY-----"
},
"apiKey": "UU8a9m4Q…",
"binLookupUrl": "https://api.pay.pomelopay.com/bin-lookup"
}
```
---
## 3. Tokenise the card (`bin-lookup`)
The card number, CVV and expiry are **RSA-OAEP(SHA-1)** encrypted with `publicKeyPem`, Base64
(no-wrap) encoded. The Pomelo JS uses WebCrypto `{name:"RSA-OAEP", hash:"SHA-1"}` over the plain
strings — the Java equivalent is `RSA/ECB/OAEPPadding` with
`OAEPParameterSpec("SHA-1","MGF1",MGF1ParameterSpec.SHA1,PSpecified.DEFAULT)`.
| Plaintext encrypted | Field |
|---|---|
| PAN (digits only) | `encryptedCardNumber` |
| CVV | `encryptedCardSecurityCode` |
| `YYMM` (year then month) | `encryptedCardExpiry` |
```
POST https://api.pay.pomelopay.com/bin-lookup
Content-Type: application/json
tenant: bankofmaldives
x-api-key: <apiKey>
x-tenant-id:
{
"encryptedCardNumber":"<b64>",
"encryptedCardSecurityCode":"<b64>",
"encryptedCardExpiry":"<b64>",
"externalId":"<id>",
"cardHolderName":"NAME ON CARD",
"encryptedCardExpiryMonth":"07", // NOTE: sent in clear despite the name
"encryptedCardExpiryYear":"28",
"encSerialId":"<publicKeyId>"
}
```
```json
{ "tokenId":"24d5be26…", "bin8":"42136300", "brand":"V" }
```
---
## 4. Rate options → 3-D Secure URL
All `next-action` calls POST to the merchant API with `Authorization: <pomeloJsKey>`.
```
POST https://api.merchants.bankofmaldives.com.mv/public-client/transactions/next-action
Authorization: <pomeloJsKey>
{ "action":"RATE_OPTIONS", "transactionId":"<id>",
"cardBrand":"V", "bin8":"42136300", "tokenId":"<tokenId>",
"javaEnabled":false, "javascriptEnabled":true, "language":"en-US",
"colorDepth":24, "screenHeight":1850, "screenWidth":1080, "tz":-300,
"userAgent":"Mozilla/5.0 (Android …; Mobile)" }
```
Response `action` values:
| `action` | Meaning | Do |
|---|---|---|
| `WAIT` | Processing | Poll (below) |
| `POLL` | Keep polling | Poll |
| `THREEDS` + `3dsUrl` | Challenge required | Run [§5](#5-3-d-secure-challenge) |
| `TRANSACTION_CONFIRMED` | Paid (frictionless) | Done |
| `TRANSACTION_FAILED` | Declined | Fail |
Poll body (every **5 s**, no browser-info):
```
POST …/next-action { "action":"POLL", "transactionId":"<id>" }
```
> In the capture: `RATE_OPTIONS → WAIT`, then one `POLL → THREEDS` with
> `3dsUrl = …/modirum/render-tds?transactionId=<id>`.
---
## 5. 3-D Secure challenge (Wibmo ACS)
A chain of auto-submitting HTML forms. **Only the `render-tds` form and the final gateway /
notification forms carry an `action` attribute** — the ACS's channel-picker and OTP forms have
no `action`; their JavaScript posts back to the **same creq URL**. So the creq URL (the
`render-tds` form's action) is the fallback action for every subsequent form.
1. **`GET <3dsUrl>`** (`render-tds`) → a form posting `creq` to
`https://secure-acs…wibmo.com/v1/acs/services/browser/creq/L/8573/<acsTransId>`. Capture that
URL as the ACS creq URL.
2. **POST creq** → the **channel picker**: radios `destValue ∈ {mobile, email, token}`, plus hidden
`creq`, `authMethod`, `otpDest`, `selectChannel`, `otpChannels`, `formReqType`. The BML token /
authenticator is the **`token`** channel. Submit:
`destValue=token`, `selectChannel=token`, `authMethod=OOB`, `otpDest=`, `formReqType=SUBMIT`
(keep the hidden `creq` / `otpChannels`).
3. **POST channel** → the **OTP entry** page (`otpValue` input). Submit `otpValue=<BML token TOTP>`,
`formReqType=SUBMIT`. A wrong/expired code re-renders the OTP page with text containing
*"incorrect"* / *"expired"* — regenerate the TOTP and retry once.
4. On success the ACS returns a form auto-posting **`cres`** to the Mastercard gateway; the gateway
returns a form auto-posting the result (`order.id`, `result=SUCCESS`, …) to
**`transactions/mpgsNotification/<id>`**. Follow both so the verdict is recorded.
Cookies (`__cf_bm`, `_cfuvid`) are set by the ACS and must be carried across these POSTs — the
Cloudflare-fronted ACS also requires a browser User-Agent.
---
## 6. Confirm
Poll `next-action` until the recorded verdict surfaces:
| `action` | Result |
|---|---|
| `TRANSACTION_CONFIRMED` | Success |
| `TRANSACTION_FAILED` | Declined |
The merchant's own backend is also notified out-of-band (e.g.
`fahipay.mv/api/bml/gateway/callback/?…state=CONFIRMED`).
---
## Fragility — what can break
This is scraped glue across BML, Pomelo, Wibmo and MPGS. No versioned contract, no sandbox; you
learn of breakage from a failed live payment.
| Area | Breaks when | Symptom |
|---|---|---|
| **ACS HTML scraping** (most fragile) | Wibmo changes field names (`destValue`/`otpValue`/`creq`), the `"token"` channel label, the error wording, or the form layout | "Unexpected authentication page" / wrong-OTP loop |
| **Cloudflare** | `/paynow` or `wibmo.com` adds a JS/managed challenge or TLS-fingerprint check | 403; **not fixable by UA alone** |
| **TOTP seed assumption** | The card's 3-D Secure "authenticator" is not the same soft-token TOTP as the BML login; or the card only offers SMS/email OTP | Wrong code submitted; auth fails |
| **Pomelo crypto/contract** | OAEP hash change (SHA-1→256), added nonce/timestamp, renamed fields, moved endpoint | `bin-lookup` rejects the card |
| **`next-action` states** | New/renamed actions, or browser-info becomes validated | Poll never resolves |
| **Merchant detection** | BML adds other card providers (UnionPay, Apple/Google Pay); non-`mpgs` card provider | Misroute to the wrong flow |
| **`window.appData` parsing** | Key moved/obfuscated or made dynamically signed | No `pomeloJsKey` |
| **Double-charge** | Confirm poll times out but the charge went through | Retry risks paying twice |
**Maintenance:** re-capture a HAR whenever any party updates; expect to touch the ACS form parser
most often; the flow is effectively untestable in CI (no deterministic 3-D Secure double). Keep the
gitignored HARs under `docs/bmlapi/tmp/` as reference fixtures to diff against.
---
&nbsp;
---
**Related:** [QR Payment](13-qr-payment.md) · App side:
[Card Verification & Merchant Card Pay](../thijooree/29-card-verification-and-merchant-card-pay.md)
[← Card Freeze](15-card-freeze.md)
+1
View File
@@ -193,6 +193,7 @@ The access token expires after `expires_in` seconds (typically 3600). On a `401`
| 13 | [QR Payment](13-qr-payment.md) | PayMV QR payment — QR formats, payrequest lookup, 3-step pay flow |
| 14 | [Notifications](14-notifications.md) | Notifications list, mark-as-read, and polling |
| 15 | [Card Freeze](15-card-freeze.md) | Freeze / unfreeze a BML card |
| 16 | [Merchant Card Payment](16-card-payment.md) | Pay a card-only BML Merchant Services link — Pomelo tokenise + 3-D Secure |
---
+1 -1
View File
@@ -128,4 +128,4 @@ Fetch all four service groups in sequence. For each group:
---
[← Profile Picture](06-profile-picture.md)
[← Profile Picture](06-profile-picture.md) &nbsp;&nbsp;&nbsp; **Next →** [PayMV QR](08-paymv-qr.md)
+40
View File
@@ -0,0 +1,40 @@
# PayMV QR (Receive)
> ⚠️ **Work in progress.** Thijooree does not call this endpoint yet. It generates Fahipay QRs locally, and the Fahipay app currently rejects those as **"Invalid QR"**. See [PayMV QR Format → Fahipay](../thijooree/18-paymv-qr-format.md#fahipay-work-in-progress).
Fahipay's app does **not** build its receive QR on the device. Its `PayMVQR` screen asks the server for a finished card image and displays it. Found by decompiling the app (v2.0.2, Hermes bundle), **not yet confirmed with a traffic capture**: request headers and the exact response shape are unverified.
---
## Endpoint
```
GET api/app/qr/?lang=<lang>&type=p2p&amount=<amount>
```
The app also has a `POST api/app/qr/` variant, sent as form data with `type=p2p`, `lang`, `version`, `platform=app`, `amount` and `device[...]` fields.
## Response
The app reads the image from the first of these fields that is present: `qr_image`, `qr`, `image`, `qr_url`, `qr_code`. The value is either an `http…` URL or raw base64, which the app prefixes with `data:image/png;base64,`. The payload text is read from `qr_code_text` / `qrCode`.
## The card image
A 1240 × 1322 PNG:
- Blue `#005DA3` card with a 6 px border.
- The square Fahipay icon plus the "FahiPay" wordmark top-left, and "PayMV QR" top-right.
- A blue panel holding the holder's name and the QR.
- The reference (`62→05`, e.g. `P2KVTPYL4E`) printed vertically in blue in the white margin right of the panel. The amount is not included in it.
- A "MALDIVES NATIONAL QR" footer.
Thijooree reproduces this layout in `PayMvQrFragment.renderFahipayQrCard()`; see [PayMV QR Screen](../thijooree/11-paymv-qr-screen.md#fahipay--renderfahipayqrcard).
Server-issued payload fields that differ from a plain PayMV QR: `60` = `LD` + 4 digits, `62→05` = `P` + 9 chars, `62→08` = `PAYMENT`, and `54` = `***` when there is no amount. Full samples are in [PayMV QR Format](../thijooree/18-paymv-qr-format.md#real-receive-qrs-reference-samples).
---
&nbsp;
---
[← Saved Favourites](07-contacts.md)
+1
View File
@@ -127,6 +127,7 @@ Client Server
| 5 | [Transaction History](05-history.md) | Paginated activity/transaction history |
| 6 | [Profile Picture](06-profile-picture.md) | Local-only profile picture storage (no Fahipay endpoint) |
| 7 | [Saved Favourites](07-contacts.md) | Fetch saved contacts per payment service |
| 8 | [PayMV QR](08-paymv-qr.md) | Server-generated receive QR (`api/app/qr/`) — work in progress |
---
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

+24 -1
View File
@@ -21,6 +21,29 @@ Each card shows:
Tapping anywhere on the card also copies the current code. If no logins have a seed, an empty-state message is shown instead.
---
## Seed Actions
Long-pressing a card opens a menu with:
### Export seed
A dialog titled `{bank} · {name}` showing:
- A QR code of a minimal `otpauth://totp/{BANK}?secret=…` link (e.g. `otpauth://totp/BML?secret=…`), always drawn black-on-white so it scans in dark mode. No username or issuer is included, and algorithm, digits and period are left out because SHA1, 6 and 30s are the spec defaults. Export is single-account only; `otpauth-migration://` is supported for import but never produced.
- The Base32 seed in groups of 4 (selectable text)
- A **Copy seed** button. The copy is flagged `EXTRA_IS_SENSITIVE`, so Android 13+ hides the value in the clipboard preview.
### Update seed
Replaces the stored seed for that login, e.g. after re-enrolling the authenticator with the bank.
- A red warning banner explains that the old seed is deleted permanently.
- The new seed can be typed or pasted (raw Base32 or an `otpauth://` link) or scanned with the QR button. Scans that contain several accounts (`otpauth-migration://`) ask which one to use.
- Once the input is a valid seed, a live preview shows its current and next code with a countdown so the user can check it against the bank before saving. The preview is the same card as the sign-in screen (`view_otp_preview.xml`, shared by both), and tapping it copies the code. Input that isn't valid Base32, is shorter than 8 characters (such as a pasted 6-digit code), or matches the current seed disables **Replace**.
- **Replace** asks for confirmation ("Delete old seed?"). Confirming calls `CredentialStore.updateMibOtpSeed()` / `updateBmlOtpSeed()`, which overwrite only the encrypted seed. For MIB it also calls `MibLoginFlow.updateOtpSeed()` so silent re-login uses the new seed.
The username, password and sessions are not touched. Other places that need an OTP (transfers, QR pay, pay with card) read the seed from `CredentialStore` each time, so they pick up the new seed immediately.
### Algorithm
Standard RFC 6238 TOTP:
@@ -58,7 +81,7 @@ The OTP screen is informational — the user copies the displayed code manually
## Security
The TOTP seeds are stored encrypted in `CredentialStore`. They are never logged or included in error reports.
The TOTP seeds are stored encrypted in `CredentialStore`. They are never logged or included in error reports. They leave the app only when the user chooses **Export seed**.
---
+80 -15
View File
@@ -2,37 +2,102 @@
Generates a receive-payment PayMV / Favara QR code. **Generation only** — the send/scan side of PayMV lives in `TransferFragment` via `newInstanceWithAutoScan()` and the [QR scanner](25-qr-scanner.md).
> **Fahipay QRs are a work in progress.** Thijooree's Fahipay card matches Fahipay's design, but the Fahipay app currently rejects the QRs it generates as **"Invalid QR"**. BML QRs scan fine. See [PayMV QR Format → Fahipay](18-paymv-qr-format.md#fahipay-work-in-progress).
---
## Fragment — `PayMvQrFragment`
A single screen (no tabs). Re-renders the QR live as the user edits the form.
A single screen (no tabs). Re-renders the QR live (300 ms debounce) as the user edits the form. It can also be opened for a saved contact with `PayMvQrFragment.forContact(accountNumber, name, bank)`. The QR then pays into the contact's account, and the account picker and phone toggle are hidden.
### Fields
| Field | Source / behaviour |
|---|---|
| Source account dropdown | `viewModel.accounts`, filtered to non-card MVR accounts (MIB and BML USD currently excluded — both flagged as TODO in source). Defaults to `CredentialStore.getDefaultAccountNumber()` when set |
| Amount (`etAmount`) | Optional. Blank → open-amount QR |
| Reference (`etReference`) | Free-text purpose; defaults to `paymvqr_reference_default` if blank — written to tag 62→08 |
| Include phone (`switchIncludePhone`) | When on, writes the saved BML / Fahipay mobile to sub-tag 26→05 (auto-prefixed `+960` if 7-digit local) |
| Source account dropdown | `viewModel.accounts`, filtered to non-card MVR accounts (MIB, M-Faisa and BML USD currently excluded — flagged as TODO in source). Defaults to `CredentialStore.getDefaultAccountNumber()` when set |
| Amount (`etAmount`) | Optional. Blank / zero / unparseable → open-amount QR. Commas are stripped |
| Reference (`etReference`) | Free-text purpose, written to tag 62→08. Blank → tag omitted (BML), or `PAYMENT` (Fahipay) |
| Include phone (`switchIncludePhone`) | When on, writes the saved BML / Fahipay mobile to sub-tag 26→05, normalised to `+960XXXXXXX` |
### Generation
### What goes on the card
`buildQrPayload()` assembles a decimal TLV payload per the [PayMV QR Format](18-paymv-qr-format.md):
| Item | Value |
|---|---|
| Name | BML / MIB: `accountBriefName`. Fahipay: the holder's full name (`profileName`, falling back to the saved Fahipay profile's `fullName`) — **not** the generic "Fahipay Wallet" brief name. Contacts: the contact's name. Always uppercased |
| QR | The payload below, white modules on the card blue, error correction M, no quiet zone |
| Vertical text | The QR's reference (tag 62→05) — see below |
| Amount | **Not printed** on the card (neither bank does); it only appears inside the QR, and in BML's vertical text |
1. Tag 26 container: GUI (`mv.favara.mpqr`), acquirer BIC, account number, optional mobile, `IPAY`
2. Acquirer BIC is derived from the source account's bank: `MALBMVMV` (BML) / `MADVMVMV` (MIB) / `FAHIMVMV` (Fahipay)
3. Tag 62 container: random 9-char reference + the purpose text
4. Tag 80 container: GUI + ISO timestamp
5. Appends `"6304"` and computes CRC-16/CCITT-FALSE over the full string
### Vertical text (reference)
The rendered card image (bank-styled background plus QR) is shown in-place.
Both banks print a short code vertically beside the QR, reading bottom-to-top. It is the same string as the payload's reference, **tag 62→05**, so Thijooree calculates the reference first and uses it for both.
| Bank | Reference / vertical text | Example |
|---|---|---|
| BML | Account number converted to **base-32** (digits `0-9A-V`, uppercase), followed by the **amount exactly as typed** (commas removed, no forced decimals), capped at 25 chars | `7730000188362` → `70V3UKKUA`; with amount `100` → `70V3UKKUA100` |
| Fahipay | `P` + 9 random uppercase alphanumeric chars. **No amount** | `P2KVTPYL4E` |
| Other (MIB contacts) | 9 random uppercase alphanumeric chars | `WHQS0SX5O` |
BML's base-32 is `AccountNumbertoBase32` from the BML app: `BigInt(account)`, repeatedly `% 32` into the alphabet `0123456789ABCDEFGHIJKLMNOPQRSTUV`. If the account number isn't numeric, Thijooree falls back to a random 9-char reference.
---
## Card Rendering
Two renderers, chosen by the target's bank. Both return a `Bitmap` shown in `ivQrCard` (`fitCenter`) and used for Share / Save.
### BML (and MIB) — `renderQrCard()`
A 1:1 copy of BML app v2.1.47's `ReceiveCard` React Native component (decompiled from the Hermes bundle). All values are BML's StyleSheet values in dp, laid out for a 560 dp reference screen width (`SCREEN_WIDTH_DP`) and drawn at 2 px/dp (`PX_PER_DP`), so the card comes out about 1024 px wide.
| Element | Spec |
|---|---|
| Colour | `mmaBlue` `#0E5CA4` everywhere |
| Card | Width `sw − 48`. Blue background, radius 20. The white top section is inset 2 dp (top corners 18), which shows as a thin blue border |
| Header row | 32 dp from top, 40 dp side margins. Left: `bml_logo_paymv` ("BANK OF MALDIVES" wordmark, from BML's assets) contained in `0.38·sw × 0.38·sw·0.1117`. Right: "PayMV QR", Sofia Pro Bold, `#0E5CA4`, sized to BML's `0.2·sw × 0.2·sw·0.1733` image box, shifted down 1 dp. MIB uses `mib_faisanet_logo` in the same box |
| QR panel | 24 dp below the header, 40 dp side margins, radius 16, 24 dp bottom margin |
| Name | Roboto (system default) regular, 14 sp, white; 8 + 12 dp above, 16 dp below |
| QR | `0.5·sw` square; 37 dp padding below |
| Vertical text | Roboto 10 sp, **black at 80 % opacity**, rotated −90°. Centred `railW/1.37 − railW/2` right of the QR's right edge and `(qr + railW/1.5)/2` down from the QR top, where `railW = sw/1.85`. Ellipsized to `railW` |
| Footer | "MALDIVES NATIONAL QR", Sofia Pro Bold (`res/font/sofia_pro_bold.ttf`), `0.046·sw`, letter spacing 1.2 dp, 12 dp vertical padding |
### Fahipay — `renderFahipayQrCard()`
Fahipay's app doesn't draw its card; it shows an image generated by Fahipay's server (`api/app/qr/`). Thijooree copies that image's layout, measured in pixels on its **1240 × 1322** canvas. Text is sized so capital letters match the measured cap heights.
| Element | Spec |
|---|---|
| Colour | `#005DA3` |
| Card | Blue, radius 50. White area inset 6 px (top corners 44) down to y 1174. The blue below it is the footer |
| Header row | Square app icon `fahipay_logo` at (94, 94)–(163, 163), then the "FahiPay" wordmark `fahipay_logo_long` at x 178, y 104, 48 px tall — **both, side by side** |
| "PayMV QR" | Sofia Pro Bold, blue, right-aligned at x 1147, cap height 30 (cap top y 101) |
| QR panel | (166, 218)–(1074, 1126), radius 55 |
| Name | Roboto regular, white, centred at x 619, cap height 30 (cap top y 314). Shrinks to fit the panel minus 80 px |
| QR | 562 px at (338, 417) |
| Vertical text | Montserrat Regular (`res/font/montserrat_regular.ttf`), **blue**, cap height 27, rotated −90°. It sits in the **white margin right of the panel**: text starts at y 1087, baseline at x 1171 |
| Footer | "MALDIVES NATIONAL QR", Sofia Pro Bold, white, cap height 55.5 (cap top y 1220), BML's letter spacing (1.2/25.76 em) |
Fonts follow the BML card (Sofia Pro Bold, Roboto), except the vertical text, which keeps Fahipay's Montserrat.
---
## Generation
`buildQrPayload()` assembles a decimal TLV payload per the [PayMV QR Format](18-paymv-qr-format.md#generating-a-receive-payment-qr):
1. Tag 01: `11` (static). Fahipay QRs with an amount use `12` (dynamic)
2. Tag 26: GUI (`mv.favara.mpqr`), acquirer BIC ×2 — `MALBMVMV` (BML) / `MADVMVMV` (MIB) / `FAHIMVMV` (Fahipay), account number, optional mobile, `IPAY`
3. Tag 54: amount as `%.2f`. If there's no amount: omitted (BML), `***` (Fahipay)
4. Tag 59: name, uppercased, max 25 chars
5. Tag 60: Fahipay only — `LD` + 4 random digits
6. Tag 62: the reference (above) + purpose
7. Tag 80: GUI + timestamp `yyyy-MM-dd'T'HH:mm:ss.00000`
8. Appends `"6304"` and computes CRC-16/CCITT-FALSE over the full string
### Actions
- **Share** (`btnShare`) — exports the rendered card via `FileProvider` + `ACTION_SEND`
- **Save** (`btnSave`, `PayMvQrFragment.kt:78`) — writes the PNG to `MediaStore.Images` / `Pictures/`
- **Share** (`btnShare`) — writes `<name>_paymv_qr.png` to the cache and shares it via `FileProvider` + `ACTION_SEND`
- **Save** (`btnSave`) — writes `<name>_PayMV_QR.png` to `MediaStore.Images` / `Pictures/`
---
+92 -11
View File
@@ -31,10 +31,10 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
| `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs and in BML POS QRs |
| `52` | Merchant category code | `"0000"` (generic) |
| `53` | Transaction currency | `"462"` = MVR (ISO 4217 numeric) |
| `54` | Transaction amount | Decimal string (e.g. `"1.50"`); absent for open-amount QRs |
| `54` | Transaction amount | Decimal string (e.g. `"1.50"`). Open-amount QRs: absent (Thijooree BML) or `"***"` (BML's and Fahipay's own QRs) |
| `58` | Country code | `"MV"` |
| `59` | Merchant / recipient name | Max 25 characters |
| `60` | Merchant city / store code | BML POS QRs only |
| `59` | Merchant / recipient name | Max 25 characters, uppercase in every real QR seen |
| `60` | Merchant city / store code | `LD` + 4 digits (e.g. `LD0442`, `LD0745`). Seen in BML POS QRs and in BML's and Fahipay's own receive QRs; meaning of the digits unknown |
| `62` | Additional data field | Container — see sub-tags below |
| `63` | CRC | `6304` prefix + 4-char hex checksum — always last |
| `80` | Supplementary data | Container — timestamp and domain |
@@ -66,8 +66,8 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
| Sub-Tag | Field | Notes |
|---|---|---|
| `05` | Reference / bill number | 9 random uppercase alphanumeric characters |
| `08` | Payment purpose | Free-form text entered by the payee |
| `05` | Reference / bill number | Bank-specific — see [Reference (tag 62→05)](#reference-tag-6205). Also printed vertically on the QR card |
| `08` | Payment purpose | Free-form text entered by the payee. BML's app defaults it to `Quickpay Transfer`, Fahipay to `PAYMENT` |
---
@@ -77,6 +77,7 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
|---|---|---|
| `00` | Domain | `"mv.favara.mpqr"` |
| `01` | Timestamp | ISO 8601 format: `"yyyy-MM-dd'T'HH:mm:ss.00000"` |
| `02` | Unknown | `"0005"` — only seen in Fahipay's own QR **with an amount**; not generated by Thijooree |
---
@@ -115,18 +116,98 @@ To create a QR that others can scan to pay you:
10 04 IPAY
52 04 0000 ← MCC
53 03 462 ← MVR
54 <len> <amount> ← Omit tag entirely if open-amount
54 <len> <amount> ← "%.2f". Open amount: omit (BML) / "***" (Fahipay)
58 02 MV
59 <len> <name up to 25 chars>
59 <len> <NAME UP TO 25 CHARS> ← Uppercased
60 06 LD<4 random digits> ← Fahipay only
62 <len>
05 09 <9 random alphanum chars> ← Reference
08 <len> <purpose text>
05 <len> <reference> ← Bank-specific, see below
08 <len> <purpose text> ← Omit if blank (BML) / "PAYMENT" (Fahipay)
80 <len>
00 15 mv.favara.mpqr
01 <len> <yyyy-MM-dd'T'HH:mm:ss.00000> ← Timestamp
6304<CRC>
```
For Fahipay, tag `01` is `12` (dynamic) when an amount is set.
---
## Reference (tag 62→05)
The reference is also the **vertical text** printed beside the QR on both banks' cards, so it is calculated once and used for both (`PayMvQrFragment.generateQr()`).
### BML — base-32 account number + amount
```
reference = base32(accountNumber) + amountAsTyped
```
- `base32` is BML's `AccountNumbertoBase32`: treat the account number as an integer and convert it to base 32 with the alphabet `0123456789ABCDEFGHIJKLMNOPQRSTUV`, most significant digit first
- `amountAsTyped` is the amount field with commas removed and **no forced decimals** (`100` stays `100`, `100.5` stays `100.5`). Empty for open-amount QRs
- Capped at 25 characters. A non-numeric account number falls back to 9 random characters
| Account | Amount | Reference / vertical text |
|---|---|---|
| `7730000188362` | — | `70V3UKKUA` |
| `7730000188362` | `100` | `70V3UKKUA100` |
Confirmed by decoding a QR from BML's app: `62→05` = `70V3UKKUA`, the same as the vertical text on its card.
### Fahipay — `P` + 9 random characters
Fahipay's server issues references like `P135KOKXJY` and `P2KVTPYL4E`: `P` followed by 9 uppercase alphanumerics. The vertical text shows the reference only — **the amount is not appended** (confirmed on a QR carrying amount `55`). Thijooree generates `"P" + 9 random chars`.
### Others
9 random uppercase alphanumeric characters.
---
## Real Receive QRs (Reference Samples)
Decoded from QR images generated by the official apps (CRC verified).
**BML app** (open amount):
```
00020101021126920014mv.favara.mpqr0108MALBMVMV0208MALBMVMV031377300001883620511+96091980261004IPAY6006LD04425204000053034625403***5802MV5915SHIHAM A.RAHMAN6234050970V3UKKUA0817Quickpay Transfer80470014mv.favara.mpqr01252026-09-26T02:29:53.000006304F8E6
```
Note that BML's app places tag `60` *inside* tag `26` here (after `10 IPAY`, as `6006LD0442`).
**Fahipay app**, open amount:
```
00020101021126810014mv.favara.mpqr0108FAHIMVMV0208FAHIMVMV03125008500611080511+96098074051004IPAY5204000053034625403***5802MV5912MOHAMED RAIF6006LD074562250510P135KOKXJY0807PAYMENT80470014mv.favara.mpqr01252026-09-26T03:44:36.0000063042707
```
**Fahipay app**, amount `55`:
```
00020101021226810014mv.favara.mpqr0108FAHIMVMV0208FAHIMVMV03125003600510030511+96091980261004IPAY5204000053034625402555802MV5919SHIHAM ABDUL RAHMAN6006LD097062250510P2KVTPYL4E0807PAYMENT80550014mv.favara.mpqr01252026-09-26T03:22:08.000000204000563045304
```
---
## Fahipay (Work in Progress)
> ⚠️ **Fahipay QRs generated by Thijooree do not work yet.** The Fahipay app rejects them as **"Invalid QR"**. BML QRs from Thijooree scan fine in the BML app.
Fahipay's app doesn't build its QR locally: it fetches it from `GET api/app/qr/?lang=…&type=p2p&amount=…`, and the server returns the finished card image and payload. Things tried so far, with the Fahipay app still reporting invalid:
| Change | Status |
|---|---|
| Mobile `26→05` normalised from Fahipay's stored `960XXXXXXX` to `+960XXXXXXX` | Done (was a real bug) |
| Name `59` uppercased | Done |
| `54` = `***` for open amount, `01` = `12` with an amount | Done |
| `60` = `LD` + 4 random digits | Done |
| `62→08` defaults to `PAYMENT` | Done |
| `62→05` shaped `P` + 9 chars | Done |
| `80→02` = `0005` (amount QRs only) | Not done |
The CRC is correct (verified against all samples). With no amount, Thijooree's payload now has the same fields in the same order as Fahipay's own. The leading theory is that Fahipay's scanner looks up the `P…` reference on Fahipay's server, which issued it. If so, no locally generated QR can pass, and the fix would be to fetch the payload from `api/app/qr/` and render the card around it.
---
## Parsing a PayMV QR (Incoming Scan)
@@ -219,11 +300,11 @@ exactly one request is made either way.
## Example Payload
Static QR for account `7700000000123`, holder `"AHMED ALI"`, open amount, purpose `"Rent"`:
Static BML QR for account `7730000188362`, holder `"AHMED ALI"`, open amount, purpose `"Rent"`:
```
000201010211268...520400005303462
5802MV5909AHMED ALI6225050912345ABCDEF0804Rent
5802MV5909AHMED ALI6221050970V3UKKUA0804Rent
80...63044A2B
```
@@ -0,0 +1,159 @@
# Card Verification & Merchant Card Payment
Two linked features:
1. **Card verification** — on the [Cards](22-cards.md) manage screen, a **Verify** action reads the
physical card over NFC (or takes it by hand), checks it matches the on-screen card, and stores the
full card details (PAN, expiry, CVV) encrypted on-device.
2. **Merchant card payment** — on [Transfer](07-transfer.md), a BML Merchant Services transaction ID
whose merchant has **no BML Pay** is paid with a verified card via the Pomelo + 3-D Secure flow
([BML API → Merchant Card Payment](../bmlapi/16-card-payment.md)).
> ⚠️ The merchant card flow is scraped browser/ACS traffic, not a stable API. Storing the CVV is a
> security/PCI liability. See the API doc's
> [Fragility](../bmlapi/16-card-payment.md#fragility--what-can-break) section.
---
## Card verification
### Entry — the Verify button
In manage mode the action row has **Change PIN · Freeze · Block · Verify**
(`fragment_cards.xml`, icon `ic_card_verify`). The button reads **Verified** once the selected card
has a stored entry (`bindManageCardData` in `PayWithCardFragment.kt`).
`onVerifyClicked(item)` (`PayWithCardFragment.kt:296`) branches on NFC:
| Device state | Behaviour |
|---|---|
| No NFC hardware | Straight to manual entry (`showCardDetailsDialog`) |
| NFC off | Dialog: **NFC Settings** / **Manually Verify** / Cancel |
| NFC ready | Enter verify mode (tap animation) |
### Verify mode
`setVerifyMode(enabled, item)` (`PayWithCardFragment.kt:314`) swaps the manage action buttons for
**Cancel Verification** / **Manually Verify**, and draws `CardVerifyAnimationView`
(`ui/home/CardVerifyAnimationView.kt`) in the empty area — a flat card tapping a phone with NFC
waves, matching the [Tap to Pay](23-tap-to-pay.md) style, with `WAITING / READING / SUCCESS / ERROR`
states.
`startVerifyReader()` (`PayWithCardFragment.kt:346`) uses `NfcAdapter.enableReaderMode` (reader,
not HCE). On tap, `EmvCardReader.read(tag)` (`nfc/EmvCardReader.kt:24`) runs a minimal contactless
EMV read (PPSE → SELECT AID → GPO → read AFL records) and returns `CardData(pan, expiry)` from tags
`5A` / `57` (Track 2) and `5F24`. `onVerifyCardRead` (`:367`) compares the **last 4 digits** against
the managed card:
- **match** → success check mark → `showCardDetailsDialog(item, nfcData)` for the CVV;
- **mismatch / unreadable** → error state, then back to waiting.
### Card details dialog
`showCardDetailsDialog(item, nfcData?)` (`PayWithCardFragment.kt:406`, layout
`dialog_card_manual_verify.xml`):
- The **name** is always prefilled read-only from the API-provided holder name (`accountBriefName`
for BML, `cardHolderName` for MIB) — never read off the chip.
- **After an NFC tap** (`nfcData != null`): card number + expiry are prefilled and **locked**; only
the CVV is entered. Title shows `Card ending <4>`.
- **Manual entry**: number + expiry + CVV entered; validated with a Luhn check (`luhnValid`,
`:500`), last-4 match, a not-in-the-past expiry (`normalizeExpiry`, `:489`), and a 3–4 digit CVV.
`saveVerifiedCard` (`PayWithCardFragment.kt:481`) writes the entry and toggles the button to
**Verified**.
### Storage — `VerifiedCardStore`
`util/VerifiedCardStore.kt`. Per-card entry keyed by the card's identity (`bml:<accountNumber>` /
`mib:<cardId>`), encrypted with the shared `CacheEncryption` AndroidKeyStore key (same as the other
caches).
```
VerifiedCard(pan, expiry /*MM/YY*/, cvv, method /*nfc|manual*/, verifiedAt)
```
`save` / `load` / `isVerified` / `keys` / `remove` / `clear`. **Not** wiped by the "clear cache" or
"remove login" paths — treated as user data (like profile images).
---
## Merchant card payment
### Routing — card-only vs BML Pay
A transaction ID / link typed into Transfer's **To** field is parsed by
`BmlMerchantTxnClient.parseTransactionId` and resolved in
`TransferFragment.lookupBmlMerchantTransaction` (`TransferFragment.kt:882`):
1. `BmlMerchantTxnClient.fetchPayPage(id)` (`api/bml/BmlMerchantTxnClient.kt:43`) loads `/paynow`
(browser UA — the host is Cloudflare-fronted) and parses `window.appData`.
2. If `!supportsBmlPay && supportsCard` → `bmlHandler().payCardMerchant(page)` (card flow).
3. Otherwise → existing QR path (`fetchQrPayload` → `bmlQrPayTarget` → `openBmlQr`), see
[Transfer Flows](20-transfer-flows.md).
### On-screen, like the QR merchant mode
`BmlTransferHandler.payCardMerchant(page)` (`ui/home/transfer/BmlTransferHandler.kt:441`) renders
into the Transfer screen rather than a one-off dialog, mirroring the BML QR merchant mode:
- `showCardMerchant(page)` (`:468`) paints the merchant as the **To** card, fills + **locks** the
amount (these links carry a fixed amount), and disables remarks.
- The **From** picker is limited to BML cards; a verified default card is auto-selected.
- State lives in `TransferDraft.bmlCardMerchant`, so it survives tab switches and theme/rotation
recreation (repainted via `restoreFromDraft`).
- The **✕** on the To card and `clearForm()` both call `clearCardMerchant()` (`:486`), which unlocks
and empties the amount and re-enables remarks.
A card is only offered when it is **both** verified **and** belongs to a BML login the app has an
OTP seed for (`verifiedCardCandidates`, `:428`; `isCardVerified`, `:463`) — the 3-D Secure step
needs that seed.
### Send
`submitCardPayment` (`:496`) → `confirmCardMerchant` (`:506`) shows the shared transfer confirm
dialog (biometric-gated), then `executeCardMerchant` (`:534`) runs, off the main thread:
```
BmlMerchantCardPayClient().pay(page, card) { Totp.generate(otpSeed) }
```
where `card` comes from `VerifiedCardStore` (expiry split `MM/YY` → month/year) and `otpSeed` is the
card's BML login seed. The client (`api/bml/BmlMerchantCardPayClient.kt`) performs the whole
Pomelo + MPGS + Wibmo 3-D Secure sequence — feeding the BML token TOTP into the ACS OTP form
automatically, retrying once if the first code expired. Outcome is shown in the shared
processing/success dialog; failures surface as a toast.
### Key assumption
The 3-D Secure "Authenticator" OTP must be the **same** soft-token TOTP the app already uses for BML
transfers (`CredentialStore.loadBmlCredentials(loginId).otpSeed`). This holds for the user's own
BML-issued card on a login the app has. It does **not** work for a non-BML card, a card belonging to
another login/person, or a card whose 3-D Secure only offers SMS/email OTP.
---
## Files
| File | Role |
|---|---|
| `ui/home/PayWithCardFragment.kt` | Verify button, verify mode, NFC reader, card details dialog |
| `ui/home/CardVerifyAnimationView.kt` | "Tap card to verify" animation |
| `nfc/EmvCardReader.kt` | Minimal contactless EMV read (PAN + expiry) |
| `util/VerifiedCardStore.kt` | Encrypted per-card store of full details |
| `res/layout/dialog_card_manual_verify.xml` | Card details form |
| `api/bml/BmlMerchantTxnClient.kt` | `fetchPayPage` (merchant-type detection), `announceBrowser`, QR payload |
| `api/bml/BmlMerchantCardPayClient.kt` | Pomelo tokenise + 3-D Secure card payment |
| `ui/home/transfer/BmlTransferHandler.kt` | On-screen card merchant mode + payment |
| `ui/home/TransferFragment.kt` | Transaction-ID lookup + routing |
---
&nbsp;
---
**Related:** [Cards](22-cards.md) · [Transfer Flows](20-transfer-flows.md) · API side:
[Merchant Card Payment](../bmlapi/16-card-payment.md)
[← Settings — About](28-settings-about.md)
+3 -2
View File
@@ -19,7 +19,7 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
| [08 — Contacts](08-contacts.md) | Contact list, add/edit/delete, categories, contact picker sheet |
| [09 — Activities](09-activities.md) | Local transfer log, TransferReceiptFragment, share/save receipt |
| [10 — OTP Screen](10-otp-screen.md) | TOTP display, real-time countdown, enrolled bank authenticators |
| [11 — PayMV QR Screen](11-paymv-qr-screen.md) | Generate receive-payment QR (send/scan lives in Transfer) |
| [11 — PayMV QR Screen](11-paymv-qr-screen.md) | Generate receive-payment QR, BML/Fahipay card rendering, vertical reference text (Fahipay QRs WIP) |
| [12 — BML QR Pay](12-bml-qr-pay.md) | (Stub — see Transfer Flows for the live BML QR merchant flow) |
| [13 — Financing](13-financing.md) | MIB promotional deals, BML loans, BML foreign spend limits |
| [14 — Settings](14-settings.md) | Settings hub: Logins (drag to reorder), Appearance, Privacy & Security, Notifications, Storage, About |
@@ -34,12 +34,13 @@ Documentation for app-specific logic — UI flows, routing decisions, and busine
| [26 — Circular Nav](26-circular-nav.md) | Radial 4-slot wheel UI with lock centre |
| [27 — Settings: Notifications](27-settings-notifications.md) | Opt-in flow: permission → battery opt → service start |
| [28 — Settings: About](28-settings-about.md) | Version, T&Cs, donate buttons |
| [29 — Card Verification & Merchant Card Pay](29-card-verification-and-merchant-card-pay.md) | NFC/manual card verification + card-only BML merchant payment |
## Reference
| Document | Description |
|---|---|
| [18 — PayMV QR Format](18-paymv-qr-format.md) | Decimal TLV encoding, all tags, CRC-16, QR generation recipe, parsing reference |
| [18 — PayMV QR Format](18-paymv-qr-format.md) | Decimal TLV encoding, all tags, CRC-16, per-bank references, real samples, Fahipay WIP, parsing reference |
| [19 — Parsers](19-parsers.md) | Account display parser architecture — how raw bank API data is normalised into a unified `AccountListDisplay` model |
| [20 — Transfer Flows](20-transfer-flows.md) | TransferFragment entry points, recipient lookup, transfer type routing, rejected combinations, BML business OTP flow, BML QR merchant payments |
| [AI Security Audit](AI_SECURITY_CHECK.md) | Full source security audit — credential storage, network layer, manifest, data privacy |
+3
View File
@@ -0,0 +1,3 @@
# FAQ
## [What is and how do i get my TOTP Seed?](totpseed/README.md)
@@ -0,0 +1,87 @@
# Set up BML
Reset your Bank of Maldives authenticator to get a new OTP seed, then add that seed to Thijooree so it can generate your OTP codes.
> [!NOTE]
> You need the BML app signed in, and a BML debit card with its expiry date and CVC to confirm who you are.
> [!IMPORTANT]
> Want the same codes in another authenticator app too, such as Microsoft Authenticator or Google Authenticator? Add the secret key to that app **after step 5 and before step 7**. After you tap **Verify Code**, BML stops showing the secret key and you would have to reset again. See [Export from Microsoft Authenticator](04-export-microsoft.md) for the steps.
<table>
<tr>
<th width="25%">Step 1</th>
<th width="25%">Step 2</th>
<th width="25%">Step 3</th>
<th width="25%">Step 4</th>
</tr>
<tr>
<td align="center"><img src="screenshots/bml_1.jpg" alt="BML app wallet screen with the profile icon highlighted" width="200"></td>
<td align="center"><img src="screenshots/bml_2.jpg" alt="Profile menu with Authenticator Setup highlighted" width="200"></td>
<td align="center"><img src="screenshots/bml_3.jpg" alt="Channel Settings screen with the Reset Authenticator button highlighted" width="200"></td>
<td align="center"><img src="screenshots/bml_4.jpg" alt="Debit card verification form with the Authorize button highlighted" width="200"></td>
</tr>
<tr>
<td valign="top">
<b>Open your profile</b><br>
In the BML app, tap the <b>profile icon</b> in the top-right corner of the Wallet screen.
</td>
<td valign="top">
<b>Open Authenticator Setup</b><br>
In the menu, under <b>Settings</b>, tap <b>Authenticator Setup</b>.
</td>
<td valign="top">
<b>Reset the authenticator</b><br>
On the <b>Security</b> tab, tap <b>Reset Authenticator</b>. This replaces any authenticator app you used before.
</td>
<td valign="top">
<b>Verify your debit card</b><br>
Pick a debit card, enter its expiry month, expiry year and CVC, then tap <b>Authorize</b>.
</td>
</tr>
<tr>
<th>Step 5</th>
<th>Step 6</th>
<th>Step 7</th>
<th>Done</th>
</tr>
<tr>
<td align="center"><img src="screenshots/bml_5.jpg" alt="QR code screen with the copy button next to the secret key highlighted" width="200"></td>
<td align="center"><img src="screenshots/thijooree_1.jpg" alt="Thijooree sign-in screen with the OTP seed filled in and the current OTP shown" width="200"></td>
<td align="center"><img src="screenshots/bml_6.jpg" alt="BML screen with the OTP entered and the Verify Code button highlighted" width="200"></td>
<td align="center"><img src="screenshots/bml_7.jpg" alt="Authenticator reset successfully message" width="200"></td>
</tr>
<tr>
<td valign="top">
<b>Copy the secret key</b><br>
Below the QR code, tap the <b>copy button</b> next to the secret key. Keep this screen open, you will come back to it.
</td>
<td valign="top">
<b>Add the seed to Thijooree</b><br>
Open Thijooree and paste the key into <b>OTP Seed (TOTP Secret)</b>. Tap the <b>Current OTP</b> box to copy the 6-digit code.<br><br>
<i>Adding the key to another authenticator app? Do it now, before step 7.</i>
</td>
<td valign="top">
<b>Verify the code in BML</b><br>
Go back to the BML app, paste the code into the 6-digit code field and tap <b>Verify Code</b>.
</td>
<td valign="top">
<b>All done</b><br>
BML shows <b>Authenticator reset successfully</b>. Thijooree now generates your BML OTP codes.
</td>
</tr>
</table>
## Log in to Thijooree
Once BML shows **Authenticator reset successfully**, go back to Thijooree:
1. Enter your BML **Username** and **Password**.
2. Check that **OTP Seed (TOTP Secret)** still has the key you pasted in step 6.
3. Tap **Login**.
> [!TIP]
> The OTP changes every 30 seconds. If BML rejects the code, copy the current one from Thijooree again and verify straight away.
> [!WARNING]
> The secret key gives full access to your OTP codes. Don't share it or screenshot it where others can see it.
@@ -0,0 +1,39 @@
# Set up MIB
Maldives Islamic Bank doesn't let you reset your authenticator from the app. The only way to get a new OTP seed is to ask customer care, and then wait. And wait some more.
> [!NOTE]
> You need patience, a working email address and a small amount of faith. Results may vary.
## How to do it
1. **Contact customer care**<br>
Get in touch with MIB customer care and ask them to reset your authenticator and send you a new secret key.
2. **Perform the summoning ritual**<br>
Light a candle, face the direction of the nearest MIB branch and chant *"please reply, please reply"* three times. Offering a cup of tea to the ticket gods is optional but recommended.
3. **Wait for the email**<br>
MIB emails you the new secret key. Eventually. Check your inbox, then check your spam folder, then check your inbox again.
4. **Wait more**<br>
Still nothing? This is normal. Refresh your inbox. Touch grass. Refresh your inbox again.
5. **Perform another ritual**<br>
Repeat step 2, but with two candles this time. If it has been a few working days, a polite follow-up to customer care also works, and is less of a fire hazard.
6. **Add the seed to Thijooree**<br>
Once the email arrives, copy the secret key from it. Open Thijooree, go to the **faisanet** sign-in screen and paste the key into **OTP Seed (TOTP Secret)**. The **Current OTP** appears below it.
## Log in to Thijooree
Once the key is in, in Thijooree:
1. Enter your MIB **Username** and **Password**.
2. Tap **Login**.
> [!TIP]
> Already have your MIB account in Google Authenticator or Bitwarden? Skip the rituals entirely and see [Export from Google Authenticator](03-export-googleauthenticator.md) or [Export from Bitwarden](05-export-bitwarden.md).
> [!WARNING]
> The secret key gives full access to your OTP codes. Don't share it, and delete the email once you have logged in.
@@ -0,0 +1,107 @@
# Export from Google Authenticator
Google Authenticator can export your accounts as a QR code. Take a screenshot of that QR code and load it into Thijooree to get the same OTP seed, without resetting anything with your bank.
> [!NOTE]
> Exporting doesn't change your seed. Google Authenticator keeps working, and it shows the same codes as Thijooree.
> [!IMPORTANT]
> Thijooree holds one seed per login. In step 3, select **only** the bank account you want to log in to on Thijooree.
<table>
<tr>
<th width="25%">Step 1</th>
<th width="25%">Step 2</th>
<th width="25%">Step 3</th>
<th width="25%">Step 4</th>
</tr>
<tr>
<td align="center"><img src="screenshots/google_1.jpg" alt="Google Authenticator home screen with the menu button highlighted" width="200"></td>
<td align="center"><img src="screenshots/google_2.jpg" alt="Google Authenticator menu with Transfer codes highlighted" width="200"></td>
<td align="center"><img src="screenshots/google_3.jpg" alt="Select codes screen with only MIB checked and the Next button highlighted" width="200"></td>
<td align="center"><img src="screenshots/google_4.jpg" alt="Scan this QR code screen showing the export QR code" width="200"></td>
</tr>
<tr>
<td valign="top">
<b>Open the menu</b><br>
In Google Authenticator, tap the <b>menu button</b> (three lines) in the top-left corner.
</td>
<td valign="top">
<b>Open Transfer codes</b><br>
Tap <b>Transfer codes</b>.
</td>
<td valign="top">
<b>Select your bank account</b><br>
Check <b>only</b> the bank account you want to log in to on Thijooree, then tap <b>Next</b>.
</td>
<td valign="top">
<b>Screenshot the QR code</b><br>
Take a <b>screenshot</b> of the QR code. Keep this screen open, you will come back to it.
</td>
</tr>
<tr>
<th>Step 5</th>
<th>Step 6</th>
<th>Step 7</th>
<th>Step 8</th>
</tr>
<tr>
<td align="center"><img src="screenshots/thijooree_2.jpg" alt="Thijooree sign-in screen with the QR button next to the OTP seed field highlighted" width="200"></td>
<td align="center"><img src="screenshots/thijooree_3.jpg" alt="Thijooree QR scanner with the Pick image button highlighted" width="200"></td>
<td align="center"><img src="screenshots/thijooree_4.jpg" alt="Photo picker with the QR code screenshot selected" width="200"></td>
<td align="center"><img src="screenshots/google_5.jpg" alt="Google Authenticator Scan this QR code screen with the Next button highlighted" width="200"></td>
</tr>
<tr>
<td valign="top">
<b>Open the QR scanner</b><br>
Open Thijooree and, on the sign-in screen, tap the <b>QR button</b> next to <b>OTP Seed (TOTP Secret)</b>.
</td>
<td valign="top">
<b>Pick an image</b><br>
The QR code is on the same phone, so there is nothing to scan. Tap <b>Pick image</b>.
</td>
<td valign="top">
<b>Select the screenshot</b><br>
Select the QR code screenshot from step 4. Thijooree fills in the OTP seed for you.
</td>
<td valign="top">
<b>Finish the export</b><br>
Go back to Google Authenticator and tap <b>Next</b> on the QR code screen.
</td>
</tr>
<tr>
<th>Step 9</th>
<th colspan="2">Step 10</th>
<th></th>
</tr>
<tr>
<td align="center"><img src="screenshots/google_6.jpg" alt="Remove your exported codes screen with Keep exported codes and Done highlighted" width="200"></td>
<td align="center"><img src="screenshots/google_7.jpg" alt="Google Authenticator list with the MIB code highlighted" width="200"></td>
<td align="center"><img src="screenshots/thijooree_5.jpg" alt="Thijooree sign-in screen with the Current OTP highlighted, matching Google Authenticator" width="200"></td>
<td></td>
</tr>
<tr>
<td valign="top">
<b>Keep the exported codes</b><br>
Select <b>Keep exported codes</b> and tap <b>Done</b>. Don't remove them, or the account disappears from Google Authenticator.
</td>
<td valign="top" colspan="2">
<b>Check that the codes match</b><br>
Compare the <b>Current OTP</b> in Thijooree with the code for the same account in Google Authenticator. They should be the same, because both use the same seed.
</td>
<td></td>
</tr>
</table>
## Log in to Thijooree
Once the codes match, in Thijooree:
1. Enter your bank **Username** and **Password**.
2. Tap **Login**.
> [!TIP]
> The OTP changes every 30 seconds. If the codes don't match, wait for both to refresh and compare again. If they still differ, go back to step 3 and check you selected the right account.
> [!WARNING]
> The QR code screenshot holds your OTP seed. Delete it from your phone, and from any cloud photo backup, once you have logged in.
@@ -0,0 +1,62 @@
# Export from Microsoft Authenticator
Microsoft Authenticator can't export TOTP seeds unless your phone is rooted, so you can't move an existing seed out of it.
Instead, reset your OTP seed with your bank to get a new secret key. Add that key to Thijooree, and to Microsoft Authenticator too if you want codes in both apps.
> [!IMPORTANT]
> Resetting replaces the old seed. The existing BML entry in Microsoft Authenticator stops working, so you have to add the new key to it again.
## How to do it
1. Follow [Set up BML](01-setup-bml.md) up to **step 5**, where you copy the secret key.
2. Add the key to Microsoft Authenticator using the steps below.
3. Go back to [Set up BML](01-setup-bml.md) and continue from **step 6**.
> [!WARNING]
> Add the key to Microsoft Authenticator **before step 7** (Verify Code). After you verify, BML stops showing the secret key and you would have to reset again.
## Add the key to Microsoft Authenticator
<table>
<tr>
<th width="20%">Step 1</th>
<th width="20%">Step 2</th>
<th width="20%">Step 3</th>
<th width="20%">Step 4</th>
<th width="20%">Done</th>
</tr>
<tr>
<td align="center"><img src="screenshots/msauth_1.jpg" alt="Microsoft Authenticator home screen with the QR code button highlighted" width="160"></td>
<td align="center"><img src="screenshots/msauth_2.jpg" alt="Scan QR Code screen with the Enter code manually button highlighted" width="160"></td>
<td align="center"><img src="screenshots/msauth_3.jpg" alt="Add account screen with Other account highlighted" width="160"></td>
<td align="center"><img src="screenshots/msauth_4.jpg" alt="Add account form with account name and secret key filled in" width="160"></td>
<td align="center"><img src="screenshots/msauth_5.jpg" alt="Bank of Maldives account in the Microsoft Authenticator list showing a 6-digit code" width="160"></td>
</tr>
<tr>
<td valign="top">
<b>Add an account</b><br>
Open Microsoft Authenticator and tap the <b>QR code button</b> in the bottom-right corner.
</td>
<td valign="top">
<b>Enter the code manually</b><br>
BML is on the same phone, so there is nothing to scan. Tap <b>Enter code manually</b>.
</td>
<td valign="top">
<b>Choose the account type</b><br>
Tap <b>Other account (Google, Facebook, etc.)</b>.
</td>
<td valign="top">
<b>Paste the secret key</b><br>
Enter an <b>Account Name</b> such as <i>Bank of Maldives</i>, paste the key from BML into <b>Secret Key</b> and tap <b>Finish</b>.
</td>
<td valign="top">
<b>Account added</b><br>
The account now shows a 6-digit code. It matches the code in Thijooree because both use the same key.
</td>
</tr>
</table>
Now go back to [Set up BML](01-setup-bml.md) and continue from **step 6**. In step 7 you can verify with the code from either Thijooree or Microsoft Authenticator.
For MIB, see [Set up MIB](02-setup-mib.md).
@@ -0,0 +1,52 @@
# Export from Bitwarden
Bitwarden stores the authenticator key of a login in plain text. Copy it from the login's edit screen and paste it into Thijooree to get the same OTP seed, without resetting anything with your bank.
> [!NOTE]
> Copying the key doesn't change your seed. Bitwarden keeps working, and it shows the same codes as Thijooree.
<table>
<tr>
<th width="25%">Step 1</th>
<th width="25%">Step 2</th>
<th width="25%">Step 3</th>
<th width="25%">Step 4</th>
</tr>
<tr>
<td align="center"><img src="screenshots/bitwarden_1.jpg" alt="Bitwarden View login screen for Bank of Maldives with the edit button highlighted" width="200"></td>
<td align="center"><img src="screenshots/bitwarden_2.jpg" alt="Bitwarden Edit login screen with the copy button next to the Authenticator key highlighted" width="200"></td>
<td align="center"><img src="screenshots/thijooree_6.jpg" alt="Thijooree sign-in screen with the OTP seed filled in and the current OTP shown" width="200"></td>
<td align="center"><img src="screenshots/bitwarden_3.jpg" alt="Bitwarden View login screen with the Authenticator key code highlighted, matching Thijooree" width="200"></td>
</tr>
<tr>
<td valign="top">
<b>Edit your bank login</b><br>
In Bitwarden, open the login for your bank and tap the <b>edit button</b> in the bottom-right corner.
</td>
<td valign="top">
<b>Copy the authenticator key</b><br>
Tap the <b>copy button</b> next to <b>Authenticator key</b>.
</td>
<td valign="top">
<b>Add the seed to Thijooree</b><br>
Open Thijooree and paste the key into <b>OTP Seed (TOTP Secret)</b>. The <b>Current OTP</b> appears below it.
</td>
<td valign="top">
<b>Check that the codes match</b><br>
Go back to Bitwarden, close the edit screen without saving and compare the <b>Authenticator key</b> code with the <b>Current OTP</b> in Thijooree. They should be the same, because both use the same seed.
</td>
</tr>
</table>
## Log in to Thijooree
Once the codes match, in Thijooree:
1. Enter your bank **Username** and **Password**.
2. Tap **Login**.
> [!TIP]
> The OTP changes every 30 seconds. If the codes don't match, wait for both to refresh and compare again. If they still differ, copy the key from Bitwarden again and make sure you opened the right login.
> [!WARNING]
> The authenticator key gives full access to your OTP codes. Don't share it or paste it anywhere else.
+28
View File
@@ -0,0 +1,28 @@
# TOTP Seed
## What is a TOTP seed?
A TOTP (Time-based One-Time Password) seed is the secret key your bank gives you when you set up its authenticator. It looks like a string of capital letters and numbers such as `JBSWY3DPEHPK3PXP`, or comes inside a QR code as an `otpauth://` link.
## Thijooree needs the seed to:
- Enable OTP-less Transactions.
- Show your OTP codes (to use official bank app or website along with Thijooree.)
To keep transactions secure, you can have Thijooree ask for your biometrics instead of an OTP. \
Keep your seed private: anyone who has it can make your OTP codes.
## How do I get my TOTP seed?
You get your seed in one of two ways:
### Setup
| [<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
| [<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) |
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Some files were not shown because too many files have changed in this diff Show More