Compare commits

..
Author SHA1 Message Date
shihaam d4805219f4 new version number because fayaz broke build 2026-10-03 01:50:27 +05:00
shihaam 4c680ad22c remove unused transfer_select_service translation 2026-10-03 01:49:59 +05:00
shihaam 494a42c1a8 build on versionname change 2026-10-03 01:43:25 +05:00
shihaam 9ace8dd724 release v1.0.34 2026-10-03 01:40:43 +05:00
shihaam a3449167db fix build issue applyfahipaycontact 2026-10-03 01:39:19 +05:00
shihaam 1612e21471 Revert "release v1.0.34"
This reverts commit f02b05b180.
2026-10-03 01:37:51 +05:00
shihaam f02b05b180 release v1.0.34 2026-10-03 01:33:22 +05:00
shihaam 5abd981096 save trsnaction type to history for reload, raastas and bill pay to autoselct card 2026-10-03 01:32:39 +05:00
shihaam bb76f4e591 ooredoo billpay via bml card 2026-10-03 01:27:29 +05:00
shihaam 8795d5f758 ooredoo raastas via bml card 2026-10-03 01:08:33 +05:00
quillfires 0d4f0074c7 feat: add Dhivehi localization
Add a complete Dhivehi translation for the app's user-facing strings.

- Add the `values-b+dv/strings.xml` resource
- Localize onboarding, login, security, navigation, dashboard, transfers, contacts, financing, cards, settings, and related UI
- Adapt wording naturally for Dhivehi rather than using literal English translations
- Preserve existing string keys, format arguments, and Android resource structure
2026-10-03 01:05:40 +05:00
shihaam 237d25af67 add dhiraagu bill pay 2026-10-03 00:26:00 +05:00
shihaam a0c515103a update docs 2026-10-02 23:31:07 +05:00
shihaam 56b464b58e add support for reload via BML veried cards 2026-10-02 23:30:55 +05:00
shihaam 9ab7f979fa add support for reload via BML veried cards 2026-10-02 23:29:28 +05:00
shihaam 7a4b7a1712 add support for reload, raastas, ooredoo and dhiraagu bill pay via FahiPay 2026-10-02 22:04:05 +05:00
shihaam c0944d3809 prep for reload/raastas part 3: add GST notice, max and min values and make contact picker and contacts page behave correctly for fahipay contacts 2026-10-02 19:51:52 +05:00
shihaam b05ee44715 prep for reload/raastas part 2 2026-10-02 19:12:21 +05:00
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
shihaam e717360d00 Merge pull request 'fix language toggle alignment' from azuwlaa/android:fix/language-toggle-alignment 2026-09-25 17:53:03 +05:00
shihaam 2f26599931 fix language toggle misaligned on onboarding screen #58 2026-09-25 17:50:17 +05:00
azuwlaaandClaude Opus 5.5 f8f9d82440 fix language toggle buttons misaligned
The Dhivehi button sat a few pixels lower than English because the
toggle group aligns children by text baseline and Thaana has different
font metrics. Turn off baseline alignment for the language toggle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FQUrmJjrypeoPsubpzuezC
2026-09-25 17:35:45 +05:00
178 changed files with 8115 additions and 3849 deletions
+3
View File
@@ -17,6 +17,9 @@ docs/mibapi/tmp
docs/bmlapi/tmp
docs/fahipayapi/tmp
docs/mfaisaapi/tmp
docs/dhiraaguapi/tmp
docs/ooredooapi/tmp
docs/ooredooapi/tmp
tmp
app/key.jks
.kotlin/*
+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 = 35
versionName = "1.0.35"
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,365 @@
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 sh.sar.basedbank.api.bml.BmlMerchantTxnClient.Companion.PAGE_ORIGIN
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. A decline after 3-D Secure (e.g. insufficient
* funds) doesn't show up there: it's a new `paymentErrorHistory` entry on the transaction,
* checked alongside.
* 6. Return to the merchant: `GET <txn>?wait=1` redirects to the merchant's `redirectUrl` with a
* signed `state=CONFIRMED` — the browser's last hop, and how merchants (Dhiraagu, Ooredoo)
* learn they were paid. Without it the card is charged but the merchant never delivers.
*
* 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 {
/** Paid. [merchantNotified] is false when the return to the merchant (step 6) failed. */
data class Success(val merchantNotified: Boolean) : 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
val txnClient = BmlMerchantTxnClient()
val browserId = runCatching { txnClient.announceBrowser(txnId) }.getOrNull()
// Earlier attempts' declines are already in the history; only newer ones are ours
val priorErrors = browserId?.let { id -> runCatching { txnClient.paymentErrors(txnId, id).size }.getOrNull() }
// 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 confirmed(txnId)
"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 confirmed(txnId)
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
}
if (browserId != null && priorErrors != null) {
runCatching { txnClient.paymentErrors(txnId, browserId) }.getOrNull()
?.drop(priorErrors)?.lastOrNull()
?.let { return Result.Failure(it.message.ifBlank { "The bank declined the payment" }) }
}
Thread.sleep(POLL_MS / 2)
}
return Result.Failure("Payment status unknown — check with the merchant before retrying")
}
/** The payment went through: return to the merchant, then report success either way. */
private fun confirmed(txnId: String) = Result.Success(merchantNotified = returnToMerchant(txnId))
/**
* What the browser does once the payment page sees the confirmation: loads `<txn>?wait=1`,
* which 302s to the merchant's `redirectUrl` (`…?transactionId=<id>&state=CONFIRMED&signature=…`)
* and on to its receipt page. Some merchants' callback page instead auto-submits a form on
* load (Ooredoo's posts the result on to its own site), so those forms are submitted too.
* Retries a couple of times; true when the chain ended on a 2xx page.
*/
private fun returnToMerchant(txnId: String): Boolean {
repeat(RETURN_ATTEMPTS) { attempt ->
if (attempt > 0) Thread.sleep(RETURN_RETRY_MS)
val ok = runCatching {
var request = browserNav(Request.Builder().url("$PAGE_ORIGIN/$txnId?wait=1"))
for (hop in 0..MAX_AUTO_SUBMITS) {
val (code, html, url) = client.newCall(request).execute().use {
Triple(it.code, it.body?.string().orEmpty(), it.request.url.toString())
}
if (code !in 200..299) return@runCatching false
// A page that only exists to post itself onward, like the ACS's own hops
if (hop == MAX_AUTO_SUBMITS || !AUTO_SUBMIT.containsMatchIn(html)) return@runCatching true
val form = AcsForm.parse(html, url) ?: return@runCatching true
request = browserNav(form.toRequest().newBuilder())
}
true
}.getOrDefault(false)
if (ok) return true
}
return false
}
private fun browserNav(builder: Request.Builder): Request = builder
.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()
/** 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.
// A rejected code comes back as the OTP page again with "The OTP code you entered is
// incorrect Please try again."
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 (!otpRejected(html)) break
retry = true
}
if (otpRejected(html)) return Result.Failure("The bank rejected the BML token code. Check the phone's clock and try again.")
// 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
}
private fun otpRejected(html: String) = html.contains("name=\"otpValue\"") &&
(html.contains("incorrect", true) || html.contains("expired", true))
// ── 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
private const val RETURN_ATTEMPTS = 3
private const val RETURN_RETRY_MS = 2_000L
/** Auto-submitting merchant pages followed on the way back; Ooredoo has one. */
private const val MAX_AUTO_SUBMITS = 2
/** `<body onload="document.forms['x'].submit()">` and the like. */
private val AUTO_SUBMIT = Regex("""onload\s*=\s*("[^"]*|'[^']*)\.submit\(\)""", RegexOption.IGNORE_CASE)
}
}
@@ -0,0 +1,185 @@
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.
* Returns the browser id, for [paymentErrors].
*/
fun announceBrowser(transactionId: String): String {
val browserId = "${transactionId}_${System.currentTimeMillis()}"
patch(transactionId, JSONObject().put("activeBrowserId", browserId))
patch(transactionId, JSONObject().put("fx", "reset"))
return browserId
}
/**
* The transaction's failed payment attempts, oldest first, read with the page's load PATCH
* as [browserId]. A declined card (e.g. `INSUFFICIENT_FUNDS`) lands here while the
* transaction stays payable — `state` doesn't change, `hasError` turns true.
*/
fun paymentErrors(transactionId: String, browserId: String): List<PaymentError> {
val history = patch(transactionId, JSONObject().put("activeBrowserId", browserId))
.optJSONArray("paymentErrorHistory") ?: return emptyList()
return (0 until history.length()).mapNotNull { history.optJSONObject(it) }.map {
PaymentError(
code = it.optString("code"),
message = it.optString("customerVisibleDescription").ifBlank { it.optString("reason") }
)
}
}
/** One entry of `paymentErrorHistory`: the gateway's code and the wording BML shows for it. */
data class PaymentError(val code: String, val message: String)
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()
}
}
}
@@ -0,0 +1,210 @@
package sh.sar.basedbank.api.dhiraagu
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.Locale
import java.util.concurrent.TimeUnit
/**
* Dhiraagu prepaid reload ("Easy TopUp") and bill payment ("Easy Pay") through dhiraagu.com.mv,
* paid by card on BML's merchant gateway. Dhiraagu only builds the order; the money moves on the
* BML Merchant Services transaction handed back, which is paid like any card-only BML merchant
* link. See `docs/dhiraaguapi/02-reload.md` and `docs/dhiraaguapi/03-bill-pay.md`.
*
* Every call blocks, so run it on an IO thread.
*/
class DhiraaguPaymentClient {
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
/**
* Creates the reload order for [number] and the BML transaction paying for it: cart →
* merchant (the BML gateway entry) → payment → BML transaction. [amount] is the whole MVR
* amount paid, GST included. Returns the 24-hex BML transaction id. Throws with Dhiraagu's
* wording when a step is refused.
*/
fun createReloadTransaction(number: String, amount: Int): String {
val topupNonce = pageNonce("$BASE/services/easy-topup")
val gst = gstOf(amount)
val cart = api("cart", "recharge", topupNonce, JSONObject()
.put("formId", FORM_RELOAD)
.put("serviceNumber", number)
.put("amount", amount)
.put("amountGST", gst.toDouble())
.put("amountRecharge", (BigDecimal(amount) - gst).toDouble())
.put("gstRate", GST_RATE)
.put("memberId", "").put("memberName", "").put("memberNId", "")
.put("customerId", "").put("customerCode", "")
.put("version", 2))
return payCart(FORM_RELOAD, cart, BigDecimal(amount))
}
/**
* Creates the bill payment order for postpaid [number] and the BML transaction paying for
* it: lookup (for the billing account) → cart → merchant → payment → BML transaction.
* [amount] is MVR, up to 2 decimal places. Returns the 24-hex BML transaction id. Throws
* with Dhiraagu's wording when a step is refused.
*/
fun createBillPayTransaction(number: String, amount: BigDecimal): String {
val easyPayNonce = pageNonce("$BASE/services/easy-pay")
// The cart needs the billing account the number belongs to, which only the lookup gives
val info = call("dhiraaguIO", "infoUnlisted", easyPayNonce, JSONObject().put("number", number))
if (info.optJSONArray("serviceDetails")?.optJSONObject(0)?.optString("prepaidIndicator") == "Y") {
throw Exception("Prepaid number is not allowed.")
}
val accountNumber = info.optString("accountNumber").ifBlank { throw Exception("Invalid account/service number") }
// The page refuses some account statuses and customer types before ordering; so do we
val rules = runCatching { get("setting", "bill", easyPayNonce).getJSONObject("resp").getJSONObject("settingAppJson1") }.getOrNull()
val status = info.optString("accountStatus")
if (rules != null && status in rules.blockedValues("accountStatus")) {
throw Exception("Dhiraagu can't accept payment for this service. Contact Dhiraagu customer service. [Account Status: $status]")
}
val customerType = info.optString("customerType")
if (rules != null && customerType in rules.blockedValues("customerType")) {
throw Exception("The number is not allowed. [Customer Type: $customerType]")
}
val cart = api("cart", "easyPay", easyPayNonce, JSONObject()
.put("formId", FORM_BILL)
.put("serviceNumber", number)
.put("accountNumber", accountNumber)
.put("amount", money(amount))
.put("memberId", "").put("memberName", "").put("memberNId", "")
.put("billRef", "")
.put("billType", if (info.optString("type") == BILL_WRITE_OFF) BILL_WRITE_OFF else BILL_PAYMENT))
return payCart(FORM_BILL, cart, amount)
}
/**
* The payment page's half, shared by every form: picks the BML gateway, creates the payment
* for [cart] and has Dhiraagu create the BML transaction. Returns its 24-hex id.
*/
private fun payCart(formId: Int, cart: JSONObject, amount: BigDecimal): String {
val cartId = cart.optString("cartId").ifBlank { throw Exception("Dhiraagu didn't create the order") }
// The payment page carries its own nonce, used for the rest of the order
val paymentNonce = pageNonce("$BASE/services/payment-v2?cartid=$cartId")
val merchants = apiList("merchant", "form", paymentNonce, JSONObject().put("formId", formId))
val bml = (0 until merchants.length()).map { merchants.getJSONObject(it) }
.firstOrNull { it.optInt("gatewayId") == GATEWAY_BML }
?: throw Exception("Dhiraagu isn't taking BML card payments right now")
val payment = api("payment", "create", paymentNonce, JSONObject()
.put("formId", formId)
.put("cartId", cartId)
.put("gatewayId", GATEWAY_BML)
.put("dhiraaguPayNumber", "")
.put("amount", money(amount))
.put("paymentMerchantId", bml.getString("merchantId"))
.put("memberId", "").put("tokenize", "").put("paymentType", "")
.put("recurringFrequency", "").put("bmlTokenId", ""))
val paymentId = payment.optString("paymentId").ifBlank { throw Exception("Dhiraagu didn't create the payment") }
val txn = api("bml", "createV2", paymentNonce, JSONObject().put("paymentId", paymentId))
return TXN_URL.find(txn.optString("url"))?.groupValues?.get(1)
?: throw Exception("BML didn't create the transaction")
}
private fun money(amount: BigDecimal) =
String.format(Locale.US, "%.2f", amount.setScale(2, RoundingMode.HALF_UP))
/** The `val` list of a `setting` rule, e.g. `{"accountStatus":{"val":["F"]}}`. */
private fun JSONObject.blockedValues(rule: String): Set<String> {
val vals = optJSONObject(rule)?.optJSONArray("val") ?: return emptySet()
return (0 until vals.length()).map { vals.optString(it) }.toSet()
}
// ── HTTP ─────────────────────────────────────────────────────────────────
/** Every page embeds a `var nonce = "…"` that its API calls send as the `nonce` header. */
private fun pageNonce(url: String): String =
NONCE.find(page(url))?.groupValues?.get(1) ?: throw Exception("Dhiraagu page didn't load")
private fun page(url: String): String = client.newCall(
Request.Builder().url(url)
.header("User-Agent", UA)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.build()
).execute().use { r ->
if (r.code in 500..599) throw BankServerException("Dhiraagu")
r.body?.string().orEmpty()
}
private fun api(sub: String, act: String, nonce: String, body: JSONObject): JSONObject =
call(sub, act, nonce, body).getJSONObject("resp")
private fun apiList(sub: String, act: String, nonce: String, body: JSONObject): JSONArray =
call(sub, act, nonce, body).getJSONArray("resp")
/** POSTs to `sdk-dhr-webapi.ashx`; throws unless `respStatus` is OK. */
private fun call(sub: String, act: String, nonce: String, body: JSONObject): JSONObject =
send(sub, act, nonce, body.toString().toRequestBody(JSON))
/** GETs from `sdk-dhr-webapi.ashx` (the settings calls); throws unless `respStatus` is OK. */
private fun get(sub: String, act: String, nonce: String): JSONObject = send(sub, act, nonce, null)
private fun send(sub: String, act: String, nonce: String, body: okhttp3.RequestBody?): JSONObject {
val text = client.newCall(
Request.Builder().url("$API?website_id=$WEBSITE_ID&sub=$sub&act=$act")
.apply { if (body != null) post(body) }
.header("User-Agent", UA)
.header("Accept", "application/json, text/javascript, */*; q=0.01")
.header("X-Requested-With", "XMLHttpRequest")
.header("Origin", BASE)
.header("nonce", nonce)
.build()
).execute().use { r ->
if (r.code in 500..599) throw BankServerException("Dhiraagu")
r.body?.string().orEmpty()
}
val obj = try { JSONObject(text) } catch (_: Exception) {
throw Exception("Unexpected response from Dhiraagu")
}
if (obj.optString("respStatus") != "OK") {
throw Exception(obj.optString("respMsg").ifBlank { obj.optString("resp") }.ifBlank { "Dhiraagu refused the payment" })
}
return obj
}
companion object {
private const val BASE = "https://www.dhiraagu.com.mv"
private const val API = "$BASE/api/sdk-dhr-webapi.ashx"
private const val WEBSITE_ID = "CA2BB809-3A22-485B-A518-DA6B6DE653A5"
private const val UA = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
private val JSON = "application/json".toMediaType()
private val NONCE = Regex("""var nonce = "([^"]+)"""")
private val TXN_URL = Regex("""transaction\.merchants\.bankofmaldives\.com\.mv/([0-9a-fA-F]{24})""")
/** Easy Pay's (bill payment) form id across cart / merchant / payment. */
private const val FORM_BILL = 1
/** Easy TopUp's form id across cart / merchant / payment. */
private const val FORM_RELOAD = 2
/** Bank of Maldives in `merchant&act=form` (1 = BML, 2 = MIB, 3 = DhiraaguPay). */
private const val GATEWAY_BML = 1
private const val GST_RATE = 0.08
/** Easy Pay's `billType`s; the lookup's `type` says which applies. */
private const val BILL_PAYMENT = "BillPayment"
private const val BILL_WRITE_OFF = "writeOffPayments"
/**
* The GST inside a GST-inclusive reload [amount], as the page works it out:
* `amount × rate / (1 + rate)`, to 2 places. The number is credited `amount − gst`.
*/
fun gstOf(amount: Int): BigDecimal {
val rate = BigDecimal(GST_RATE.toString())
return (BigDecimal(amount) * rate).divide(BigDecimal.ONE + rate, 2, RoundingMode.HALF_UP)
}
}
}
@@ -0,0 +1,47 @@
package sh.sar.basedbank.api.fahipay
import android.os.Build
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody
import okio.Buffer
/** Form-body helpers shared by the Fahipay POST endpoints (login, OTP, payments). */
internal object FahipayForm {
/** The `device[...]` fields every Fahipay POST carries. */
fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
"device[available]" to "true",
"device[platform]" to "Android",
"device[uuid]" to deviceUuid,
"device[model]" to Build.MODEL,
"device[manufacturer]" to Build.MANUFACTURER,
"device[isVirtual]" to "false",
"device[serial]" to "unknown"
)
/**
* Builds a multipart/form-data body with lowercase "content-disposition" headers,
* which is what the Fahipay server requires.
*/
fun body(vararg parts: Pair<String, String>): RequestBody {
val boundary = java.util.UUID.randomUUID().toString()
val buf = Buffer()
for ((name, value) in parts) {
val valueBytes = value.toByteArray(Charsets.UTF_8)
buf.writeUtf8("--$boundary\r\n")
buf.writeUtf8("content-disposition: form-data; name=\"$name\"\r\n")
buf.writeUtf8("Content-Length: ${valueBytes.size}\r\n")
buf.writeUtf8("\r\n")
buf.write(valueBytes)
buf.writeUtf8("\r\n")
}
buf.writeUtf8("--$boundary--\r\n")
val snapshot = buf.readByteString()
val mediaType = "multipart/form-data; boundary=$boundary".toMediaType()
return object : RequestBody() {
override fun contentType() = mediaType
override fun contentLength() = snapshot.size.toLong()
override fun writeTo(sink: okio.BufferedSink) { sink.write(snapshot) }
}
}
}
@@ -4,11 +4,8 @@ import android.os.Build
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okio.Buffer
import org.json.JSONObject
import java.security.SecureRandom
import java.util.concurrent.TimeUnit
@@ -74,14 +71,14 @@ class FahipayLoginFlow {
*/
fun login(idCard: String, password: String, deviceUuid: String): FahipayLoginStep {
initSession()
val body = buildFormBody(
val body = FahipayForm.body(
"email" to idCard,
"password" to password,
"grant_type" to "auth_id",
"lang" to "en",
"version" to "2.0.0",
"platform" to "BasedBank",
*deviceParts(deviceUuid)
"platform" to "thijooree",
*FahipayForm.deviceParts(deviceUuid)
)
val resp = client.newCall(
@@ -109,15 +106,15 @@ class FahipayLoginFlow {
* Returns authId.
*/
fun verifyTotp(code: String, deviceUuid: String): String {
val body = buildFormBody(
val body = FahipayForm.body(
"code" to code,
"channel" to "totp",
"action" to "login",
"grant_type" to "auth_id",
"lang" to "en",
"version" to "2.0.0",
"platform" to "BasedBank",
*deviceParts(deviceUuid)
"platform" to "thijooree",
*FahipayForm.deviceParts(deviceUuid)
)
val resp = client.newCall(
@@ -138,42 +135,6 @@ class FahipayLoginFlow {
?: throw Exception("No authID in OTP response")
}
private fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
"device[available]" to "true",
"device[platform]" to "Android",
"device[uuid]" to deviceUuid,
"device[model]" to Build.MODEL,
"device[manufacturer]" to Build.MANUFACTURER,
"device[isVirtual]" to "false",
"device[serial]" to "unknown"
)
/**
* Builds a multipart/form-data body with lowercase "content-disposition" headers,
* which is what the Fahipay server requires.
*/
private fun buildFormBody(vararg parts: Pair<String, String>): RequestBody {
val boundary = java.util.UUID.randomUUID().toString()
val buf = Buffer()
for ((name, value) in parts) {
val valueBytes = value.toByteArray(Charsets.UTF_8)
buf.writeUtf8("--$boundary\r\n")
buf.writeUtf8("content-disposition: form-data; name=\"$name\"\r\n")
buf.writeUtf8("Content-Length: ${valueBytes.size}\r\n")
buf.writeUtf8("\r\n")
buf.write(valueBytes)
buf.writeUtf8("\r\n")
}
buf.writeUtf8("--$boundary--\r\n")
val snapshot = buf.readByteString()
val mediaType = "multipart/form-data; boundary=$boundary".toMediaType()
return object : RequestBody() {
override fun contentType() = mediaType
override fun contentLength() = snapshot.size.toLong()
override fun writeTo(sink: okio.BufferedSink) { sink.write(snapshot) }
}
}
companion object {
fun generateDeviceUuid(): String {
val bytes = ByteArray(8)
@@ -0,0 +1,71 @@
package sh.sar.basedbank.api.fahipay
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import java.util.concurrent.TimeUnit
/**
* Pays a phone number from the Fahipay wallet: Ooredoo Raastas, Ooredoo bill pay, Dhiraagu
* reload and Dhiraagu bill pay. All four are the same POST, only the path differs — see
* `docs/fahipayapi/09-payments.md`.
*/
class FahipayPaymentClient {
private val BASE_URL = "https://fahipay.mv"
private val UA = "okhttp/4.12.0"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
/**
* A payment the server accepted. [message] is its wording, e.g. "Transaction successful." or,
* for Dhiraagu bill pay, "Transaction will be processed shortly.". [transactionId] (`tid`) is
* only returned by the reload / Raastas endpoints.
*/
data class Result(val message: String, val transactionId: String?)
/**
* POSTs the payment to [path] (e.g. `actions/payment/ooredoo/recharge/`). [amount] is sent as
* typed — the caller checks the service's limits first. Returns on success; throws with the
* server's message when it refuses, [BankServerException] on a 5xx, and IOException when the
* request doesn't get through. Blocking — call from IO.
*/
fun pay(session: FahipaySession, path: String, number: String, amount: String, deviceUuid: String): Result {
val body = FahipayForm.body(
"number" to number,
"amount" to amount,
"lang" to "en",
"version" to "2.0.2",
"build" to "329",
"platform" to "thijooree",
*FahipayForm.deviceParts(deviceUuid)
)
val resp = client.newCall(
Request.Builder().url("$BASE_URL/$path")
.post(body)
.header("authid", session.authId)
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
.header("User-Agent", UA)
.header("accept", "application/json")
.build()
).execute()
val code = resp.code
val json = resp.body?.string().orEmpty()
resp.close()
if (code in 500..599) throw BankServerException("Fahipay")
val obj = try { JSONObject(json) } catch (_: Exception) {
throw Exception("Unexpected response from Fahipay (HTTP $code)")
}
val message = obj.optString("msg").ifBlank { obj.optString("title") }
if (obj.optString("type") != "success") throw Exception(message.ifBlank { "Payment failed" })
return Result(
message = message,
transactionId = obj.optString("tid").takeIf { it.isNotBlank() }
)
}
}
@@ -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,90 @@
package sh.sar.basedbank.api.ooredoo
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.models.BankServerException
import java.math.BigDecimal
import java.math.RoundingMode
import java.util.Locale
import java.util.concurrent.TimeUnit
/**
* Ooredoo prepaid recharge (Raastas) and bill payment through the ooredoo.mv Quick Pay pages,
* paid by card on BML's merchant gateway. Ooredoo only creates the order; the money moves on the
* BML Merchant Services transaction it hands back, which is paid like any card-only BML merchant
* link. See `docs/ooredooapi/02-raastas.md` and `docs/ooredooapi/03-bill-pay.md`.
*
* Every call blocks, so run it on an IO thread.
*/
class OoredooPaymentClient {
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
/**
* Creates the recharge order for [number] (7 digits) and the BML transaction paying for it.
* [amount] is what the number is credited, in whole MVR; the card pays [charged], which is
* that plus GST. Returns the 24-hex BML transaction id. Throws with Ooredoo's wording when
* the order is refused.
*/
fun createRaastasTransaction(number: String, amount: Int, charged: BigDecimal): String =
createOrder(number, charged, amount.toString(), transType = "recharge", serviceType = "prepaid")
/**
* Creates the bill payment order for postpaid [number] (7 digits) and the BML transaction
* paying for it. [amount] is MVR, up to 2 decimal places; no GST. Returns the 24-hex BML
* transaction id. Throws with Ooredoo's wording when the order is refused.
*/
fun createBillPayTransaction(number: String, amount: BigDecimal): String =
createOrder(number, amount, money(amount), transType = "billpay", serviceType = "Mobile")
/** `POST PaymentGateway/bml` — one call makes the order and its BML transaction. */
private fun createOrder(
number: String, charged: BigDecimal, amountWithoutGst: String, transType: String, serviceType: String,
): String {
val msisdn = "960$number"
val body = JSONObject()
.put("msisdn", msisdn)
.put("purchaseAmount", money(charged))
.put("amountWithoutGst", amountWithoutGst)
.put("receiverMsisdn", msisdn)
.put("transType", transType)
.put("serviceType", serviceType)
.put("serviceTypeDisplayName", "Mobile")
val text = client.newCall(
Request.Builder().url("$BASE/ooredoo-prod/PaymentGateway/bml")
.post(body.toString().toRequestBody(JSON))
.header("User-Agent", UA)
.header("Accept", "application/json")
.header("Origin", BASE)
.build()
).execute().use { r ->
if (r.code in 500..599) throw BankServerException("Ooredoo")
r.body?.string().orEmpty()
}
val obj = try { JSONObject(text) } catch (_: Exception) {
throw Exception("Unexpected response from Ooredoo")
}
if (obj.optString("status") != "OK" || obj.optString("code") != "2000") {
throw Exception(obj.optString("msg").ifBlank { "Ooredoo refused the payment" })
}
val data = obj.optJSONObject("data") ?: throw Exception("Ooredoo didn't create the order")
return TXN_URL.find(data.optString("bmlUrl"))?.groupValues?.get(1)
?: throw Exception("BML didn't create the transaction")
}
private fun money(amount: BigDecimal) =
String.format(Locale.US, "%.2f", amount.setScale(2, RoundingMode.HALF_UP))
companion object {
private const val BASE = "https://www.ooredoo.mv"
private const val UA = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
private val JSON = "application/json".toMediaType()
private val TXN_URL = Regex("""transaction\.merchants\.bankofmaldives\.com\.mv/([0-9a-fA-F]{24})""")
}
}
@@ -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
}
}
@@ -1,686 +0,0 @@
package sh.sar.basedbank.ui.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.LinearLayout
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.color.MaterialColors
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.delay
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentChatBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.databinding.ViewChatHeaderBinding
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.ContactListParser
import sh.sar.basedbank.util.ReceiptStore
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.ChatAccount
import sh.sar.basedbank.util.ChatStore
import sh.sar.basedbank.util.ChatThread
/**
* One person's transfers as a chat, or one business's card / Scan to Pay payments.
*
* A person can have several accounts (e.g. MVR and USD); the "To" bar picks which one to send to.
* Sending opens [TransferFragment] in a sheet, prefilled with the recipient, source account,
* amount and note, so the usual confirm/OTP flow still applies. A monthly summary sits on top,
* and long-pressing a bubble offers Send again / receipt / copy.
*/
class ChatFragment : Fragment(), ContactSheetHost {
private var _binding: FragmentChatBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private val app get() = requireActivity().application as BasedBankApp
private var peerKey = ""
private var header: ViewChatHeaderBinding? = null
private val adapter = ChatMessagesAdapter(
onReceiptClick = { openReceipt(it.receiptKey) },
onLongPress = { message, anchor -> showMessageMenu(message, anchor) }
)
private var thread: ChatThread? = null
private var fromAccounts: List<BankAccount> = emptyList()
private var selectedFrom: BankAccount? = null
private var selectedTo: ChatAccount? = null
private var merchantQr: String? = null
private var cardAccounts: List<BankAccount> = emptyList()
private var selectedCard: BankAccount? = null
/** When the last transfer / payment sheet was opened, to tell whether it left a new receipt. */
private var payStartedAt = 0L
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentChatBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
peerKey = savedInstanceState?.getString(ARG_PEER_KEY) ?: requireArguments().getString(ARG_PEER_KEY).orEmpty()
binding.rvMessages.layoutManager = LinearLayoutManager(requireContext()).apply { stackFromEnd = true }
binding.rvMessages.adapter = adapter
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.observe(viewLifecycleOwner) {
adapter.setHideAmounts(it)
thread?.let { t -> bindSummary(t) }
bindFromChip()
}
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
fromAccounts = accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN }
cardAccounts = accounts.filter {
it.bank == "BML" && it.profileType in CARD_TYPES && it.statusDesc.equals("Active", ignoreCase = true)
}
bindFromAccounts()
}
binding.btnFrom.setOnClickListener { if (thread?.isMerchant == true) showCardPicker() else showAccountPicker() }
binding.btnSaveContact.setOnClickListener { openContact() }
binding.toAccountBar.setOnClickListener { showRecipientPicker() }
binding.btnSend.setOnClickListener { send() }
binding.etNote.setOnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_SEND) { send(); true } else false
}
binding.btnPayAgain.setOnClickListener { payBusiness() }
// Transfers started from this chat come straight back here instead of opening the receipt.
parentFragmentManager.setFragmentResultListener(TransferFragment.RESULT_TRANSFER_DONE, viewLifecycleOwner) { _, _ ->
onTransferDone()
}
// A newly saved contact adds an account (and maybe a nickname) to this chat; reload.
viewModel.contacts.observe(viewLifecycleOwner) { loadThread() }
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString(ARG_PEER_KEY, peerKey)
}
private fun loadThread() {
val ctx = requireContext().applicationContext
val contacts = viewModel.contacts.value ?: emptyList()
viewLifecycleOwner.lifecycleScope.launch {
val threads = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) }
val knownIds = thread?.messages?.mapTo(HashSet()) { it.id }.orEmpty()
val t = threads.firstOrNull { it.peerKey == peerKey }
?: threads.firstOrNull { th -> th.messages.any { it.id in knownIds } }
if (_binding == null) return@launch
thread = t
if (t == null) return@launch
peerKey = t.peerKey
adapter.showDestination = t.accounts.size > 1
adapter.setMessages(t.messages, getString(R.string.chat_today), getString(R.string.chat_yesterday))
binding.rvMessages.scrollToPosition(adapter.itemCount - 1)
bindSummary(t)
bindFromAccounts()
val previous = selectedTo
val to = t.accounts.firstOrNull { it.account == previous?.account }
?: t.accounts.firstOrNull { it.account == t.peerAccount }
selectTo(to, matchFromCurrency = previous == null)
updateToolbar()
}
}
/** Picks which of the person's accounts to send to and updates everything that depends on it. */
private fun selectTo(to: ChatAccount?, matchFromCurrency: Boolean) {
val b = _binding ?: return
val t = thread ?: return
selectedTo = to
if (t.isMerchant) {
// Businesses paid by card / Scan to Pay: history only, nothing to send to or save.
b.toAccountBar.visibility = View.GONE
b.saveContactBar.visibility = View.GONE
b.composer.visibility = View.GONE
b.tvCannotSend.visibility = View.GONE
b.btnPayAgain.visibility = View.VISIBLE
bindMerchantQr(t)
return
}
b.btnPayAgain.visibility = View.GONE
// One account: it's shown in the header. Several: this bar picks between them.
b.toAccountBar.visibility = if (to == null || t.accounts.size < 2) View.GONE else View.VISIBLE
if (to != null) {
b.tvToAccount.text = to.account
b.tvToCurrency.text = to.currency
b.tvToCurrency.visibility = if (to.currency.isBlank()) View.GONE else View.VISIBLE
val nickname = to.contact?.benefNickName?.takeIf { it.isNotBlank() && !it.equals(t.peerName, ignoreCase = true) }
b.tvToNickname.text = nickname
b.tvToNickname.visibility = if (nickname == null) View.GONE else View.VISIBLE
b.ivToSwitch.visibility = if (t.accounts.size > 1) View.VISIBLE else View.GONE
}
// Offer to save the selected account (or the person, when no account is known yet).
b.saveContactBar.visibility = if (to?.contact == null) View.VISIBLE else View.GONE
val canSend = to != null
b.composer.visibility = if (canSend) View.VISIBLE else View.GONE
b.tvCannotSend.visibility = if (canSend) View.GONE else View.VISIBLE
// Sending to a USD account: default to a USD source account, and likewise for MVR.
if (matchFromCurrency && to != null && to.currency.isNotBlank() &&
!selectedFrom?.currencyName.equals(to.currency, ignoreCase = true)) {
fromAccounts.firstOrNull { it.currencyName.equals(to.currency, ignoreCase = true) }?.let(::selectFrom)
}
updateCurrencyWarning()
}
private fun showRecipientPicker() {
val t = thread ?: return
if (t.accounts.size < 2) return
val ctx = requireContext()
val dialog = BottomSheetDialog(ctx)
val list = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val pad = (8 * resources.displayMetrics.density).toInt()
setPadding(0, pad, 0, pad * 3)
}
list.addView(sheetTitle(getString(R.string.chat_pick_recipient_account)))
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
for (account in t.accounts) {
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
row.tvDropdownAccountName.text = account.contact?.benefNickName?.takeIf { it.isNotBlank() }
?: getString(R.string.chat_not_in_contacts)
row.tvDropdownAccountNumber.text = account.account
row.tvDropdownBalance.text = account.currency
row.ivDropdownCardLogo.visibility = View.GONE
// A USD account can only be paid from a USD account.
val unavailable = account.currency.equals("USD", ignoreCase = true) && !hasUsdSource()
row.tvDropdownAccountType.text = getString(R.string.chat_needs_usd_source)
row.tvDropdownAccountType.visibility = if (unavailable) View.VISIBLE else View.GONE
row.root.alpha = if (unavailable) 0.4f else 1f
if (account.account == selectedTo?.account) row.root.setBackgroundColor(selectedBg)
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
if (!unavailable) row.root.setOnClickListener {
selectTo(account, matchFromCurrency = true)
updateToolbar()
dialog.dismiss()
}
list.addView(row.root)
}
dialog.setContentView(list)
dialog.show()
}
private fun sheetTitle(text: String) = TextView(requireContext()).apply {
this.text = text
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_TitleMedium)
val pad = (16 * resources.displayMetrics.density).toInt()
setPadding(pad, pad / 2, pad, pad / 2)
}
private fun bindFromAccounts() {
val b = _binding ?: return
if (fromAccounts.isEmpty()) return
// Default to the account last used with this person.
val lastUsed = thread?.messages?.lastOrNull { it.isSent }?.accountNumber
val current = selectedFrom?.takeIf { s -> fromAccounts.any { it.accountNumber == s.accountNumber } }
selectedFrom = current ?: fromAccounts.firstOrNull { it.accountNumber == lastUsed } ?: fromAccounts.first()
selectFrom(selectedFrom!!)
}
private fun onTransferDone() {
childFragmentManager.findFragmentByTag(TAG_HIDDEN_TRANSFER)?.let {
childFragmentManager.beginTransaction().remove(it).commitAllowingStateLoss()
}
binding.etAmount.text = null
binding.etNote.text = null
// The receipt shows the new bubble right away; history catches up a little later and
// replaces it with the bank's own entry.
sync()
viewLifecycleOwner.lifecycleScope.launch {
delay(HISTORY_CATCH_UP_MS)
sync()
}
// Only offer the receipt this payment made; QR payments don't save one.
val receipt = ReceiptStore.loadAll(requireContext()).firstOrNull()?.takeIf { it.savedAt >= payStartedAt }
val merchant = thread?.isMerchant == true
Snackbar.make(binding.root, if (merchant) R.string.chat_payment_sent else R.string.chat_transfer_sent, Snackbar.LENGTH_LONG)
.setAnchorView(if (binding.composer.visibility == View.VISIBLE) binding.composer else binding.btnPayAgain)
.apply { if (receipt != null) setAction(R.string.chat_view_receipt) { openReceipt(receipt.savedAt.toString()) } }
.show()
}
private fun openReceipt(receiptKey: String) {
val entry = ReceiptStore.loadAll(requireContext()).firstOrNull { it.savedAt.toString() == receiptKey }
if (entry == null) {
Snackbar.make(binding.root, R.string.chat_receipt_missing, Snackbar.LENGTH_SHORT).show()
return
}
(requireActivity() as HomeActivity).showWithBackStack(TransferReceiptFragment.newInstance(entry.data, null))
}
private fun sync() {
val ctx = requireContext().applicationContext
val accounts = viewModel.accounts.value ?: emptyList()
val contacts = viewModel.contacts.value ?: emptyList()
viewLifecycleOwner.lifecycleScope.launch {
withContext(Dispatchers.IO) { ChatStore.sync(ctx, app, accounts, contacts) }
if (_binding != null) loadThread()
}
}
private fun selectFrom(account: BankAccount) {
val b = _binding ?: return
selectedFrom = account
bindFromChip()
b.tilAmount.prefixText = account.currencyName
updateCurrencyWarning()
}
/** The chip shows the account (or, in a business chat, the card) paid from, with its balance. */
private fun bindFromChip() {
val b = _binding ?: return
val account = (if (thread?.isMerchant == true) selectedCard else selectedFrom) ?: return
val balance = AccountListParser.from(account)?.balance.orEmpty()
val hide = viewModel.hideAmounts.value ?: false
val money = when {
balance.isBlank() -> account.currencyName
hide -> AccountHistoryAdapter.maskAmount(balance)
else -> balance
}
b.btnFrom.text = getString(R.string.chat_from_chip, account.accountBriefName, account.accountNumber, money)
b.btnFrom.contentDescription = getString(R.string.chat_from_account) + ": " + b.btnFrom.text
// A card shows its network logo up front, in full colour; an account keeps the dropdown arrow.
val logo = if (thread?.isMerchant == true) sh.sar.basedbank.util.bmlapi.BmlCardParser.cardNetworkIcon(account) else null
val density = resources.displayMetrics.density
if (logo != null) {
b.btnFrom.setIconResource(logo)
b.btnFrom.iconTint = null
b.btnFrom.iconGravity = com.google.android.material.button.MaterialButton.ICON_GRAVITY_START
b.btnFrom.iconSize = (28 * density).toInt()
} else {
b.btnFrom.setIconResource(R.drawable.ic_arrow_right)
b.btnFrom.iconTint = android.content.res.ColorStateList.valueOf(
MaterialColors.getColor(b.btnFrom, com.google.android.material.R.attr.colorOnSecondaryContainer))
b.btnFrom.iconGravity = com.google.android.material.button.MaterialButton.ICON_GRAVITY_END
b.btnFrom.iconSize = (16 * density).toInt()
}
}
private fun showCardPicker() {
if (cardAccounts.isEmpty()) return
val ctx = requireContext()
val dialog = BottomSheetDialog(ctx)
val list = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val pad = (8 * resources.displayMetrics.density).toInt()
setPadding(0, pad, 0, pad * 3)
}
list.addView(sheetTitle(getString(R.string.chat_pick_card)))
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
for (card in cardAccounts) {
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
bindAccountRow(row, card)
sh.sar.basedbank.util.bmlapi.BmlCardParser.cardNetworkIcon(card)?.let {
row.ivDropdownCardLogo.setImageResource(it)
row.ivDropdownCardLogo.visibility = View.VISIBLE
}
if (card.accountNumber == selectedCard?.accountNumber) row.root.setBackgroundColor(selectedBg)
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
row.root.setOnClickListener {
selectedCard = card
bindFromChip()
dialog.dismiss()
}
list.addView(row.root)
}
dialog.setContentView(list)
dialog.show()
}
/**
* MVR can't be sent into a USD account: say so and disable Send. USD into an MVR account is
* converted by BML at its rate: allowed, with a warning.
*/
private fun updateCurrencyWarning() {
val b = _binding ?: return
val from = selectedFrom?.currencyName.orEmpty()
val to = selectedTo?.currency.orEmpty()
val blocked = isBlockedPair(from, to)
val converts = from.equals("USD", ignoreCase = true) && to.equals("MVR", ignoreCase = true)
b.tvCurrencyWarning.text = when {
blocked -> getString(R.string.chat_currency_blocked)
converts -> getString(R.string.chat_currency_warning)
else -> null
}
b.tvCurrencyWarning.visibility = if (blocked || converts) View.VISIBLE else View.GONE
b.btnSend.isEnabled = !blocked
}
/** MVR → USD account: BML doesn't allow it. */
private fun isBlockedPair(fromCurrency: String, toCurrency: String) =
fromCurrency.equals("MVR", ignoreCase = true) && toCurrency.equals("USD", ignoreCase = true)
private fun hasUsdSource() = fromAccounts.any { it.currencyName.equals("USD", ignoreCase = true) }
/** Looks up a saved static QR for this business: with one, the button pays it directly. */
private fun bindMerchantQr(t: ChatThread) {
val ctx = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch {
val qr = withContext(Dispatchers.IO) { ChatStore.merchantQr(ctx, t.peerName) }
val b = _binding ?: return@launch
merchantQr = qr
b.btnPayAgain.text = if (qr != null) getString(R.string.chat_pay_business, t.peerName)
else getString(R.string.chat_pay_again)
// A saved QR is paid from a card picked here: show just the card chip above the button.
b.composer.visibility = if (qr != null && cardAccounts.isNotEmpty()) View.VISIBLE else View.GONE
b.amountRow.visibility = View.GONE
b.tvCurrencyWarning.visibility = View.GONE
if (qr != null && selectedCard == null) {
val lastCard = t.messages.lastOrNull { m -> cardAccounts.any { it.accountNumber == m.accountNumber } }?.accountNumber
val defaultCard = CredentialStore(requireContext()).getDefaultCardAccountNumber()
selectedCard = cardAccounts.firstOrNull { it.accountNumber == lastCard }
?: cardAccounts.firstOrNull { it.accountNumber == defaultCard }
?: cardAccounts.firstOrNull()
}
bindFromChip()
}
}
/** Saved static QR: pay it in the sheet over the chat. Otherwise: open the scanner. */
private fun payBusiness() {
val qr = merchantQr
if (qr == null) {
(requireActivity() as HomeActivity).showWithBackStack(TransferFragment.newInstanceWithAutoScan())
return
}
if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return
val transfer = TransferFragment.newInstanceFromBmlQr(qr, selectedCard?.accountNumber, returnOnSuccess = true)
payStartedAt = System.currentTimeMillis()
hideKeyboard()
TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet")
}
/** This month with this person (sent / received / net) or at this business (spent / payments). */
private fun bindSummary(t: ChatThread) {
val b = _binding ?: return
val monthStart = java.util.Calendar.getInstance().apply {
set(java.util.Calendar.DAY_OF_MONTH, 1)
set(java.util.Calendar.HOUR_OF_DAY, 0); set(java.util.Calendar.MINUTE, 0)
set(java.util.Calendar.SECOND, 0); set(java.util.Calendar.MILLISECOND, 0)
}.timeInMillis
val thisMonth = t.messages.filter { it.timeMillis >= monthStart }
if (thisMonth.isEmpty()) { b.summaryCard.visibility = View.GONE; return }
// One currency per card: the selected account's, else the one used most this month.
val currency = selectedTo?.currency?.takeIf { c -> thisMonth.any { it.currency == c } }
?: thisMonth.groupingBy { it.currency }.eachCount().maxByOrNull { it.value }!!.key
val ms = thisMonth.filter { it.currency == currency }
val hide = viewModel.hideAmounts.value ?: false
fun money(v: Double) = if (hide) "$currency ••••" else "$currency ${"%,.2f".format(v)}"
val month = java.text.SimpleDateFormat("MMMM", java.util.Locale.getDefault()).format(java.util.Date())
b.summaryCard.visibility = View.VISIBLE
val sent = ms.filter { it.isSent }.sumOf { -it.amount }
if (t.isMerchant) {
b.tvSumLabel1.text = getString(R.string.chat_sum_spent, month)
b.tvSumValue1.text = money(sent)
b.sumColumn2.visibility = View.GONE
b.tvSumLabel3.text = getString(R.string.chat_sum_payments)
b.tvSumValue3.text = ms.size.toString()
return
}
val received = ms.filter { !it.isSent }.sumOf { it.amount }
val night = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
android.content.res.Configuration.UI_MODE_NIGHT_YES
b.sumColumn2.visibility = View.VISIBLE
b.tvSumLabel1.text = getString(R.string.chat_sum_sent, month)
b.tvSumValue1.text = money(sent)
b.tvSumValue1.setTextColor(MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorError))
b.tvSumLabel2.text = getString(R.string.chat_sum_received)
b.tvSumValue2.text = money(received)
b.tvSumValue2.setTextColor(android.graphics.Color.parseColor(if (night) "#81C784" else "#2E7D32"))
b.tvSumLabel3.text = getString(R.string.chat_sum_net)
val net = received - sent
b.tvSumValue3.text = if (hide) "••••" else (if (net < 0) "−" else "+") + "%,.2f".format(kotlin.math.abs(net))
}
/** Long-press on a bubble: send the same again, open its receipt, or copy its reference. */
private fun showMessageMenu(m: sh.sar.basedbank.util.ChatMessage, anchor: View) {
val t = thread ?: return
val menu = android.widget.PopupMenu(requireContext(), anchor, android.view.Gravity.END)
val amount = "%.2f".format(kotlin.math.abs(m.amount))
val canSendAgain = m.isSent && !t.isMerchant && selectedTo != null
if (canSendAgain) menu.menu.add(0, 1, 0, getString(R.string.chat_send_again, "${m.currency} $amount"))
if (m.receiptKey.isNotBlank()) menu.menu.add(0, 2, 1, R.string.chat_view_receipt_full)
if (m.reference.isNotBlank()) menu.menu.add(0, 3, 2, R.string.chat_copy_reference)
if (menu.menu.size() == 0) return
menu.setOnMenuItemClickListener { item ->
when (item.itemId) {
1 -> {
// Same account it went to, when that account is still one of theirs.
t.accounts.firstOrNull { it.account == m.peerAccount }?.let { selectTo(it, matchFromCurrency = true) }
binding.etAmount.setText(amount)
binding.etNote.setText(m.note)
send()
}
2 -> openReceipt(m.receiptKey)
3 -> {
val clipboard = requireContext().getSystemService(android.content.ClipboardManager::class.java)
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("reference", m.reference))
Snackbar.make(binding.root, R.string.chat_reference_copied, Snackbar.LENGTH_SHORT).show()
}
}
true
}
menu.show()
}
/** Fills an account row: name, full account number, balance (masked when amounts are hidden). */
private fun bindAccountRow(row: ItemAccountDropdownBinding, account: BankAccount) {
val display = AccountListParser.from(account)
row.tvDropdownAccountName.text = account.accountBriefName
row.tvDropdownAccountNumber.text = account.accountNumber
val balance = display?.balance.orEmpty()
val hide = viewModel.hideAmounts.value ?: false
row.tvDropdownBalance.text = if (hide && balance.isNotBlank()) AccountHistoryAdapter.maskAmount(balance) else balance
val type = display?.typeLabel.orEmpty()
row.tvDropdownAccountType.text = type
row.tvDropdownAccountType.visibility = if (type.isBlank()) View.GONE else View.VISIBLE
row.ivDropdownCardLogo.visibility = View.GONE
}
private fun showAccountPicker() {
if (fromAccounts.isEmpty()) return
val ctx = requireContext()
val dialog = BottomSheetDialog(ctx)
val list = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val pad = (8 * resources.displayMetrics.density).toInt()
setPadding(0, pad, 0, pad * 3)
}
list.addView(sheetTitle(getString(R.string.chat_pick_account)))
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
for (account in fromAccounts) {
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
bindAccountRow(row, account)
// MVR can't go into a USD account: show those accounts, but not selectable.
val blocked = isBlockedPair(account.currencyName, selectedTo?.currency.orEmpty())
if (blocked) {
row.tvDropdownAccountType.text = getString(R.string.chat_cant_send_mvr_to_usd)
row.tvDropdownAccountType.visibility = View.VISIBLE
row.root.alpha = 0.4f
}
if (account.accountNumber == selectedFrom?.accountNumber) row.root.setBackgroundColor(selectedBg)
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
if (!blocked) row.root.setOnClickListener {
selectFrom(account)
dialog.dismiss()
}
list.addView(row.root)
}
dialog.setContentView(list)
dialog.show()
}
private fun send() {
val t = thread ?: return
val from = selectedFrom ?: return
val to = selectedTo ?: return
if (isBlockedPair(from.currencyName, to.currency)) return
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull()
if (amount == null || amount <= 0.0) {
binding.tilAmount.error = getString(R.string.chat_amount_invalid)
return
}
binding.tilAmount.error = null
val transfer = TransferFragment.newInstanceFromQr(
accountNumber = to.account,
displayName = to.contact?.benefNickName?.takeIf { it.isNotBlank() } ?: t.peerName,
amount = "%.2f".format(amount),
remarks = binding.etNote.text?.toString()?.trim()?.takeIf { it.isNotBlank() },
fromAccountNumber = from.accountNumber,
returnOnSuccess = true,
autoConfirm = true
)
if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return
payStartedAt = System.currentTimeMillis()
hideKeyboard()
if (isBusinessProfile(from)) {
// Business profiles confirm with an OTP typed into the Transfer page: show it in a sheet.
TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet")
} else {
// Personal profiles confirm automatically: run the Transfer page hidden, so only its
// confirm dialog appears over the chat. A new send replaces any earlier one.
childFragmentManager.beginTransaction()
.replace(R.id.hiddenTransferHost, transfer, TAG_HIDDEN_TRANSFER)
.commit()
}
}
/** Same rule as the Transfer page: the account's BML login profile is a business one. */
private fun isBusinessProfile(account: BankAccount): Boolean {
val profiles = app.bmlProfilesMap[account.loginTag.removePrefix("bml_")] ?: return false
return profiles.firstOrNull { it.profileId == account.profileId }?.profileType == "business"
}
/** Closes the composer's keyboard so the transfer sheet doesn't open pushed up by it. */
private fun hideKeyboard() {
val b = _binding ?: return
val focused = b.root.findFocus() ?: return
requireContext().getSystemService(android.view.inputmethod.InputMethodManager::class.java)
?.hideSoftInputFromWindow(focused.windowToken, 0)
focused.clearFocus()
}
/** Shows the photo, contact name and real name in the toolbar; tapping it opens the contact. */
private fun updateToolbar() {
val t = thread ?: return
val bar = (activity as? AppCompatActivity)?.supportActionBar ?: return
val h = header ?: ViewChatHeaderBinding.inflate(layoutInflater).also { h ->
header = h
h.root.setOnClickListener { openContact() }
}
h.tvHeaderName.text = t.peerName
// Real name (when titled with a nickname), plus the account and currency when there's only one.
val subtitle = if (t.isMerchant) getString(R.string.chat_merchant_subtitle) else listOfNotNull(
t.realName.takeIf { it.isNotBlank() && !it.equals(t.peerName, ignoreCase = true) },
selectedTo?.takeIf { t.accounts.size == 1 }?.account,
selectedTo?.takeIf { t.accounts.size == 1 }?.currency?.takeIf { it.isNotBlank() }
).joinToString(" · ")
h.tvHeaderAccount.text = subtitle
h.tvHeaderAccount.visibility = if (subtitle.isBlank()) View.GONE else View.VISIBLE
val sizePx = (40 * resources.displayMetrics.density).toInt()
if (t.isMerchant) {
h.ivHeaderAvatar.shapeAppearanceModel = com.google.android.material.shape.ShapeAppearanceModel.builder()
.setAllCornerSizes(10 * resources.displayMetrics.density).build()
h.ivHeaderAvatar.setImageBitmap(ChatsAdapter.shopBitmap(h.root, ChatsAdapter.merchantColor(t.peerKey), sizePx))
} else {
val photo = t.contact?.customerImgHash?.let { ContactImageCache.load(requireContext(), it) }
h.ivHeaderAvatar.setImageBitmap(photo ?: contactInitialsBitmap(t.peerName, ChatsAdapter.avatarColor(t.peerKey), sizePx))
}
requireActivity().title = t.peerName
bar.setDisplayShowTitleEnabled(false)
bar.setDisplayShowCustomEnabled(true)
bar.customView = h.root
}
private fun openContact() {
val t = thread ?: return
if (t.isMerchant) return
val display = (selectedTo?.contact ?: t.contact)?.let { ContactListParser.from(it) }
if (display != null) {
if (childFragmentManager.findFragmentByTag("contact_details") != null) return
ContactDetailsSheetFragment.newInstance(display).show(childFragmentManager, "contact_details")
} else {
// Not a saved contact yet: offer to save them, prefilled with what we know.
if (childFragmentManager.findFragmentByTag("add_contact") != null) return
AddContactSheetFragment.newInstance(
bmlProfileId = selectedFrom?.profileId?.takeIf { it.isNotBlank() },
accountNumber = selectedTo?.account,
recipientName = t.realName.ifBlank { t.peerName },
currency = selectedTo?.currency?.takeIf { it.isNotBlank() } ?: selectedFrom?.currencyName
).show(childFragmentManager, "add_contact")
}
}
override fun openTransfer(contact: ContactDisplay) {
val fragment = TransferFragment.newInstance(
accountNumber = contact.accountNumber,
displayName = contact.name,
subtitle = contact.transferSubtitle,
colorHex = contact.bankColor,
imageHash = contact.imageHash
)
(requireActivity() as HomeActivity).showWithBackStack(fragment)
}
/** Deleting lives on the Contacts page, which also keeps its caches in sync. */
override fun confirmDelete(contact: ContactDisplay) {
(requireActivity() as HomeActivity).showWithBackStack(ContactsFragment())
}
override fun onResume() {
super.onResume()
updateToolbar()
}
override fun onPause() {
super.onPause()
(activity as? AppCompatActivity)?.supportActionBar?.apply {
setDisplayShowCustomEnabled(false)
setDisplayShowTitleEnabled(true)
customView = null
}
}
override fun onDestroyView() {
super.onDestroyView()
header = null
_binding = null
}
companion object {
private const val ARG_PEER_KEY = "peer_key"
private const val HISTORY_CATCH_UP_MS = 10_000L
private const val TAG_HIDDEN_TRANSFER = "transfer_hidden"
private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN")
private val CARD_TYPES = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT")
fun newInstance(peerKey: String) = ChatFragment().apply {
arguments = Bundle().apply { putString(ARG_PEER_KEY, peerKey) }
}
}
}
@@ -1,205 +0,0 @@
package sh.sar.basedbank.ui.home
import android.content.res.Configuration
import android.graphics.Color
import android.graphics.Typeface
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.format.DateUtils
import android.text.style.StyleSpan
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.core.graphics.ColorUtils
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.shape.ShapeAppearanceModel
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ItemChatBubbleBinding
import sh.sar.basedbank.databinding.ItemChatDateBinding
import sh.sar.basedbank.util.ChatMessage
/**
* One chat's transfers or payments as compact bubbles — sent / paid on the right, received on the
* left — with ✓ (seen in a BML alert) or ✓✓ (booked in history). Day separators carry the day's
* totals.
*/
class ChatMessagesAdapter(
private val onReceiptClick: (ChatMessage) -> Unit,
private val onLongPress: (ChatMessage, View) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
/** Rows of the list: a day separator or a bubble. */
sealed class Item {
data class DateChip(val day: String, val messages: List<ChatMessage>) : Item()
data class Bubble(val message: ChatMessage) : Item()
}
private val items = mutableListOf<Item>()
private var hideAmounts = false
private var todayLabel = ""
private var yesterdayLabel = ""
/** When the person has several accounts, sent bubbles say which one the money went to. */
var showDestination = false
fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return
hideAmounts = hide
notifyDataSetChanged()
}
/** [messages] must be oldest first. */
fun setMessages(messages: List<ChatMessage>, todayLabel: String, yesterdayLabel: String) {
this.todayLabel = todayLabel
this.yesterdayLabel = yesterdayLabel
items.clear()
messages.groupBy { it.date.take(10) }.forEach { (day, dayMessages) ->
items.add(Item.DateChip(day, dayMessages))
dayMessages.forEach { items.add(Item.Bubble(it)) }
}
notifyDataSetChanged()
}
override fun getItemCount() = items.size
override fun getItemViewType(position: Int) = when (items[position]) {
is Item.DateChip -> TYPE_DATE
is Item.Bubble -> TYPE_BUBBLE
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return if (viewType == TYPE_DATE) DateVH(ItemChatDateBinding.inflate(inflater, parent, false))
else BubbleVH(ItemChatBubbleBinding.inflate(inflater, parent, false))
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = items[position]) {
is Item.DateChip -> (holder as DateVH).bind(item)
is Item.Bubble -> (holder as BubbleVH).bind(item.message)
}
}
private fun formatAmount(m: ChatMessage) =
if (hideAmounts) "${m.currency} ••••••" else "${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}"
private fun isNight(view: View) =
(view.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
private fun ticksFor(m: ChatMessage) = if (m.isBooked) "✓✓" else "✓"
private fun tickColor(view: View, m: ChatMessage, fallback: Int) =
if (m.isBooked) Color.parseColor(if (isNight(view)) "#90CAF9" else "#1565C0") else fallback
inner class DateVH(private val b: ItemChatDateBinding) : RecyclerView.ViewHolder(b.root) {
fun bind(item: Item.DateChip) {
val first = item.messages.first()
val label = when {
DateUtils.isToday(first.timeMillis) -> todayLabel
DateUtils.isToday(first.timeMillis + DateUtils.DAY_IN_MILLIS) -> yesterdayLabel
else -> AccountHistoryAdapter.formatDateHeader(first.date)
}
val text = SpannableStringBuilder(label)
text.setSpan(StyleSpan(Typeface.BOLD), 0, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
if (!hideAmounts) {
// Day totals per currency: ↑ sent, ↓ received.
val parts = item.messages.groupBy { it.currency }.map { (ccy, ms) ->
val sent = ms.filter { it.isSent }.sumOf { -it.amount }
val received = ms.filter { !it.isSent }.sumOf { it.amount }
listOfNotNull(
"↑ %.2f".format(sent).takeIf { sent > 0 },
"↓ %.2f".format(received).takeIf { received > 0 }
).joinToString(" · ").let { if (item.messages.map { m -> m.currency }.toSet().size > 1) "$ccy $it" else it }
}.filter { it.isNotBlank() }
if (parts.isNotEmpty()) text.append(" ").append(parts.joinToString(" "))
}
b.tvDate.text = text
}
}
inner class BubbleVH(private val b: ItemChatBubbleBinding) : RecyclerView.ViewHolder(b.root) {
fun bind(m: ChatMessage) {
val ctx = b.root.context
val sent = m.isSent
val night = isNight(b.root)
val density = ctx.resources.displayMetrics.density
b.bubbleRow.gravity = if (sent) Gravity.END else Gravity.START
(b.cardBubble.layoutParams as LinearLayout.LayoutParams).gravity = if (sent) Gravity.END else Gravity.START
// Rounded, with a small "tail" corner on the sender's side at the bottom.
val rtl = b.root.layoutDirection == View.LAYOUT_DIRECTION_RTL
val big = 18 * density
val tail = 4 * density
val tailOnRight = sent != rtl
b.cardBubble.shapeAppearanceModel = ShapeAppearanceModel.builder()
.setTopLeftCornerSize(big).setTopRightCornerSize(big)
.setBottomLeftCornerSize(if (tailOnRight) big else tail)
.setBottomRightCornerSize(if (tailOnRight) tail else big)
.build()
// Sent: tinted. Received: neutral with an outline, so the two read apart on any theme.
val bg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorErrorContainer)
else Color.parseColor(if (night) "#2A2C2E" else "#FFFFFF")
val fg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnErrorContainer)
else MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnSurface)
val green = Color.parseColor(if (night) "#81C784" else "#2E7D32")
b.cardBubble.setCardBackgroundColor(bg)
b.cardBubble.strokeWidth = if (sent) 0 else density.toInt()
b.cardBubble.strokeColor = MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOutlineVariant)
b.tvAmount.text = if (sent) formatAmount(m) else "+ ${formatAmount(m)}"
b.tvAmount.setTextColor(if (sent) fg else green)
b.tvNote.text = m.note
b.tvNote.setTextColor(fg)
b.tvNote.visibility = if (m.note.isBlank()) View.GONE else View.VISIBLE
b.btnReceipt.visibility = if (m.receiptKey.isNotBlank()) View.VISIBLE else View.GONE
b.btnReceipt.setColorFilter(fg)
b.btnReceipt.setOnClickListener { onReceiptClick(m) }
b.tvAccount.text = when {
!sent -> ctx.getString(R.string.chat_into_short, m.accountDisplayName)
showDestination && m.peerAccount.isNotBlank() ->
ctx.getString(R.string.chat_account_to, m.accountDisplayName, m.peerAccount)
else -> m.accountDisplayName
}
b.tvAccount.setTextColor(fg)
b.tvTime.text = AccountHistoryAdapter.formatTime(m.date)
b.tvTime.setTextColor(fg)
b.tvTicks.text = ticksFor(m)
b.tvTicks.setTextColor(tickColor(b.root, m, ColorUtils.setAlphaComponent(fg, 0xB3)))
b.tvTicks.contentDescription = ctx.getString(if (m.isBooked) R.string.chat_tick_booked else R.string.chat_tick_seen)
b.cardBubble.setOnClickListener { showDetail(b.root, m) }
b.cardBubble.setOnLongClickListener { onLongPress(m, b.cardBubble); true }
}
}
private fun showDetail(anchor: View, m: ChatMessage) {
val ctx = anchor.context
val details = buildString {
val sign = if (m.isSent) "-" else "+"
append("Amount\n$sign ${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}\n\n")
append("Date\n${AccountHistoryAdapter.formatFullDate(m.date)}\n\n")
if (m.note.isNotBlank()) append("Remarks\n${m.note}\n\n")
if (m.reference.isNotBlank()) append("Reference\n${m.reference}\n\n")
append("Account\n${m.accountDisplayName}\n\n")
append("Status\n${ctx.getString(if (m.isBooked) R.string.chat_tick_booked else R.string.chat_tick_seen)}")
}
MaterialAlertDialogBuilder(ctx)
.setTitle(m.peerName)
.setMessage(details)
.setPositiveButton("OK", null)
.show()
}
companion object {
private const val TYPE_DATE = 0
private const val TYPE_BUBBLE = 1
}
}
@@ -1,172 +0,0 @@
package sh.sar.basedbank.ui.home
import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
import com.google.android.material.shape.ShapeAppearanceModel
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ItemChatBinding
import sh.sar.basedbank.util.ChatThread
import sh.sar.basedbank.util.ContactImageCache
/**
* Telegram-style chat list: one row per person or business, latest transfer as the preview.
* Pinned chats come first; long-press selects a chat (for pinning).
*/
class ChatsAdapter(
private val onChatClick: (ChatThread) -> Unit,
private val onChatLongPress: (ChatThread) -> Unit
) : RecyclerView.Adapter<ChatsAdapter.ViewHolder>() {
private var allThreads: List<ChatThread> = emptyList()
private var displayed: List<ChatThread> = emptyList()
private var pinned: List<String> = emptyList()
private var searchQuery = ""
private var hideAmounts = false
/** Chat key shown as selected (long-pressed), or null. */
var selectedKey: String? = null
set(value) { field = value; notifyDataSetChanged() }
fun updateThreads(threads: List<ChatThread>, pinnedKeys: List<String>) {
allThreads = threads
pinned = pinnedKeys
applyFilter()
}
fun setSearch(query: String) {
searchQuery = query
applyFilter()
}
fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return
hideAmounts = hide
notifyDataSetChanged()
}
fun isPinned(key: String) = key in pinned
val isEmpty get() = displayed.isEmpty()
private fun applyFilter() {
val matching = if (searchQuery.isBlank()) allThreads else allThreads.filter {
it.peerName.contains(searchQuery, ignoreCase = true) ||
it.realName.contains(searchQuery, ignoreCase = true) ||
it.peerAccount.contains(searchQuery) ||
it.messages.any { m -> m.note.contains(searchQuery, ignoreCase = true) }
}
// Pinned first, most recently pinned on top; the rest stay newest first.
val (pinnedThreads, others) = matching.partition { it.peerKey in pinned }
displayed = pinnedThreads.sortedBy { pinned.indexOf(it.peerKey) } + others
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val holder = ViewHolder(ItemChatBinding.inflate(LayoutInflater.from(parent.context), parent, false))
holder.binding.root.setOnClickListener {
val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) onChatClick(displayed[pos])
}
holder.binding.root.setOnLongClickListener {
val pos = holder.bindingAdapterPosition
if (pos != RecyclerView.NO_POSITION) onChatLongPress(displayed[pos])
pos != RecyclerView.NO_POSITION
}
return holder
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(displayed[position])
override fun getItemCount() = displayed.size
inner class ViewHolder(val binding: ItemChatBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(thread: ChatThread) {
val ctx = binding.root.context
val night = (ctx.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
val last = thread.last
binding.tvName.text = thread.peerName
val amount = if (hideAmounts) "${last.currency} ••••••"
else "${last.currency} ${"%.2f".format(kotlin.math.abs(last.amount))}"
val ticks = if (last.isBooked) " ✓✓" else " ✓"
binding.tvPreview.text = when {
last.merchant -> ctx.getString(R.string.chat_preview_paid, amount) + ticks
last.isSent -> {
val note = last.note.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty()
ctx.getString(R.string.chat_preview_sent, amount) + note + ticks
}
else -> ctx.getString(R.string.chat_preview_received, amount)
}
binding.tvPreview.setTextColor(
if (!last.isSent) Color.parseColor(if (night) "#81C784" else "#2E7D32")
else MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorOnSurfaceVariant)
)
binding.tvTime.text = if (DateUtils.isToday(last.timeMillis)) AccountHistoryAdapter.formatTime(last.date)
else AccountHistoryAdapter.formatDateOnly(last.date)
val sizePx = (52 * ctx.resources.displayMetrics.density).toInt()
if (thread.isMerchant) {
// Businesses get a rounded-square shop icon; people keep round initials or a photo.
binding.ivAvatar.shapeAppearanceModel = ShapeAppearanceModel.builder()
.setAllCornerSizes(14 * ctx.resources.displayMetrics.density).build()
binding.ivAvatar.setImageBitmap(shopBitmap(binding.root, merchantColor(thread.peerKey), sizePx))
} else {
binding.ivAvatar.shapeAppearanceModel = ShapeAppearanceModel.builder().setAllCornerSizes(sizePx / 2f).build()
val photo = thread.contact?.customerImgHash?.let { ContactImageCache.load(ctx, it) }
binding.ivAvatar.setImageBitmap(photo ?: contactInitialsBitmap(thread.peerName, avatarColor(thread.peerKey), sizePx))
}
val isPinned = thread.peerKey in pinned
binding.ivPin.visibility = if (isPinned) View.VISIBLE else View.GONE
// Selected (long-pressed): tinted; pinned: a soft surface; otherwise the normal ripple.
when {
thread.peerKey == selectedKey -> binding.root.setBackgroundColor(
MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorErrorContainer))
isPinned -> binding.root.setBackgroundColor(
MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorSurfaceContainerHigh))
else -> binding.root.setBackgroundResource(selectableBackground(binding.root))
}
}
}
private fun selectableBackground(view: View): Int {
val out = android.util.TypedValue()
view.context.theme.resolveAttribute(android.R.attr.selectableItemBackground, out, true)
return out.resourceId
}
companion object {
private val AVATAR_COLORS = listOf("#E8B04B", "#5C9CE6", "#E57373", "#66BB6A", "#9575CD", "#4DB6AC", "#F06292", "#FF8A65")
private val MERCHANT_COLORS = listOf("#6D4C41", "#546E7A", "#5D4037", "#455A64", "#8D6E63", "#37474F")
/** Stable per-person avatar colour, so a chat keeps its colour between visits. */
fun avatarColor(peerKey: String) = AVATAR_COLORS[(peerKey.hashCode() and 0x7fffffff) % AVATAR_COLORS.size]
fun merchantColor(peerKey: String) = MERCHANT_COLORS[(peerKey.hashCode() and 0x7fffffff) % MERCHANT_COLORS.size]
/** Shop icon on a coloured square; the image view's shape rounds the corners. */
fun shopBitmap(view: View, colorHex: String, sizePx: Int): Bitmap {
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm)
canvas.drawRect(RectF(0f, 0f, sizePx.toFloat(), sizePx.toFloat()),
Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor(colorHex) })
val icon = ContextCompat.getDrawable(view.context, R.drawable.ic_store)?.mutate() ?: return bm
val inset = sizePx / 4
icon.setBounds(inset, inset, sizePx - inset, sizePx - inset)
icon.draw(canvas)
return bm
}
}
}
@@ -1,261 +0,0 @@
package sh.sar.basedbank.ui.home
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentChatsBinding
import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.ChatStore
import sh.sar.basedbank.util.ChatThread
import java.text.DateFormat
import java.util.Date
/**
* Transfers grouped by person or business, shown as a chat list. Contacts is reachable from the
* toolbar; long-press a chat to pin it to the top.
*/
class ChatsFragment : Fragment() {
private var _binding: FragmentChatsBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private val app get() = requireActivity().application as BasedBankApp
private lateinit var adapter: ChatsAdapter
private val scanLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
onQrScanned(raw)
}
private var actionMode: ActionMode? = null
private var syncedThisView = false
private var syncedWithContacts = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentChatsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = ChatsAdapter(
onChatClick = { thread ->
if (actionMode != null) actionMode?.finish()
else (requireActivity() as HomeActivity).showWithBackStack(ChatFragment.newInstance(thread.peerKey))
},
onChatLongPress = { thread -> startSelection(thread) }
)
binding.rvChats.layoutManager = LinearLayoutManager(requireContext())
binding.rvChats.adapter = adapter
binding.etSearch.addTextChangedListener { text ->
adapter.setSearch(text?.toString() ?: "")
updateEmptyView()
}
binding.swipeRefresh.setOnRefreshListener { sync() }
// Scan to Pay: opens the QR scanner; after paying it comes back here and refreshes.
binding.fabScanPay.setOnClickListener {
scanLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
parentFragmentManager.setFragmentResultListener(TransferFragment.RESULT_TRANSFER_DONE, viewLifecycleOwner) { _, _ ->
sync()
// BML's alert often lands a few seconds after the payment: check again shortly.
viewLifecycleOwner.lifecycleScope.launch {
delay(RECHECK_AFTER_PAYMENT_MS)
if (_binding != null) sync()
}
}
// Keep the button above the system navigation bar when there's no bottom bar.
val fabMargin = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.fabScanPay) { v, insets ->
val bottomNav = NavCustomization.getNavMode(
requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE)
) == NavCustomization.NAV_MODE_BOTTOM
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
(v.layoutParams as android.widget.FrameLayout.LayoutParams).bottomMargin =
fabMargin + if (bottomNav) 0 else navBar
v.requestLayout()
insets
}
requireActivity().addMenuProvider(object : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
menu.add(Menu.NONE, R.id.action_open_contacts, 0, R.string.nav_contacts)
.setIcon(R.drawable.ic_contacts)
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
if (menuItem.itemId != R.id.action_open_contacts) return false
(requireActivity() as HomeActivity).showWithBackStack(ContactsFragment())
return true
}
}, viewLifecycleOwner, Lifecycle.State.RESUMED)
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
// Contacts give chats a saved name and an account number to send to.
(activity as? HomeActivity)?.loadAllContacts()
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
loadThreads()
// Contacts usually load after the first sync; sync again so their real names get looked up.
if (syncedThisView && !syncedWithContacts && contacts.isNotEmpty()) sync()
}
// Accounts arrive after login; sync once they are available.
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
if (!syncedThisView && accounts.any { it.bank == "BML" }) sync()
}
loadThreads()
}
private fun sync() {
syncedThisView = true
binding.swipeRefresh.isRefreshing = true
val accounts = viewModel.accounts.value ?: emptyList()
val contacts = viewModel.contacts.value ?: emptyList()
if (contacts.isNotEmpty()) syncedWithContacts = true
val ctx = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch {
withContext(Dispatchers.IO) { ChatStore.sync(ctx, app, accounts, contacts) }
_binding?.swipeRefresh?.isRefreshing = false
loadThreads()
}
}
private fun loadThreads() {
val ctx = requireContext().applicationContext
val contacts = viewModel.contacts.value ?: emptyList()
viewLifecycleOwner.lifecycleScope.launch {
val (threads, pins) = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) to ChatStore.pins(ctx) }
if (_binding == null) return@launch
adapter.updateThreads(threads, pins)
updateEmptyView()
}
}
/**
* A scanned QR: BML Scan to Pay (static or card-machine) opens the payment, a PayMV QR opens a
* transfer. Either way, success comes back to this list, which then refreshes.
*/
private fun onQrScanned(raw: String) {
val activity = requireActivity() as HomeActivity
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
if (bmlTarget != null) {
activity.showWithBackStack(TransferFragment.newInstanceFromBmlQr(bmlTarget, null, returnOnSuccess = true))
return
}
val qr = PaymvQrParser.parse(raw)
val account = qr?.accountNumber
if (account == null) {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
return
}
activity.showWithBackStack(TransferFragment.newInstanceFromQr(
accountNumber = account,
displayName = qr.merchantName ?: account,
amount = qr.amount,
remarks = qr.purpose,
returnOnSuccess = true
))
}
/** Long-press: select the chat and offer Pin / Unpin in the top bar. */
private fun startSelection(thread: ChatThread) {
adapter.selectedKey = thread.peerKey
val pinned = adapter.isPinned(thread.peerKey)
val callback = object : ActionMode.Callback {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
mode.title = getString(R.string.chat_selected_one)
menu.add(Menu.NONE, R.id.action_pin_chat, 0, if (pinned) R.string.chat_unpin else R.string.chat_pin)
.setIcon(R.drawable.ic_pin)
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS or MenuItem.SHOW_AS_ACTION_WITH_TEXT)
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu) = false
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
if (item.itemId != R.id.action_pin_chat) return false
setPinned(thread, !pinned, offerUndo = true)
mode.finish()
return true
}
override fun onDestroyActionMode(mode: ActionMode) {
actionMode = null
adapter.selectedKey = null
}
}
actionMode?.finish()
actionMode = (requireActivity() as AppCompatActivity).startSupportActionMode(callback)
}
private fun setPinned(thread: ChatThread, pin: Boolean, offerUndo: Boolean) {
val ctx = requireContext().applicationContext
viewLifecycleOwner.lifecycleScope.launch {
withContext(Dispatchers.IO) { ChatStore.setPinned(ctx, thread.peerKey, pin) }
loadThreads()
val b = _binding ?: return@launch
if (!offerUndo) return@launch
Snackbar.make(b.root, getString(if (pin) R.string.chat_pinned_msg else R.string.chat_unpinned_msg, thread.peerName), Snackbar.LENGTH_LONG)
.setAction(R.string.chat_undo) { setPinned(thread, !pin, offerUndo = false) }
.show()
}
}
private fun updateEmptyView() {
val b = _binding ?: return
b.emptyView.visibility = if (adapter.isEmpty) View.VISIBLE else View.GONE
if (!adapter.isEmpty) return
val since = ChatStore.sinceMillis(requireContext())
b.emptyView.text = when {
b.etSearch.text?.isNotBlank() == true -> getString(R.string.chats_no_results)
since > 0L -> getString(R.string.chats_empty_since,
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(since)))
else -> getString(R.string.chats_empty)
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_chats)
}
override fun onDestroyView() {
actionMode?.finish()
super.onDestroyView()
_binding = null
}
companion object {
private const val RECHECK_AFTER_PAYMENT_MS = 10_000L
}
}
@@ -24,14 +24,14 @@ import sh.sar.basedbank.util.ContactListParser
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.TransferNetwork
/** Contact details drawer shown when a contact is tapped in [ContactsFragment] or a chat header. */
/** Contact details drawer shown when a row in [ContactsFragment] is tapped. */
class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
private var _binding: SheetContactDetailsBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private val contactsFragment get() = parentFragment as? ContactSheetHost
private val contactsFragment get() = parentFragment as? ContactsFragment
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = SheetContactDetailsBinding.inflate(inflater, container, false)
@@ -15,7 +15,7 @@ import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
class ContactPickerAdapter(
private val imageCache: MutableMap<String, Bitmap>,
private val onItemClick: (accountNumber: String, label: String) -> Unit,
private val onItemClick: (accountNumber: String, label: String, categoryId: String?) -> Unit,
private val onSameAsFrom: () -> Unit,
private val onImageNeeded: ((hash: String) -> Unit)? = null,
private val onItemLongClick: ((accountNumber: String, anchor: android.view.View) -> Boolean)? = null
@@ -33,7 +33,9 @@ class ContactPickerAdapter(
val imageHash: String? = null,
val inactiveReason: String? = null,
val balance: String? = null,
val bankLogoRes: Int? = null
val bankLogoRes: Int? = null,
/** The saved contact's category, for rows that are contacts; null otherwise. */
val categoryId: String? = null
) : PickerItem()
}
@@ -125,7 +127,7 @@ class ContactPickerAdapter(
Toast.makeText(binding.root.context, item.inactiveReason, Toast.LENGTH_SHORT).show()
item.isSameAsFrom ->
Toast.makeText(binding.root.context, R.string.transfer_same_account, Toast.LENGTH_SHORT).show()
else -> onItemClick(item.accountNumber, item.displayName)
else -> onItemClick(item.accountNumber, item.displayName, item.categoryId)
}
}
binding.root.setOnLongClickListener { view ->
@@ -54,7 +54,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val pageAdapters: List<ContactPickerAdapter> = pages.mapIndexed { i, page ->
ContactPickerAdapter(
imageCache = sharedImageCache,
onItemClick = { accountNumber, label -> handlePickerSelection(accountNumber, label) },
onItemClick = { accountNumber, label, categoryId -> handlePickerSelection(accountNumber, label, categoryId) },
onSameAsFrom = {},
onImageNeeded = { hash -> fetchImage(hash) },
onItemLongClick = { accountNumber, anchor ->
@@ -161,12 +161,13 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
}.also { it.attach() }
}
private fun handlePickerSelection(accountNumber: String, label: String) {
private fun handlePickerSelection(accountNumber: String, label: String, categoryId: String?) {
val contacts = viewModel.contacts.value ?: emptyList()
val accounts = viewModel.accounts.value ?: emptyList()
val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
categoryId?.let { bundle.putString(KEY_CATEGORY, it) }
when {
accountNumber.startsWith("bmlqr:") -> {
bundle.putString(KEY_SUBTITLE, "BML QR Merchant")
@@ -226,7 +227,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
imageHash = r.imageHash,
// A MFAISA-tagged recent is itself a valid M-Faisa recipient — don't grey it out
// when the source is M-Faisa.
inactiveReason = if (r.bank == "MFAISA") null else mfaisaInactive
inactiveReason = if (r.bank == "MFAISA") null else mfaisaInactive,
categoryId = r.contactCategory
))
}
return items
@@ -328,7 +330,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
colorHex = contact.bankColor,
isSameAsFrom = contact.benefAccount == fromAccountNumber,
imageHash = contact.customerImgHash,
inactiveReason = mfaisaInactive ?: currencyMismatchReason(fromCurrency, contact.transferCyDesc)
inactiveReason = mfaisaInactive ?: currencyMismatchReason(fromCurrency, contact.transferCyDesc),
categoryId = contact.benefCategoryId
))
}
return items
@@ -396,6 +399,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
const val KEY_SUBTITLE = "subtitle"
const val KEY_COLOR = "color"
const val KEY_IMAGE_HASH = "imageHash"
/** The picked contact's category (`BankContact.benefCategoryId`); absent for non-contacts. */
const val KEY_CATEGORY = "category"
private const val ARG_FROM_ACCOUNT = "fromAccount"
private const val RECENTS_TAG = "__recents__"
private const val MY_ACCOUNTS_TAG = "__my_accounts__"
@@ -1,9 +0,0 @@
package sh.sar.basedbank.ui.home
import sh.sar.basedbank.util.ContactDisplay
/** A screen that can show [ContactDetailsSheetFragment] and handle its Transfer/Delete buttons. */
interface ContactSheetHost {
fun openTransfer(contact: ContactDisplay)
fun confirmDelete(contact: ContactDisplay)
}
@@ -40,7 +40,7 @@ import sh.sar.basedbank.util.ContactManager
import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.TransferNetwork
class ContactsFragment : Fragment(), ContactSheetHost {
class ContactsFragment : Fragment() {
private var _binding: FragmentContactsBinding? = null
private val binding get() = _binding!!
@@ -182,18 +182,19 @@ class ContactsFragment : Fragment(), ContactSheetHost {
ContactDetailsSheetFragment.newInstance(contact).show(childFragmentManager, "contact_details")
}
override fun openTransfer(contact: ContactDisplay) {
internal fun openTransfer(contact: ContactDisplay) {
val fragment = TransferFragment.newInstance(
accountNumber = contact.accountNumber,
displayName = contact.name,
subtitle = contact.transferSubtitle,
colorHex = contact.bankColor,
imageHash = contact.imageHash
imageHash = contact.imageHash,
contactCategory = contact.categoryId
)
(requireActivity() as HomeActivity).showWithBackStack(fragment)
}
override fun confirmDelete(contact: ContactDisplay) {
internal fun confirmDelete(contact: ContactDisplay) {
val ctx = requireContext()
val errorColor = MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorError)
val icon = ContextCompat.getDrawable(ctx, R.drawable.ic_delete)?.mutate()?.apply { setTint(errorColor) }
@@ -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,12 +225,21 @@ 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) {
R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> AccountsFragment()
R.id.nav_chats -> ChatsFragment()
R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
R.id.nav_pay_mv_qr -> PayMvQrFragment()
@@ -474,7 +537,6 @@ fun applyNavLabelVisibility() {
val dest = fragment ?: when (itemId) {
R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> AccountsFragment()
R.id.nav_chats -> ChatsFragment()
R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
R.id.nav_pay_mv_qr -> PayMvQrFragment()
@@ -530,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()
}
@@ -32,7 +32,6 @@ object NavCustomization {
/** All items that can occupy either a bottom nav slot or the "More" screen. */
val ALL_SWAPPABLE = listOf(
NavItemDef(R.id.nav_accounts, "nav_accounts", R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts),
NavItemDef(R.id.nav_chats, "nav_chats", R.drawable.ic_chat, R.string.nav_chats, R.string.nav_desc_chats),
NavItemDef(R.id.nav_contacts, "nav_contacts", R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts),
NavItemDef(R.id.nav_transfer, "nav_transfer", R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer),
NavItemDef(R.id.nav_pay_mv_qr, "nav_pay_mv_qr", R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr),
@@ -52,14 +51,14 @@ object NavCustomization {
fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
keyToId(prefs.getString("bottom_nav_slot_1_key", null), R.id.nav_accounts),
keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_chats),
keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_contacts),
keyToId(prefs.getString("bottom_nav_slot_3_key", null), R.id.nav_transfer),
)
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
prefs.edit()
.putString("bottom_nav_slot_1_key", idToKey(slots[0]) ?: "nav_accounts")
.putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_chats")
.putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_contacts")
.putString("bottom_nav_slot_3_key", idToKey(slots[2]) ?: "nav_transfer")
.apply()
}
@@ -80,7 +79,7 @@ object NavCustomization {
fun getCircularSlots(prefs: SharedPreferences): List<Int> = listOf(
keyToId(prefs.getString("circular_slot_1_key", null), R.id.nav_transfer),
keyToId(prefs.getString("circular_slot_2_key", null), R.id.nav_pay_with_card),
keyToId(prefs.getString("circular_slot_3_key", null), R.id.nav_chats),
keyToId(prefs.getString("circular_slot_3_key", null), R.id.nav_contacts),
keyToId(prefs.getString("circular_slot_4_key", null), R.id.nav_accounts),
)
@@ -88,7 +87,7 @@ object NavCustomization {
prefs.edit()
.putString("circular_slot_1_key", idToKey(slots[0]) ?: "nav_transfer")
.putString("circular_slot_2_key", idToKey(slots[1]) ?: "nav_pay_with_card")
.putString("circular_slot_3_key", idToKey(slots[2]) ?: "nav_chats")
.putString("circular_slot_3_key", idToKey(slots[2]) ?: "nav_contacts")
.putString("circular_slot_4_key", idToKey(slots[3]) ?: "nav_accounts")
.apply()
}
@@ -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) {
File diff suppressed because it is too large Load Diff
@@ -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 {
@@ -1,62 +0,0 @@
package sh.sar.basedbank.ui.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import sh.sar.basedbank.databinding.SheetTransferBinding
/**
* Shows [TransferFragment] in a bottom sheet so the screen behind (a chat) stays visible.
* The transfer page runs unchanged inside; with returnOnSuccess it dismisses this sheet.
*/
class TransferSheetFragment : BottomSheetDialogFragment() {
private var _binding: SheetTransferBinding? = null
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = SheetTransferBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
// Leave the top of the chat visible above the sheet.
binding.transferContainer.layoutParams.height = (resources.displayMetrics.heightPixels * SHEET_HEIGHT_FRACTION).toInt()
if (childFragmentManager.findFragmentById(binding.transferContainer.id) == null) {
val transferArgs = requireArguments()
childFragmentManager.beginTransaction()
.replace(binding.transferContainer.id, TransferFragment().apply { arguments = transferArgs })
.commitNow()
}
}
override fun onStart() {
super.onStart()
(dialog as? BottomSheetDialog)?.let { d ->
// Keep the amount/remarks fields above the keyboard, but don't open it by itself.
@Suppress("DEPRECATION")
d.window?.setSoftInputMode(
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE or WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN
)
d.behavior.skipCollapsed = true
d.behavior.state = BottomSheetBehavior.STATE_EXPANDED
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
private const val SHEET_HEIGHT_FRACTION = 0.8f
/** [transferArgs] are the arguments of a TransferFragment built by one of its factories. */
fun newInstance(transferArgs: Bundle) = TransferSheetFragment().apply { arguments = transferArgs }
}
}
@@ -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.
@@ -351,7 +409,6 @@ class BmlTransferHandler(
) {
fragment.clearForm()
host?.triggerRefresh()
fragment.returnToCallerIfRequested()
}
} else {
dialog.dismiss()
@@ -361,6 +418,177 @@ 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> =
BmlVerifiedCards.payable(ctx, viewModel.accounts.value ?: emptyList())
/**
* 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 = BmlVerifiedCards.isPayable(ctx, account)
/** 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)
}
/**
* The card payment's confirm dialog (biometric-gated), then the Pomelo + 3-D Secure payment of
* [page] with [src]. Also the send step for carrier services paid by card
* ([CardPayoutTransferHandler]), once the carrier has created the BML transaction.
*/
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 (card, otpSeed) = BmlVerifiedCards.load(ctx, src) ?: run {
dialog.dismiss()
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
return
}
fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching {
BmlMerchantCardPayClient().pay(page, card) { retry ->
// A code about to roll over can expire before the ACS checks it, and a
// retry in the same window would resend the rejected code: wait for the
// next window in both cases. Runs on IO.
val left = TOTP_WINDOW_MS - System.currentTimeMillis() % TOTP_WINDOW_MS
if (retry || left < TOTP_MIN_LEFT_MS) Thread.sleep(left + 500)
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()
}
// Charged, but the merchant may not deliver until it's told
if (!result.merchantNotified) {
Toast.makeText(ctx, ctx.getString(R.string.bml_card_pay_merchant_not_notified,
page.merchantName, page.transactionId), Toast.LENGTH_LONG).show()
}
}
is BmlMerchantCardPayClient.Result.Failure -> {
dialog.dismiss()
Toast.makeText(ctx, result.message, Toast.LENGTH_LONG).show()
}
}
}
}
// ─── Personal-profile transfer (token OTP, no user interaction) ──────────
/**
@@ -735,6 +963,12 @@ class BmlTransferHandler(
}
}
private fun isCard(account: BankAccount) =
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
private fun isCard(account: BankAccount) = BmlVerifiedCards.isCard(account)
private companion object {
/** The BML token's TOTP window. */
const val TOTP_WINDOW_MS = 30_000L
/** Don't send a card payment's 3-D Secure code with less than this left in its window. */
const val TOTP_MIN_LEFT_MS = 5_000L
}
}
@@ -0,0 +1,52 @@
package sh.sar.basedbank.ui.home.transfer
import android.content.Context
import sh.sar.basedbank.api.bml.BmlMerchantCardPayClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.VerifiedCardStore
/**
* BML cards that can pay a merchant by card + 3-D Secure: the card is verified (full details in
* [VerifiedCardStore]) and belongs to a BML login we hold the OTP seed for, since the 3-D Secure
* step is answered with that login's token code.
*/
object BmlVerifiedCards {
/** A payable card's details, ready for [BmlMerchantCardPayClient.pay]. */
data class Payable(val card: BmlMerchantCardPayClient.Card, val otpSeed: String)
fun isCard(account: BankAccount) =
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
fun isPayable(ctx: Context, account: BankAccount): Boolean =
isCard(account) && VerifiedCardStore.isVerified(ctx, key(account)) && otpSeed(ctx, account) != null
fun payable(ctx: Context, accounts: List<BankAccount>): List<BankAccount> {
val verified = VerifiedCardStore.keys(ctx)
return accounts.filter { isCard(it) && key(it) in verified && otpSeed(ctx, it) != null }
}
/** The stored card details and OTP seed for [account], or null when it isn't payable. */
fun load(ctx: Context, account: BankAccount): Payable? {
val stored = VerifiedCardStore.load(ctx, key(account)) ?: return null
val seed = otpSeed(ctx, account) ?: return null
val expiry = stored.expiry.split("/") // "MM/YY"
if (expiry.size != 2) return null
return Payable(
card = BmlMerchantCardPayClient.Card(
pan = stored.pan,
expiryMonth = expiry[0].padStart(2, '0'),
expiryYear = expiry[1].takeLast(2),
cvv = stored.cvv,
holderName = account.accountBriefName
),
otpSeed = seed
)
}
private fun key(account: BankAccount) = "bml:${account.accountNumber}"
private fun otpSeed(ctx: Context, account: BankAccount) =
CredentialStore(ctx).loadBmlCredentials(account.loginTag.removePrefix("bml_"))?.otpSeed
}
@@ -0,0 +1,217 @@
package sh.sar.basedbank.ui.home.transfer
import android.widget.Toast
import androidx.annotation.DrawableRes
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
import sh.sar.basedbank.api.dhiraagu.DhiraaguPaymentClient
import sh.sar.basedbank.api.fahipay.OoredooClient
import sh.sar.basedbank.api.ooredoo.OoredooPaymentClient
import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.ui.home.HomeViewModel
import sh.sar.basedbank.ui.home.TransferFragment
import java.math.BigDecimal
import java.math.RoundingMode
/**
* A carrier service a verified BML card can pay, through the carrier's own website and its BML
* merchant gateway. The limits are the carrier website's, not Fahipay's.
*
* Add new services as constants; the exhaustive `when`s over this enum point at every site that
* needs updating.
*/
enum class CardPayoutService(
override val label: String,
override val destinationLabel: String,
@param:DrawableRes override val iconRes: Int,
override val minAmount: Int,
override val maxAmount: Int?,
override val decimalsAllowed: Boolean,
override val gstPercent: Int?,
/**
* Tags the recents paid with this service (`RecentPick.contactCategory`), so picking one
* again pays the same way. Not a real contact list: card payouts have no favourites.
*/
val contactCategory: String,
override val gstAdded: Boolean = false,
) : PayoutService {
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo,
minAmount = 20, maxAmount = 1000, decimalsAllowed = false, gstPercent = 8,
contactCategory = "CARD_DHIRAAGU_RELOAD") {
// Dhiraagu rounds the GST to 2 places and credits the rest
override fun creditedAfterGst(amount: BigDecimal): BigDecimal =
amount - DhiraaguPaymentClient.gstOf(amount.setScale(0, RoundingMode.DOWN).toInt())
},
// Easy Pay sets no limits of its own: any amount with up to 2 decimals, no GST
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo,
minAmount = 1, maxAmount = null, decimalsAllowed = true, gstPercent = null,
contactCategory = "CARD_DHIRAAGU_BILL"),
// Ooredoo credits the whole amount and charges the card 8% GST on top
OOREDOO_RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo,
minAmount = 20, maxAmount = null, decimalsAllowed = false, gstPercent = 8,
contactCategory = "CARD_RAASTAS", gstAdded = true),
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo,
minAmount = 10, maxAmount = null, decimalsAllowed = true, gstPercent = null,
contactCategory = "CARD_OOREDOO_BILL");
companion object {
/** The service a recent was paid with, from its [contactCategory], or null when it isn't one. */
fun fromContactCategory(categoryId: String?): CardPayoutService? =
entries.firstOrNull { it.contactCategory == categoryId }
}
}
/**
* Owns the Transfer screen's "carrier service by BML card" parts: which services a looked-up
* number can be paid with by card, and the picked service's amount rules.
*
* Sending only differs from paying a card-only BML merchant link in where the BML transaction
* comes from: the carrier's website creates it for the number and amount. From there it goes
* through [BmlTransferHandler.confirmCardMerchant] — the same confirm dialog, card + 3-D Secure
* payment and result as a pasted link.
*
* Mirrors [FahipayTransferHandler]: the fragment keeps the recipient card and the form state.
* Lifetime is bound to the fragment's view.
*/
class CardPayoutTransferHandler(
private val fragment: TransferFragment,
private val binding: FragmentTransferBinding,
private val viewModel: HomeViewModel,
private val bmlHandler: () -> BmlTransferHandler,
) {
private val ctx get() = fragment.requireContext()
private val amountField = PayoutAmountField(binding) { ctx }
/** The service picked for the current recipient; null when none is. */
var service: CardPayoutService?
get() = viewModel.transferDraft.cardPayoutService
private set(value) { viewModel.transferDraft.cardPayoutService = value }
// ─── Public API the fragment calls ───────────────────────────────────────
/** Account numbers of the cards that can pay by card. Call on the main thread. */
fun payableCards(): Set<String> =
BmlVerifiedCards.payable(ctx, viewModel.accounts.value ?: emptyList())
.map { it.accountNumber }.toSet()
/** The card transfer types a carrier lookup [result] allows, payable from [cards]. */
fun typesFor(result: CarrierLookup.Result, cards: Set<String>): List<TransferType.Card> {
if (cards.isEmpty()) return emptyList()
return buildList {
when (result.dhiraagu.type) {
DhiraaguClient.CustType.RELOAD -> add(CardPayoutService.DHIRAAGU_RELOAD)
DhiraaguClient.CustType.BILL_PAY -> add(CardPayoutService.DHIRAAGU_BILL)
DhiraaguClient.CustType.UNSUPPORTED -> {}
}
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) {
add(CardPayoutService.OOREDOO_RAASTAS)
}
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) {
add(CardPayoutService.OOREDOO_BILL)
}
}.map { TransferType.Card(it, result.ownerName, cards) }
}
/** Forgets the picked service and gives back the amount and reference fields. */
fun clearState() {
if (service == null) return
service = null
amountField.reset()
}
/** Why the typed amount can't be sent with the picked service, or null when it can. */
val amountProblem: String?
get() = service?.let(amountField::problem)
/** Keeps the amount and reference fields in step with the picked service. */
fun syncAmountField() {
service?.let(amountField::sync)
}
/**
* Fills the recipient card for a picked card transfer type. The fragment has already
* switched the source to one of the type's cards.
*/
fun applyService(type: TransferType.Card, number: String) {
service = type.service
// None of the payouts take a reference; syncAmountField() disables the box
binding.etRemarks.setText("")
val contacts = viewModel.contacts.value ?: emptyList()
val displayName = type.ownerName
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
?: number
fragment.prefillToDirectly(
accountNumber = number,
displayName = displayName,
subtitle = "${type.label} · $number",
colorHex = "#E4002B",
imageHash = null,
contactCategory = type.service.contactCategory
)
fragment.focusAmount()
}
// ─── Send ────────────────────────────────────────────────────────────────
/**
* Has the carrier create the BML transaction for the number and amount, then hands its
* payment page to the card merchant flow. Nothing is charged until that flow's confirm.
*/
fun submit() {
val svc = service ?: return
val src = viewModel.transferDraft.selectedAccount
if (src == null || !BmlVerifiedCards.isPayable(ctx, src)) {
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
return
}
val number = viewModel.transferDraft.transferTypeNumber
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
if (number.isBlank() || amount == null || amount.signum() <= 0 || amountProblem != null) return
val charged = svc.chargedWithGst(amount)
// Creating the order takes a few round trips; show the payment's processing box meanwhile
val processing = fragment.showProcessingDialog(ctx.getString(R.string.transfer))
fragment.viewLifecycleOwner.lifecycleScope.launch {
val page = withContext(Dispatchers.IO) {
runCatching {
val txnId = when (svc) {
CardPayoutService.DHIRAAGU_RELOAD ->
DhiraaguPaymentClient().createReloadTransaction(number, amount.intValueExact())
CardPayoutService.DHIRAAGU_BILL ->
DhiraaguPaymentClient().createBillPayTransaction(number, amount)
CardPayoutService.OOREDOO_RAASTAS ->
OoredooPaymentClient().createRaastasTransaction(number, amount.intValueExact(), charged)
CardPayoutService.OOREDOO_BILL ->
OoredooPaymentClient().createBillPayTransaction(number, amount)
}
BmlMerchantTxnClient().fetchPayPage(txnId)
}
}
processing.dismiss()
if (fragment.view == null) return@launch
page.onSuccess {
when {
!it.supportsCard ->
Toast.makeText(ctx, R.string.transfer_bml_txn_lookup_failed, Toast.LENGTH_LONG).show()
// The carrier's order must be for exactly what was typed (plus GST, if added)
it.amount.toBigDecimal().compareTo(charged) != 0 ->
Toast.makeText(ctx, R.string.transfer_bml_txn_lookup_failed, Toast.LENGTH_LONG).show()
else -> bmlHandler().confirmCardMerchant(it, src)
}
}.onFailure { e ->
val msg = when {
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
!e.message.isNullOrBlank() -> e.message!!
else -> ctx.getString(R.string.transfer_bml_txn_lookup_failed)
}
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
}
}
}
}
@@ -0,0 +1,46 @@
package sh.sar.basedbank.ui.home.transfer
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
import sh.sar.basedbank.api.fahipay.OoredooClient
/**
* Which carrier a phone number is on, and how it's billed. Feeds both payout routes: the
* Fahipay services and the BML card services each map this to what they can pay.
*/
object CarrierLookup {
data class Result(
val dhiraagu: DhiraaguClient.Result,
val ooredoo: OoredooClient.CustType,
) {
/** Dhiraagu is the only carrier that hands back an owner name. */
val ownerName: String? get() = dhiraagu.ownerName.takeIf { it.isNotBlank() }
}
/**
* Asks the likelier carrier first based on the leading digit and only falls back to the
* other when the first says it doesn't know the number. Blocking — call from IO.
*/
fun query(number: String): Result =
if (number.startsWith("7")) {
// Dhiraagu first, fall back to Ooredoo
val d = dhiraagu(number)
val o = if (d.type == DhiraaguClient.CustType.UNSUPPORTED) ooredoo(number)
else OoredooClient.CustType.UNSUPPORTED
Result(d, o)
} else {
// Ooredoo first, fall back to Dhiraagu
val o = ooredoo(number)
val d = if (o == OoredooClient.CustType.UNSUPPORTED) dhiraagu(number)
else DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED)
Result(d, o)
}
private fun dhiraagu(number: String) =
try { DhiraaguClient().validateNumber(number) }
catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) }
private fun ooredoo(number: String) =
try { OoredooClient().validateNumber(number) }
catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED }
}
@@ -1,46 +1,86 @@
package sh.sar.basedbank.ui.home.transfer
import android.view.View
import android.widget.Toast
import androidx.annotation.DrawableRes
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
import sh.sar.basedbank.api.fahipay.FahipayPaymentClient
import sh.sar.basedbank.api.fahipay.OoredooClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.ui.home.HomeActivity
import sh.sar.basedbank.ui.home.HomeViewModel
import sh.sar.basedbank.ui.home.TransferFragment
import sh.sar.basedbank.util.AccountInputParser
import sh.sar.basedbank.util.CredentialStore
/**
* A service a Fahipay wallet can pay out to. The carrier lookup decides which of these apply to
* a given number; [label] names it in the recipient card, [destinationLabel] in the confirm
* dialog's "To" block.
* a given number. The limits are Fahipay's — the same carrier service paid by card
* ([CardPayoutService]) has its own.
*
* Wallet-to-wallet Fahipay transfer is not here yet — there is no send path for it (see the
* class KDoc on [FahipayTransferHandler]). Add it as a constant once that lands, and the
* exhaustive `when`s over this enum will point at every site that needs updating.
* Wallet-to-wallet Fahipay transfer is not here yet. Add it as a constant once its send path
* lands, and the exhaustive `when`s over this enum will point at every site that needs updating.
*/
enum class FahipayService(val label: String, val destinationLabel: String) {
RAASTAS("Raastas", "Ooredoo · Raastas"),
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay"),
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload"),
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay"),
enum class FahipayService(
override val label: String,
override val destinationLabel: String,
@param:DrawableRes override val iconRes: Int,
/** The endpoint [FahipayPaymentClient.pay] POSTs this service's payments to. */
val paymentPath: String,
override val minAmount: Int,
override val maxAmount: Int?,
override val decimalsAllowed: Boolean,
/**
* The contact category of this service's Fahipay favourites list (`BankContact.benefCategoryId`,
* set by `FahipayContactsClient`). Recents of this service are tagged with it too.
*/
val contactCategory: String,
override val gstPercent: Int? = null,
) : PayoutService {
RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo,
paymentPath = "actions/payment/ooredoo/recharge/",
minAmount = 11, maxAmount = null, decimalsAllowed = false,
contactCategory = "FAHIPAY_RAASTAS", gstPercent = 8),
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo,
paymentPath = "actions/payment/ooredoo/billpay/",
minAmount = 10, maxAmount = 50000, decimalsAllowed = true,
contactCategory = "FAHIPAY_OOREDOO_BILL"),
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo,
paymentPath = "actions/payment/dhiraagu/recharge/",
minAmount = 8, maxAmount = 1000, decimalsAllowed = false,
contactCategory = "FAHIPAY_RELOAD"),
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo,
paymentPath = "actions/payment/dhiraagu/billpay/",
minAmount = 10, maxAmount = 5000, decimalsAllowed = false,
contactCategory = "FAHIPAY_DHIRAAGU_BILL");
companion object {
/**
* The service a saved Fahipay favourite or recent pays with, from its [contactCategory],
* or null when [categoryId] isn't one of them.
*/
fun fromContactCategory(categoryId: String?): FahipayService? =
entries.firstOrNull { it.contactCategory == categoryId }
}
}
/**
* Owns the Fahipay-only parts of the Transfer screen: the carrier lookup that turns a phone
* number into a set of payable services, the chip picker shown when more than one applies, and
* the selected service that the confirm dialog labels the destination with.
* number into a set of payable services, offering those in the fragment's "Transfer Type"
* picker, and the selected service that the confirm dialog labels the destination with.
*
* Mirrors [BmlTransferHandler] / [MfaisaTransferHandler]: the fragment keeps the shared confirm
* dialog, the recipient card and the form state; the handler keeps everything Fahipay-specific.
*
* **There is no send path yet.** A Fahipay source currently falls through to the MIB branch of
* `initiateTransfer`, which signs the request with a MIB session. When the real payout API is
* wired up it belongs here, as a `doTransfer(...)` alongside the lookup — same shape as the
* other handlers.
* [submit] sends the payment through [FahipayPaymentClient], with the shared confirm dialog
* and an in-dialog success screen (no receipt page yet).
*
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment]
* (for `viewLifecycleOwner` and Context) — and must be re-created when the view is recreated.
@@ -54,8 +94,11 @@ 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 }
private val amountField = PayoutAmountField(binding) { ctx }
/** How the confirm dialog names the destination, or "" when nothing is selected. */
val destinationLabel: String get() = service?.destinationLabel.orEmpty()
@@ -74,126 +117,166 @@ class FahipayTransferHandler(
lookupCarrier(rawInput)
}
/** Clears the selected service and hides the chip picker. */
/**
* Forgets the selected service and gives back the amount and reference fields it took over.
* The picker itself is the fragment's to reset.
*/
fun clearState() {
if (service == null) return
service = null
binding.layoutServiceSelector.visibility = View.INVISIBLE
}
// ─── Carrier lookup ──────────────────────────────────────────────────────
private data class CarrierResult(
val dhiraagu: DhiraaguClient.Result,
val ooredoo: OoredooClient.CustType
)
private fun lookupCarrier(number: String) {
fragment.startLookupLoading()
fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) { queryCarriers(number) }
fragment.stopLookupLoading()
val dhiraaguName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() }
val services = servicesFor(result)
if (services.isEmpty()) return@launch
// Only one option — auto-select, no chip UI needed
if (services.size == 1) {
selectService(services[0], number, dhiraaguName)
return@launch
}
// Multiple options (Ooredoo HYBRID) — show chips
showServiceChips(services, number, dhiraaguName)
}
amountField.reset()
}
/**
* Asks the likelier carrier first based on the leading digit and only falls back to the
* other when the first says it doesn't know the number. Blocking — call from IO.
* Why the typed amount can't be sent with the selected service, or null when it can (or the
* field is empty, or no service is selected).
*/
private fun queryCarriers(number: String): CarrierResult =
if (number.startsWith("7")) {
// Dhiraagu first, fall back to Ooredoo
val d = dhiraagu(number)
val o = if (d.type == DhiraaguClient.CustType.UNSUPPORTED) ooredoo(number)
else OoredooClient.CustType.UNSUPPORTED
CarrierResult(d, o)
} else {
// Ooredoo first, fall back to Dhiraagu
val o = ooredoo(number)
val d = if (o == OoredooClient.CustType.UNSUPPORTED) dhiraagu(number)
else DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED)
CarrierResult(d, o)
}
val amountProblem: String?
get() = service?.let(amountField::problem)
private fun dhiraagu(number: String) =
try { DhiraaguClient().validateNumber(number) }
catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) }
private fun ooredoo(number: String) =
try { OoredooClient().validateNumber(number) }
catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED }
private fun servicesFor(result: CarrierResult): List<FahipayService> = buildList {
if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add(FahipayService.DHIRAAGU_RELOAD)
if (result.dhiraagu.type == DhiraaguClient.CustType.BILL_PAY) add(FahipayService.DHIRAAGU_BILL)
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.RAASTAS)
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.OOREDOO_BILL)
/**
* Keeps the amount and reference fields in step with the selected service. A no-op when no
* service is selected; [clearState] puts the fields back.
*/
fun syncAmountField() {
service?.let(amountField::sync)
}
// ─── Service picker ──────────────────────────────────────────────────────
private fun showServiceChips(
services: List<FahipayService>,
number: String,
dhiraaguName: String?
) {
binding.chipDhiraaguReload.visibility = visibilityFor(FahipayService.DHIRAAGU_RELOAD in services)
binding.chipDhiraaguBill.visibility = visibilityFor(FahipayService.DHIRAAGU_BILL in services)
binding.chipRaastas.visibility = visibilityFor(FahipayService.RAASTAS in services)
binding.chipOoredooBill.visibility = visibilityFor(FahipayService.OOREDOO_BILL in services)
binding.layoutServiceSelector.visibility = View.VISIBLE
binding.chipGroupService.clearCheck()
// Dhiraagu is the only carrier that hands back an owner name, so the Ooredoo chips
// resolve their display name from saved contacts instead.
bindChip(binding.chipDhiraaguReload, FahipayService.DHIRAAGU_RELOAD, number, dhiraaguName)
bindChip(binding.chipDhiraaguBill, FahipayService.DHIRAAGU_BILL, number, dhiraaguName)
bindChip(binding.chipRaastas, FahipayService.RAASTAS, number, null)
bindChip(binding.chipOoredooBill, FahipayService.OOREDOO_BILL, number, null)
}
private fun bindChip(
chip: com.google.android.material.chip.Chip,
picked: FahipayService,
number: String,
ownerName: String?
) {
chip.setOnCheckedChangeListener { _, checked ->
if (checked) {
selectService(picked, number, ownerName)
binding.layoutServiceSelector.visibility = View.INVISIBLE
}
}
}
private fun visibilityFor(shown: Boolean) = if (shown) View.VISIBLE else View.GONE
private fun selectService(picked: FahipayService, number: String, ownerName: String?) {
service = picked
/**
* Fills the recipient card for a picked Fahipay transfer type. The fragment has already
* switched the source to a Fahipay account.
*/
fun applyService(type: TransferType.Fahipay, number: String) {
service = type.service
// None of the payouts take a reference; syncAmountField() disables the box
binding.etRemarks.setText("")
val contacts = viewModel.contacts.value ?: emptyList()
val displayName = ownerName
val displayName = type.ownerName
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
?: number
fragment.prefillToDirectly(
accountNumber = number,
displayName = displayName,
subtitle = "${picked.label} · $number",
subtitle = "${type.label} · $number",
colorHex = "#FF6B00",
imageHash = null
imageHash = null,
contactCategory = type.service.contactCategory
)
fragment.focusAmount()
}
/** The Fahipay transfer types a carrier lookup [result] allows. */
fun typesFor(result: CarrierLookup.Result): List<TransferType.Fahipay> =
servicesFor(result).map { TransferType.Fahipay(it, result.ownerName) }
// ─── Send ────────────────────────────────────────────────────────────────
/**
* Pays the picked service: confirm dialog (with the GST note for services that charge it),
* biometric gate, then the payment POST. Success shows in the dialog; a refusal closes it
* and toasts the server's message.
*/
fun submit() {
val svc = service ?: return
val src = viewModel.transferDraft.selectedAccount?.takeIf { it.bank == "FAHIPAY" } ?: run {
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val number = viewModel.transferDraft.transferTypeNumber
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
if (number.isBlank() || amount == null || amount.signum() <= 0 || amountProblem != null) return
// Whole-number services get "11", never "11.00"
val amountParam = amount.stripTrailingZeros().toPlainString()
val amountDisplay = "%,.2f".format(amount)
val toName = binding.tvToAccountName.text?.toString().orEmpty().ifBlank { number }
val confirmView = fragment.buildTransferConfirmView(
amountCurrency = "MVR",
amountValue = amountDisplay,
fromName = src.accountBriefName,
fromNumber = src.accountNumber,
fromDetail = "Fahipay",
toName = toName,
toNumber = number,
toDetail = svc.destinationLabel,
warningTexts = listOfNotNull(amountField.gstNote(svc))
)
fragment.showConfirmWithBiometric(
title = ctx.getString(R.string.transfer),
customView = confirmView,
biometricSubtitle = "MVR $amountDisplay → ${svc.label} $number",
onConfirmed = { dialog, frame ->
fragment.showProcessingInDialog(dialog, frame)
pay(src, svc, number, amountParam, amountDisplay, toName, dialog, frame)
}
)
}
private fun pay(
src: BankAccount,
svc: FahipayService,
number: String,
amountParam: String,
amountDisplay: String,
toName: String,
dialog: AlertDialog,
frame: android.widget.FrameLayout,
) {
val app = fragment.requireActivity().application as BasedBankApp
val session = app.fahipaySessionFor(src) ?: run {
dialog.dismiss()
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val deviceUuid = CredentialStore(ctx).getOrCreateFahipayDeviceUuid()
binding.btnTransfer.isEnabled = false
fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching { FahipayPaymentClient().pay(session, svc.paymentPath, number, amountParam, deviceUuid) }
}
if (fragment.view == null) return@launch
result.onSuccess {
fragment.showSuccessInDialog(
dialog, frame,
amountCurrency = "MVR",
amountValue = amountDisplay,
fromName = src.accountBriefName,
toName = "$toName · ${svc.label}"
) {
fragment.clearForm()
(fragment.activity as? HomeActivity)?.triggerRefresh()
}
}.onFailure { e ->
dialog.dismiss()
binding.btnTransfer.isEnabled = true
val msg = when {
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
!e.message.isNullOrBlank() -> e.message!!
else -> "Payment failed"
}
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
}
}
}
// ─── Carrier lookup ──────────────────────────────────────────────────────
private fun lookupCarrier(number: String) {
fragment.resetTransferTypes()
fragment.startLookupLoading()
fragment.viewLifecycleOwner.lifecycleScope.launch {
val types = withContext(Dispatchers.IO) { typesFor(CarrierLookup.query(number)) }
fragment.stopLookupLoading()
if (types.isEmpty()) return@launch
fragment.offerTransferTypes(number, types)
}
}
private fun servicesFor(result: CarrierLookup.Result): List<FahipayService> = buildList {
if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add(FahipayService.DHIRAAGU_RELOAD)
if (result.dhiraagu.type == DhiraaguClient.CustType.BILL_PAY) add(FahipayService.DHIRAAGU_BILL)
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.RAASTAS)
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.OOREDOO_BILL)
}
}
@@ -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,130 @@
package sh.sar.basedbank.ui.home.transfer
import android.content.Context
import android.text.InputType
import androidx.annotation.DrawableRes
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentTransferBinding
import java.math.BigDecimal
import java.math.RoundingMode
/**
* A carrier service a phone number can be paid with (reload, Raastas, bill pay), whichever
* route pays it — the Fahipay wallet ([FahipayService]) or a verified BML card
* ([CardPayoutService]). Each route has its own limits, so each has its own constants.
*/
interface PayoutService {
/** Names it in the "Transfer Type" picker and the recipient card. */
val label: String
/** Names it in the confirm dialog's "To" block, e.g. "Ooredoo · Raastas". */
val destinationLabel: String
@get:DrawableRes val iconRes: Int
/** Smallest amount the service accepts, in MVR. */
val minAmount: Int
/** Largest amount the service accepts, in MVR, or null for no limit. */
val maxAmount: Int?
/** Whether the amount may have a fractional part (up to 2 decimal places). */
val decimalsAllowed: Boolean
/** GST the carrier charges, in percent, or null for none. [gstAdded] says how. */
val gstPercent: Int?
/**
* False (the usual): GST comes out of the amount, so the number is credited less
* ([creditedAfterGst]). True: the number is credited the whole amount and GST is charged on
* top of it ([chargedWithGst]).
*/
val gstAdded: Boolean get() = false
/**
* What the number is credited for a GST-inclusive [amount]: amount / (1 + rate), rounded
* down. Null when the service charges no GST, or adds it on top.
*/
fun creditedAfterGst(amount: BigDecimal): BigDecimal? {
val gst = gstPercent ?: return null
if (gstAdded) return null
return amount.divide(BigDecimal.ONE + BigDecimal(gst).movePointLeft(2), 2, RoundingMode.DOWN)
}
/**
* What is paid for [amount]: amount + round2(amount × rate) when GST is added on top,
* otherwise [amount] itself.
*/
fun chargedWithGst(amount: BigDecimal): BigDecimal {
val gst = gstPercent?.takeIf { gstAdded } ?: return amount
return amount + (amount * BigDecimal(gst).movePointLeft(2)).setScale(2, RoundingMode.HALF_UP)
}
}
/**
* Keeps the Transfer screen's amount and reference fields in step with a picked
* [PayoutService]: the amount error, the GST note under the amount, the keypad, and the
* reference box disabled (none of the payouts take one). Shared by the Fahipay and card routes.
*/
class PayoutAmountField(
private val binding: FragmentTransferBinding,
private val context: () -> Context,
) {
/**
* Why the typed amount can't be sent with [svc], or null when it can (or the field is
* empty). Checks the service's minimum, maximum and whether it takes decimals.
*/
fun problem(svc: PayoutService): String? {
val ctx = context()
val text = binding.etAmount.text?.toString()?.trim().orEmpty()
if (text.isEmpty()) return null
val amount = text.toBigDecimalOrNull() ?: return ctx.getString(R.string.transfer_fahipay_amount_invalid)
val fraction = amount.stripTrailingZeros().scale()
return when {
!svc.decimalsAllowed && fraction > 0 -> ctx.getString(R.string.transfer_fahipay_amount_whole)
fraction > 2 -> ctx.getString(R.string.transfer_fahipay_amount_decimals)
amount < BigDecimal(svc.minAmount) -> ctx.getString(R.string.transfer_fahipay_amount_min, svc.minAmount)
svc.maxAmount != null && amount > BigDecimal(svc.maxAmount!!) ->
ctx.getString(R.string.transfer_fahipay_amount_max, "%,d".format(svc.maxAmount))
else -> null
}
}
/** Idempotent — the fragment calls it on every form change. */
fun sync(svc: PayoutService) {
// Whole-number services get a keypad without a decimal point. Only set on change:
// setting inputType restarts the keyboard, and this runs on every keystroke.
val inputType = if (svc.decimalsAllowed) DECIMAL_INPUT else InputType.TYPE_CLASS_NUMBER
if (binding.etAmount.inputType != inputType) binding.etAmount.inputType = inputType
binding.tilRemarks.isEnabled = false
binding.tilRemarks.alpha = 0.4f
val problem = problem(svc)
binding.tilAmount.error = problem
binding.tilAmount.helperText = if (problem == null) gstNote(svc) else null
}
/**
* For services that charge GST: what the recipient is credited once it comes out, or what
* is paid once it's added on top.
*/
fun gstNote(svc: PayoutService): String? {
val gst = svc.gstPercent ?: return null
val ctx = context()
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
if (svc.gstAdded) {
if (amount == null || amount.signum() <= 0) return ctx.getString(R.string.transfer_gst_added_hint, gst)
return ctx.getString(R.string.transfer_gst_added_pay, "%,.2f".format(svc.chargedWithGst(amount)), gst)
}
if (amount == null || amount.signum() <= 0) return ctx.getString(R.string.transfer_fahipay_gst_hint, gst)
val credited = svc.creditedAfterGst(amount) ?: return null
return ctx.getString(R.string.transfer_fahipay_gst_receive, "%,.2f".format(credited), gst)
}
/** Gives back the amount and reference fields once no service is picked. */
fun reset() {
binding.tilAmount.error = null
binding.tilAmount.helperText = null
binding.etAmount.inputType = DECIMAL_INPUT
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
}
private companion object {
/** The amount field's input type from `fragment_transfer.xml` (`numberDecimal`). */
const val DECIMAL_INPUT = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
}
}
@@ -0,0 +1,63 @@
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
// Transfer type — options the "To" lookup offered for [transferTypeNumber], and the pick
var transferTypes: List<TransferType> = emptyList()
var transferTypeNumber = ""
var transferType: TransferType? = null
// Fahipay
var fahipayService: FahipayService? = null
// Carrier service paid by verified BML card
var cardPayoutService: CardPayoutService? = null
}
@@ -0,0 +1,55 @@
package sh.sar.basedbank.ui.home.transfer
import androidx.annotation.DrawableRes
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
import sh.sar.basedbank.api.models.BankAccount
/**
* One option in the Transfer screen's "Transfer Type" picker: a way of paying the number in
* the "To" field. Picking one also decides the source account — see [worksFrom].
*
* Add new kinds as subclasses; the exhaustive `when`s over this type point at every site that
* needs updating.
*/
sealed interface TransferType {
val label: String
/** Second line in the picker: who gets paid, and how. */
val subtitle: String
@get:DrawableRes val iconRes: Int
/** Small logo on the icon's bottom corner — the wallet that pays it — or null for none. */
@get:DrawableRes val badgeRes: Int? get() = null
/** Whether [account] can be the source for this transfer type. */
fun worksFrom(account: BankAccount): Boolean
/** A bank transfer to the account the Favara ID (phone number) resolved to. */
data class Favara(val info: MibIpsAccountInfo) : TransferType {
override val label get() = "Favara Transfer"
override val subtitle get() = info.accountName
override val iconRes get() = R.drawable.favara_logo
override fun worksFrom(account: BankAccount) = account.bank == "MIB" || account.bank == "BML"
}
/** A Fahipay payout (reload, Raastas, bill pay) to the phone number. */
data class Fahipay(val service: FahipayService, val ownerName: String?) : TransferType {
override val label get() = service.label
override val subtitle get() = listOfNotNull(ownerName, "via Fahipay").joinToString(" · ")
override val iconRes get() = service.iconRes
override val badgeRes get() = R.drawable.fahipay_logo
override fun worksFrom(account: BankAccount) = account.bank == "FAHIPAY"
}
/**
* A carrier service (reload, …) paid by verified BML card through the carrier's own BML
* merchant gateway. [cards] are the account numbers of the cards that can pay it — verified,
* with an OTP seed for the 3-D Secure step — worked out when the lookup ran.
*/
data class Card(val service: CardPayoutService, val ownerName: String?, val cards: Set<String>) : TransferType {
override val label get() = service.label
override val subtitle get() = listOfNotNull(ownerName, "by BML card").joinToString(" · ")
override val iconRes get() = service.iconRes
override val badgeRes get() = R.drawable.bml_logo_vector
override fun worksFrom(account: BankAccount) = account.bank == "BML" && account.accountNumber in cards
}
}
@@ -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
}
}
@@ -1,85 +0,0 @@
package sh.sar.basedbank.util
import android.content.Context
import org.json.JSONObject
import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.bml.BmlValidateClient
import java.io.File
/**
* Encrypted directory of account number → real account-holder name and currency, looked up through BML's
* account validation. Saved contacts often carry a nickname, while bank history only shows the
* real name; this lets a name-only transfer be matched to the right account.
*/
object AccountNameStore {
private const val FILE_NAME = "account_names.json"
private val lock = Any()
/** Names are re-checked after this long, in case an account changes hands. */
private const val REFRESH_AFTER_MS = 30L * 24 * 60 * 60 * 1000
/** Failed lookups (closed or foreign accounts) are retried after this long. */
private const val RETRY_FAILED_AFTER_MS = 24L * 60 * 60 * 1000
/** Upper bound on lookups per call, so a big contact list is resolved over several syncs. */
private const val MAX_LOOKUPS_PER_RUN = 20
data class Info(val name: String, val currency: String)
private data class Entry(val name: String, val currency: String, val checkedAt: Long)
/** Account number → real name and currency, for every account resolved so far. */
fun all(context: Context): Map<String, Info> = synchronized(lock) {
load(context).filterValues { it.name.isNotBlank() }.mapValues { Info(it.value.name, it.value.currency) }
}
/** Looks up names for [accounts] that are unknown or stale. Network I/O — call on Dispatchers.IO. */
fun resolve(context: Context, session: BmlSession, accounts: Collection<String>) {
val now = System.currentTimeMillis()
val known = synchronized(lock) { load(context) }
val due = accounts.filter { it.isNotBlank() }.distinct().filter { account ->
val e = known[account] ?: return@filter true
val age = now - e.checkedAt
if (e.name.isBlank()) age > RETRY_FAILED_AFTER_MS else age > REFRESH_AFTER_MS
}.take(MAX_LOOKUPS_PER_RUN)
if (due.isEmpty()) return
val client = BmlValidateClient()
// null = network error: leave the account due for next time. A failed lookup is stored blank.
val found = due.associateWith { account ->
try {
val v = client.validateAccount(session, account)
Entry(v?.name?.trim().orEmpty(), v?.currency.orEmpty(), now)
} catch (_: Exception) { null }
}
synchronized(lock) {
val current = load(context).toMutableMap()
found.forEach { (account, entry) -> if (entry != null) current[account] = entry }
save(context, current)
}
}
fun clearAll(context: Context) = synchronized(lock) { File(context.filesDir, FILE_NAME).delete() }
private fun load(context: Context): Map<String, Entry> {
val file = File(context.filesDir, FILE_NAME)
if (!file.exists()) return emptyMap()
return try {
val root = JSONObject(CacheEncryption.decrypt(file.readText()))
root.keys().asSequence().associateWith { key ->
val o = root.getJSONObject(key)
Entry(o.optString("name"), o.optString("currency"), o.optLong("checkedAt", 0L))
}
} catch (_: Exception) { emptyMap() }
}
private fun save(context: Context, entries: Map<String, Entry>) {
try {
val root = JSONObject()
entries.forEach { (account, e) ->
root.put(account, JSONObject().put("name", e.name).put("currency", e.currency).put("checkedAt", e.checkedAt))
}
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString()))
} catch (_: Exception) {}
}
}
@@ -1,524 +0,0 @@
package sh.sar.basedbank.util
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.bml.BmlNotificationsClient
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.ui.home.AppNotification
import sh.sar.basedbank.ui.home.AccountHistoryAdapter
import java.io.File
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
/**
* One transfer shown as a chat bubble. BML history only carries the counterparty's name, so
* [peerKey] is the normalised name; [ChatStore.threads] groups by the account holder's real name.
*/
data class ChatMessage(
val id: String,
val peerKey: String,
val peerName: String,
val amount: Double, // negative = sent, positive = received
val currency: String,
val timeMillis: Long,
val accountNumber: String, // own account the money moved in/out of
val accountDisplayName: String,
val reference: String = "",
val note: String = "", // remarks — only known for transfers sent from this app
val peerAccount: String = "", // only known for transfers sent from this app
val receiptKey: String = "", // ReceiptStore entry (its savedAt) for transfers sent from this app
val dateOnly: Boolean = false, // bank gave only a date (Favara); time of day unknown
val merchant: Boolean = false // card / Scan to Pay payment to a business, not a person
) {
val isSent get() = amount < 0
/** In BML's account history (✓✓); otherwise only seen in a notification or receipt so far (✓). */
val isBooked get() = id.startsWith("bml_")
/** "yyyy-MM-dd HH:mm:ss", the format AccountHistoryAdapter's date helpers expect. */
val date: String get() = DATE_FMT.format(Date(timeMillis))
companion object {
private val DATE_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
}
}
/** One of a person's accounts that can be sent to from their chat. */
data class ChatAccount(
val account: String,
val currency: String, // "MVR", "USD"; blank if not known yet
val contact: BankContact? // saved contact for this account, if any
)
data class ChatThread(
val peerKey: String, // "name:<normalised real name>" or "shop:<normalised business name>"
val peerName: String, // contact nickname, else the real name
val realName: String, // account holder's name as the bank knows it; may be blank
val accounts: List<ChatAccount>, // empty = no known account, sending from the chat is disabled
val messages: List<ChatMessage>, // oldest first
val isMerchant: Boolean = false // a business paid by card / Scan to Pay; nothing to send to
) {
val last get() = messages.last()
val contact: BankContact? get() = accounts.firstNotNullOfOrNull { it.contact }
/** The account last sent to, else the first known one; blank when none is known. */
val peerAccount: String get() =
messages.lastOrNull { it.isSent && it.peerAccount.isNotBlank() }?.peerAccount
?.takeIf { a -> accounts.any { it.account == a } }
?: accounts.firstOrNull()?.account.orEmpty()
}
/**
* Encrypted local record of BML transfers and card / Scan to Pay payments, shown as chats.
*
* Old history is never loaded: the first call to [sync] only stamps the start time, and every
* later sync keeps transfers newer than it. Each sync reads BML's transfer notifications (instant),
* pages back through the latest history of every BML current/savings account (booked later), and
* merges in receipts of transfers sent from this app, keeping one message per transfer.
*/
object ChatStore {
private const val FILE_NAME = "chats.json"
private const val PINS_FILE_NAME = "chat_pins.json"
private const val QR_FILE_NAME = "chat_merchant_qr.json"
/** Prefix [RecentsCache] uses for static BML merchant QRs the user has paid. */
private const val RECENT_QR_PREFIX = "bmlqr:"
private val lock = Any()
private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN")
private val TRANSFER_TYPES = setOf("Transfer Debit", "Transfer Credit", "Favara Debit", "Favara Credit")
/** Card and Scan to Pay payments; narrative2 is the business name. */
private val MERCHANT_TYPES = setOf("Purchase")
/** "You have received MVR 5.00 from NAME to 7730*****1234" */
private val NOTIF_RECEIVED = Regex("""You have received ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (.+) to (\S+)""")
/** "You have sent MVR 5.00 from 7730*****1234 to NAME" */
private val NOTIF_SENT = Regex("""You have sent ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (\S+) to (.+)""")
/** "You have paid MVR 5.00 from 4xxx********xxxx to BUSINESS" (Scan to Pay / card); the name may be cut short. */
private val NOTIF_PAID = Regex("""You have paid ([A-Z]{3}) ([\d,]+(?:\.\d+)?) from (\S+) to (.+)""")
/** Masked own account or card in notifications, e.g. "7730*****1234". */
private val MASKED_ACCOUNT = Regex("""(\d{3,})\*+(\d{3,})""")
/** How far apart two records of the same transfer can be timestamped. */
private const val MATCH_WINDOW_MS = 15 * 60 * 1000L
/** Upper bound on history pages fetched per account in one sync. */
private const val MAX_PAGES_PER_SYNC = 5
private const val NAME_KEY_PREFIX = "name:"
private const val MERCHANT_KEY_PREFIX = "shop:"
private data class State(val sinceMillis: Long, val messages: List<ChatMessage>)
fun normalise(name: String) = name.trim().replace(Regex("\\s+"), " ").lowercase(Locale.US)
fun sinceMillis(context: Context): Long = synchronized(lock) { load(context).sinceMillis }
fun isStarted(context: Context) = sinceMillis(context) > 0L
/**
* Groups stored messages into chats, most recent chat first. Call off the main thread.
*
* Chats are per person, keyed by the account holder's real name: bank history only names the
* sender, never their account, so money can't be told apart per account anyway. Every known
* account with that real name (saved contacts, accounts sent to) belongs to the chat, and the
* user picks which one to send to — typically their MVR or USD account.
*
* Real names come from [AccountNameStore], so a contact saved as "Ali" joins the
* "Mohamed Ali" chat.
*/
fun threads(context: Context, contacts: List<BankContact>): List<ChatThread> {
val messages = synchronized(lock) { load(context).messages }
val lookedUp = AccountNameStore.all(context)
val contactsByAccount = contacts.filter { it.benefAccount.isNotBlank() }.associateBy { it.benefAccount }
fun realNameOf(account: String): String? =
lookedUp[account]?.name?.takeIf { it.isNotBlank() }
?: contactsByAccount[account]?.benefName?.takeIf { it.isNotBlank() }
// Notifications cut business names short ("FAMILY ROOM" for "FAMILY ROOM COFFEE"); file a
// short name under the longest known name it starts.
val merchantNames = messages.filter { it.merchant }.map { it.peerKey }.toSet()
fun merchantKey(name: String) =
merchantNames.filter { it.startsWith(name) }.maxByOrNull { it.length } ?: name
fun keyOf(m: ChatMessage): String {
if (m.merchant) return MERCHANT_KEY_PREFIX + merchantKey(m.peerKey)
val name = m.peerAccount.takeIf { it.isNotBlank() }?.let(::realNameOf) ?: m.peerName
return NAME_KEY_PREFIX + normalise(name)
}
// Every known account, grouped under its holder's real name.
val accountsByKey = HashMap<String, LinkedHashMap<String, ChatAccount>>()
fun addAccount(account: String, fallbackCurrency: String) {
val name = realNameOf(account) ?: return
val contact = contactsByAccount[account]
val currency = lookedUp[account]?.currency?.takeIf { it.isNotBlank() }
?: contact?.transferCyDesc?.takeIf { it.isNotBlank() } ?: fallbackCurrency
accountsByKey.getOrPut(NAME_KEY_PREFIX + normalise(name)) { LinkedHashMap() }
.getOrPut(account) { ChatAccount(account, currency, contact) }
}
contactsByAccount.keys.forEach { addAccount(it, "") }
messages.filter { it.peerAccount.isNotBlank() }.forEach { addAccount(it.peerAccount, it.currency) }
return messages.groupBy(::keyOf).map { (key, msgs) ->
val sorted = msgs.sortedBy { it.timeMillis }
if (key.startsWith(MERCHANT_KEY_PREFIX)) {
// History carries the full business name; notifications may not.
val name = sorted.lastOrNull { it.id.startsWith("bml_") }?.peerName
?: sorted.maxByOrNull { it.peerName.length }!!.peerName
return@map ChatThread(key, name, "", emptyList(), sorted, isMerchant = true)
}
val accounts = accountsByKey[key]?.values?.toList().orEmpty()
// Bank history and the account lookup carry the real name; a receipt only has our label.
val realName = accounts.firstNotNullOfOrNull { lookedUp[it.account]?.name?.takeIf { n -> n.isNotBlank() } }
?: sorted.lastOrNull { !it.id.startsWith("rcpt_") }?.peerName
?: accounts.firstNotNullOfOrNull { it.contact?.benefName?.takeIf { n -> n.isNotBlank() } }
?: ""
val nickname = accounts.firstNotNullOfOrNull { it.contact?.benefNickName?.takeIf { n -> n.isNotBlank() } }
ChatThread(
peerKey = key,
peerName = nickname ?: realName.takeIf { it.isNotBlank() } ?: sorted.last().peerName,
realName = realName,
accounts = accounts,
messages = sorted
)
}.sortedByDescending { it.last.timeMillis }
}
/** Fetches new transfers and stores them. Does network and file I/O — call on Dispatchers.IO. */
suspend fun sync(context: Context, app: BasedBankApp, accounts: List<BankAccount>, contacts: List<BankContact>) {
val state = synchronized(lock) { load(context) }
if (state.sinceMillis == 0L) {
synchronized(lock) { save(context, State(System.currentTimeMillis(), emptyList())) }
return
}
val since = state.sinceMillis
val history = fetchHistory(app, accounts, since, state.messages.mapTo(HashSet()) { it.id })
val notifications = fetchNotifications(context, app, accounts, since)
val receipts = receiptMessages(context, accounts, since)
synchronized(lock) {
val current = load(context)
save(context, current.copy(messages = merge(current.messages, history + notifications + receipts)))
}
// Learn real names for contacts and people we've sent to, so name-only transfers match them.
val session = app.anyBmlSession() ?: return
val sentTo = synchronized(lock) { load(context).messages.map { it.peerAccount } }
// SWIFT (foreign) beneficiaries can't be looked up through BML.
val toResolve = contacts.filter { it.benefType != "S" }.map { it.benefAccount } + sentTo
AccountNameStore.resolve(context, session, toResolve)
}
/**
* Transfers from account history. BML only adds a transaction here once it is booked (often
* the next day), so recent ones come from [fetchNotifications] first.
*/
private suspend fun fetchHistory(
app: BasedBankApp,
accounts: List<BankAccount>,
since: Long,
knownIds: Set<String>
): List<ChatMessage> {
val fetched = mutableListOf<ChatMessage>()
for (account in accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN }) {
val fetcher = HistoryFetcher(account)
var pages = 0
// Page back until we reach transfers we already have or ones older than the start time.
while (pages < MAX_PAGES_PER_SYNC && fetcher.hasMore()) {
val page = try { fetcher.fetchNextPage(app) } catch (_: Exception) { break }
pages++
if (page.isEmpty()) break
var reachedKnown = false
for (trx in page) {
val time = AccountHistoryAdapter.parseDateMillis(trx.date)
if (time in 1 until startOfDay(since) || "bml_${trx.id}" in knownIds) reachedKnown = true
trx.toChatMessage(time, since)?.let { fetched += it }
}
if (reachedKnown) break
}
}
return fetched
}
private fun sh.sar.basedbank.api.models.BankTransaction.toChatMessage(time: Long, since: Long): ChatMessage? {
val name = counterpartyName?.trim().orEmpty()
val merchant = description in MERCHANT_TYPES
if ((description !in TRANSFER_TYPES && !merchant) || name.isBlank()) return null
// Favara (other-bank) entries carry only a date; keep those from the start day onwards.
val dateOnly = date.contains("T00:00:00")
if (if (dateOnly) time < startOfDay(since) else time < since) return null
return ChatMessage(
id = "bml_$id",
peerKey = normalise(name),
peerName = name,
amount = amount,
currency = currency,
timeMillis = time,
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
reference = reference.orEmpty(),
dateOnly = dateOnly,
merchant = merchant
)
}
/**
* Transfers from BML's "Funds Received" / "Funds Transferred" notifications, which arrive
* instantly. Uses the notifications the app has cached plus a fresh first page; the fresh page
* is not written back to the cache, which the notification poller uses to spot new alerts.
*/
private fun fetchNotifications(
context: Context,
app: BasedBankApp,
accounts: List<BankAccount>,
since: Long
): List<ChatMessage> {
val client = BmlNotificationsClient()
val notifications = LinkedHashMap<String, AppNotification>()
for ((loginId, session) in app.bmlSessions.toMap()) {
NotificationsCache.loadBml(context, loginId).forEach { notifications[it.id] = it }
try { client.fetchNotifications(session, loginId, page = 1).items.forEach { notifications[it.id] = it } }
catch (_: Exception) {}
}
return notifications.values
.filter { it.timestampMs >= since }
.mapNotNull { notificationToMessage(it, accounts) }
}
private fun notificationToMessage(n: AppNotification, accounts: List<BankAccount>): ChatMessage? {
val text = n.message.trim()
val received = NOTIF_RECEIVED.matchEntire(text)
val sent = if (received == null) NOTIF_SENT.matchEntire(text) else null
val paid = if (received == null && sent == null) NOTIF_PAID.matchEntire(text) else null
val (currency, amountText, name, ownMasked) = when {
received != null -> received.destructured.let { (c, a, who, own) -> listOf(c, a, who, own) }
sent != null -> sent.destructured.let { (c, a, own, who) -> listOf(c, a, who, own) }
paid != null -> paid.destructured.let { (c, a, own, who) -> listOf(c, a, who, own) }
else -> return null
}
if (MASKED_ACCOUNT.matches(name.trim())) return null // between the user's own accounts
val amount = amountText.replace(",", "").toDoubleOrNull() ?: return null
val own = MASKED_ACCOUNT.matchEntire(ownMasked)?.let { m ->
val (head, tail) = m.destructured
accounts.firstOrNull { it.bank == "BML" && it.accountNumber.startsWith(head) && it.accountNumber.endsWith(tail) }
}
return ChatMessage(
id = "ntf_${n.id}",
peerKey = normalise(name),
peerName = name.trim(),
amount = if (received != null) amount else -amount,
currency = currency,
timeMillis = n.timestampMs,
accountNumber = own?.accountNumber.orEmpty(),
accountDisplayName = own?.accountBriefName ?: "•••• ${ownMasked.takeLast(4)}",
merchant = paid != null
)
}
/** Transfers sent from this app, from their saved receipts: these carry the note and slip. */
private fun receiptMessages(context: Context, accounts: List<BankAccount>, since: Long): List<ChatMessage> =
ReceiptStore.loadAll(context).mapNotNull { entry ->
val r = entry.data
if (r.bank != "BML" || entry.savedAt < since) return@mapNotNull null
val amount = r.amount.toDoubleOrNull() ?: return@mapNotNull null
val time = AccountHistoryAdapter.parseDateMillis(r.bmlTimestamp).takeIf { it > 0L } ?: entry.savedAt
val from = accounts.firstOrNull { it.bank == "BML" && it.accountBriefName == r.fromLabel }
ChatMessage(
id = "rcpt_${r.bmlReference.ifBlank { entry.savedAt.toString() }}",
peerKey = normalise(r.toLabel),
peerName = r.toLabel,
amount = -amount,
currency = r.currency,
timeMillis = time,
accountNumber = from?.accountNumber.orEmpty(),
accountDisplayName = r.fromLabel,
reference = r.bmlReference,
note = r.remarks,
peerAccount = r.toAccount,
receiptKey = entry.savedAt.toString()
)
}
/**
* Merges stored and newly fetched messages so each transfer appears once. The same transfer can
* come from history, a notification and a receipt; the highest-ranked source is kept (history,
* then notification, then receipt) and picks up what the others add: the exact time, the note,
* the recipient account and the receipt.
*/
private fun merge(existing: List<ChatMessage>, incoming: List<ChatMessage>): List<ChatMessage> {
val byId = LinkedHashMap<String, ChatMessage>()
incoming.forEach { byId[it.id] = it }
existing.forEach { byId[it.id] = it } // stored copies already carry merged details
val kept = mutableListOf<ChatMessage>()
val absorbed = HashMap<String, MutableSet<Int>>() // kept id -> source ranks merged into it
for (candidate in byId.values.sortedWith(compareBy({ rank(it) }, { it.timeMillis }))) {
val r = rank(candidate)
val index = kept.indexOfFirst { k ->
rank(k) < r && r !in absorbed[k.id].orEmpty() && sameTransfer(k, candidate)
}
if (index >= 0) {
val k = kept[index]
kept[index] = k.copy(
timeMillis = if (k.dateOnly && !candidate.dateOnly) candidate.timeMillis else k.timeMillis,
dateOnly = k.dateOnly && candidate.dateOnly,
accountNumber = k.accountNumber.ifBlank { candidate.accountNumber },
reference = k.reference.ifBlank { candidate.reference },
note = k.note.ifBlank { candidate.note },
peerAccount = k.peerAccount.ifBlank { candidate.peerAccount },
receiptKey = k.receiptKey.ifBlank { candidate.receiptKey },
merchant = k.merchant || candidate.merchant
)
absorbed.getOrPut(k.id) { mutableSetOf() } += r
} else {
kept += candidate
}
}
return kept.sortedBy { it.timeMillis }
}
private fun rank(m: ChatMessage) = when {
m.id.startsWith("bml_") -> 0
m.id.startsWith("ntf_") -> 1
else -> 2
}
private fun sameTransfer(a: ChatMessage, b: ChatMessage): Boolean {
if (a.reference.isNotBlank() && a.reference == b.reference) return true
if (a.isSent != b.isSent || kotlin.math.abs(a.amount - b.amount) >= 0.005) return false
// A payment to a business is never the same as a transfer to a person (receipts aren't tagged).
val receipt = a.id.startsWith("rcpt_") || b.id.startsWith("rcpt_")
if (!receipt && a.merchant != b.merchant) return false
if (a.currency.isNotBlank() && b.currency.isNotBlank() && !a.currency.equals(b.currency, ignoreCase = true)) return false
if (a.accountNumber.isNotBlank() && b.accountNumber.isNotBlank() && a.accountNumber != b.accountNumber) return false
if (a.dateOnly || b.dateOnly) {
// A date-only entry could be any transfer that day, so the names must agree too.
if (startOfDay(a.timeMillis) != startOfDay(b.timeMillis)) return false
return receipt || a.peerKey == b.peerKey || a.peerKey.contains(b.peerKey) || b.peerKey.contains(a.peerKey)
}
return kotlin.math.abs(a.timeMillis - b.timeMillis) <= MATCH_WINDOW_MS
}
private fun startOfDay(millis: Long): Long = Calendar.getInstance().apply {
timeInMillis = millis
set(Calendar.HOUR_OF_DAY, 0); set(Calendar.MINUTE, 0); set(Calendar.SECOND, 0); set(Calendar.MILLISECOND, 0)
}.timeInMillis
fun clearAll(context: Context) = synchronized(lock) {
File(context.filesDir, FILE_NAME).delete()
File(context.filesDir, PINS_FILE_NAME).delete()
File(context.filesDir, QR_FILE_NAME).delete()
}
// ─── Saved merchant QRs ───────────────────────────────────────────────────
/**
* The static QR (no preset amount) last paid at the business named [merchantName], or null.
* The app already remembers such QRs among its recents, but only the last few; they are
* copied here so a business's QR is kept for good. Call off the main thread.
*/
fun merchantQr(context: Context, merchantName: String): String? = synchronized(lock) {
val file = File(context.filesDir, QR_FILE_NAME)
val saved = try {
if (!file.exists()) JSONObject() else JSONObject(CacheEncryption.decrypt(file.readText()))
} catch (_: Exception) { JSONObject() }
var changed = false
for (recent in RecentsCache.load(context)) {
if (!recent.accountNumber.startsWith(RECENT_QR_PREFIX)) continue
val key = normalise(recent.displayName)
val url = recent.accountNumber.removePrefix(RECENT_QR_PREFIX)
if (key.isNotBlank() && saved.optString(key) != url) { saved.put(key, url); changed = true }
}
if (changed) try { file.writeText(CacheEncryption.encrypt(saved.toString())) } catch (_: Exception) {}
// Names differ slightly between the QR, history and alerts; accept one starting the other.
val name = normalise(merchantName)
val keys = saved.keys().asSequence().toList()
val match = keys.firstOrNull { it == name }
?: keys.filter { it.startsWith(name) || name.startsWith(it) }.maxByOrNull { it.length }
match?.let { saved.optString(it).takeIf { url -> url.isNotBlank() } }
}
// ─── Pinned chats ─────────────────────────────────────────────────────────
/** Pinned chat keys, most recently pinned first. */
fun pins(context: Context): List<String> = synchronized(lock) {
val file = File(context.filesDir, PINS_FILE_NAME)
if (!file.exists()) return emptyList()
try {
val arr = JSONArray(CacheEncryption.decrypt(file.readText()))
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) { emptyList() }
}
fun setPinned(context: Context, peerKey: String, pinned: Boolean) {
val current = pins(context).filter { it != peerKey }
val updated = if (pinned) listOf(peerKey) + current else current
synchronized(lock) {
try {
File(context.filesDir, PINS_FILE_NAME)
.writeText(CacheEncryption.encrypt(JSONArray(updated).toString()))
} catch (_: Exception) {}
}
}
private fun load(context: Context): State {
val file = File(context.filesDir, FILE_NAME)
if (!file.exists()) return State(0L, emptyList())
return try {
val root = JSONObject(CacheEncryption.decrypt(file.readText()))
val arr = root.optJSONArray("messages") ?: JSONArray()
State(
sinceMillis = root.optLong("since", 0L),
messages = (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
ChatMessage(
id = o.optString("id"),
peerKey = o.optString("peerKey"),
peerName = o.optString("peerName"),
amount = o.optDouble("amount", 0.0),
currency = o.optString("currency"),
timeMillis = o.optLong("time", 0L),
accountNumber = o.optString("account"),
accountDisplayName = o.optString("accountName"),
reference = o.optString("reference"),
note = o.optString("note"),
peerAccount = o.optString("peerAccount"),
receiptKey = o.optString("receiptKey"),
dateOnly = o.optBoolean("dateOnly", false),
merchant = o.optBoolean("merchant", false)
)
}
)
} catch (_: Exception) { State(0L, emptyList()) }
}
private fun save(context: Context, state: State) {
try {
val arr = JSONArray()
for (m in state.messages) arr.put(JSONObject().apply {
put("id", m.id)
put("peerKey", m.peerKey)
put("peerName", m.peerName)
put("amount", m.amount)
put("currency", m.currency)
put("time", m.timeMillis)
put("account", m.accountNumber)
put("accountName", m.accountDisplayName)
put("reference", m.reference)
put("note", m.note)
put("peerAccount", m.peerAccount)
put("receiptKey", m.receiptKey)
put("dateOnly", m.dateOnly)
put("merchant", m.merchant)
})
val root = JSONObject().put("since", state.sinceMillis).put("messages", arr)
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(root.toString()))
} catch (_: Exception) {}
}
}
@@ -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)
@@ -13,7 +13,10 @@ data class RecentPick(
val isProfileImage: Boolean,
/** Source bank tag for the recent — e.g. "MFAISA". Used by the picker to decide
* per-bank selectability. Null for legacy entries; treated as unspecified. */
val bank: String? = null
val bank: String? = null,
/** The contact category the pick was paid as — set for Fahipay payouts (e.g.
* "FAHIPAY_RAASTAS") so picking the recent again applies the same service. */
val contactCategory: String? = null
)
object RecentsCache {
@@ -38,6 +41,7 @@ object RecentsCache {
if (r.imageHash != null) put("imageHash", r.imageHash)
put("isProfileImage", r.isProfileImage)
if (r.bank != null) put("bank", r.bank)
if (r.contactCategory != null) put("contactCategory", r.contactCategory)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -56,6 +60,7 @@ object RecentsCache {
if (r.imageHash != null) put("imageHash", r.imageHash)
put("isProfileImage", r.isProfileImage)
if (r.bank != null) put("bank", r.bank)
if (r.contactCategory != null) put("contactCategory", r.contactCategory)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -81,7 +86,8 @@ object RecentsCache {
colorHex = o.getString("colorHex"),
imageHash = o.optString("imageHash").takeIf { it.isNotBlank() },
isProfileImage = o.optBoolean("isProfileImage", false),
bank = o.optString("bank").takeIf { it.isNotBlank() }
bank = o.optString("bank").takeIf { it.isNotBlank() },
contactCategory = o.optString("contactCategory").takeIf { it.isNotBlank() }
)
}
} catch (_: Exception) {
@@ -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)
}
@@ -1,6 +1,7 @@
package sh.sar.basedbank.util.fahipayapi
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.ui.home.transfer.FahipayService
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.TransferNetwork
@@ -20,7 +21,8 @@ object FahipayContactParser {
imageHash = contact.customerImgHash,
profileId = contact.profileId,
transferSubtitle = contact.benefAccount,
canTransfer = false,
// Each favourites list is one payout service; the Transfer screen picks it from categoryId
canTransfer = FahipayService.fromContactCategory(contact.benefCategoryId) != null,
canEdit = false,
canDelete = false
)
@@ -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,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="10dp" />
<stroke android:width="1dp" android:color="?attr/colorOutline" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="14dp" />
<solid android:color="?attr/colorSurfaceContainerHigh" />
</shape>
@@ -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,40 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Dhiraagu mark alone, cropped square from dhiraagu_long.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="48.491"
android:viewportHeight="48.491">
<group
android:translateX="0.086"
android:translateY="3.591">
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M22.1561 2.71843C21.9696 2.07394 21.5428 1.54073 20.9538 1.21798C20.3648 0.894216 19.6856 0.818335 19.0398 1.0045C18.394 1.19168 17.8598 1.61763 17.5354 2.20445C17.2109 2.79228 17.1349 3.47016 17.3225 4.11466L20.1397 14.2748C20.4489 15.3422 21.4424 16.0879 22.5556 16.0879C22.7918 16.0879 23.027 16.0545 23.2561 15.9887C23.9019 15.8025 24.4361 15.3756 24.7605 14.7887C25.0849 14.2009 25.16 13.523 24.9734 12.8785L22.1561 2.71843Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M13.7864 19.9944C14.4382 20.0257 15.0546 19.7475 15.528 19.2922C16.0116 18.8268 16.2863 18.2015 16.2985 17.5307C16.3117 16.8599 16.0613 16.2246 15.596 15.7409L9.39471 9.38302C8.43263 8.38542 6.83695 8.35506 5.83737 9.31523C5.3538 9.78064 5.08008 10.4059 5.0669 11.0757C5.05372 11.7465 5.30412 12.3819 5.76945 12.8645L11.9707 19.2234C11.9707 19.2234 12.655 19.9387 13.7864 19.9934V19.9944Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M9.72223 23.0954L3.10228 21.5596C2.44941 21.4038 1.77424 21.511 1.20145 21.8621C0.62867 22.2132 0.227216 22.7656 0.0700814 23.4182C-0.0860399 24.0708 0.020406 24.7446 0.372186 25.3163C0.723966 25.8879 1.27749 26.2886 1.93137 26.4444L8.55132 27.9802C8.74394 28.0258 8.94162 28.049 9.1383 28.049C10.3062 28.049 11.3118 27.2568 11.5835 26.1216C11.7396 25.469 11.6322 24.7952 11.2804 24.2236C10.9286 23.6519 10.3751 23.2513 9.72122 23.0954H9.72223Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M47.6209 19.5174C47.1373 19.052 46.4996 18.8031 45.8285 18.8163C45.1564 18.8294 44.5299 19.1026 44.0635 19.5852L38.4188 25.3563C37.9525 25.8399 37.7031 26.4753 37.7153 27.1461C37.7284 27.8169 38.0022 28.4422 38.4867 28.9076C38.9571 29.3599 39.5776 29.6098 40.2315 29.6098C40.9218 29.6098 41.5656 29.3366 42.0441 28.8398L47.6888 23.0687C48.1551 22.5851 48.4045 21.9497 48.3913 21.2799C48.3782 20.6091 48.1044 19.9839 47.6209 19.5184V19.5174Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M24.497 18.6561H28.1151C28.1151 18.6561 28.329 23.4933 24.4179 23.4933C23.1608 23.4933 21.8997 23.9344 20.8413 24.7863C18.4539 26.7097 17.9662 30.1881 19.7343 32.6902C21.6544 35.4068 25.4418 35.9694 28.0675 33.9711C29.5618 32.8329 30.3211 31.2131 30.3657 29.3676C30.3657 29.3646 30.3667 29.3615 30.3688 29.3575V2.46688C30.3688 1.06761 31.5366 -0.0787268 32.9356 0.0042377C34.2039 0.0801199 35.2085 1.13034 35.2085 2.41527V28.6361C35.2085 29.9048 35.0433 31.1696 34.6945 32.3897C34.4593 33.2103 34.1289 34.0703 33.6625 34.8625C32.9894 35.9724 32.1003 36.9771 31.0034 37.8128C26.3096 41.3873 19.6004 40.4859 16.0188 35.8004C12.4371 31.1149 13.3404 24.4201 18.0342 20.8455C18.0342 20.8455 19.0804 20.026 20.5585 19.4149C21.6189 18.9778 22.7431 18.7309 23.9475 18.6702L24.497 18.6561Z" />
</group>
</vector>
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="169dp"
android:height="40dp"
android:viewportWidth="169"
android:viewportHeight="40">
<!-- Mark -->
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M22.1561 2.71843C21.9696 2.07394 21.5428 1.54073 20.9538 1.21798C20.3648 0.894216 19.6856 0.818335 19.0398 1.0045C18.394 1.19168 17.8598 1.61763 17.5354 2.20445C17.2109 2.79228 17.1349 3.47016 17.3225 4.11466L20.1397 14.2748C20.4489 15.3422 21.4424 16.0879 22.5556 16.0879C22.7918 16.0879 23.027 16.0545 23.2561 15.9887C23.9019 15.8025 24.4361 15.3756 24.7605 14.7887C25.0849 14.2009 25.16 13.523 24.9734 12.8785L22.1561 2.71843Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M13.7864 19.9944C14.4382 20.0257 15.0546 19.7475 15.528 19.2922C16.0116 18.8268 16.2863 18.2015 16.2985 17.5307C16.3117 16.8599 16.0613 16.2246 15.596 15.7409L9.39471 9.38302C8.43263 8.38542 6.83695 8.35506 5.83737 9.31523C5.3538 9.78064 5.08008 10.4059 5.0669 11.0757C5.05372 11.7465 5.30412 12.3819 5.76945 12.8645L11.9707 19.2234C11.9707 19.2234 12.655 19.9387 13.7864 19.9934V19.9944Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M9.72223 23.0954L3.10228 21.5596C2.44941 21.4038 1.77424 21.511 1.20145 21.8621C0.62867 22.2132 0.227216 22.7656 0.0700814 23.4182C-0.0860399 24.0708 0.020406 24.7446 0.372186 25.3163C0.723966 25.8879 1.27749 26.2886 1.93137 26.4444L8.55132 27.9802C8.74394 28.0258 8.94162 28.049 9.1383 28.049C10.3062 28.049 11.3118 27.2568 11.5835 26.1216C11.7396 25.469 11.6322 24.7952 11.2804 24.2236C10.9286 23.6519 10.3751 23.2513 9.72122 23.0954H9.72223Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M47.6209 19.5174C47.1373 19.052 46.4996 18.8031 45.8285 18.8163C45.1564 18.8294 44.5299 19.1026 44.0635 19.5852L38.4188 25.3563C37.9525 25.8399 37.7031 26.4753 37.7153 27.1461C37.7284 27.8169 38.0022 28.4422 38.4867 28.9076C38.9571 29.3599 39.5776 29.6098 40.2315 29.6098C40.9218 29.6098 41.5656 29.3366 42.0441 28.8398L47.6888 23.0687C48.1551 22.5851 48.4045 21.9497 48.3913 21.2799C48.3782 20.6091 48.1044 19.9839 47.6209 19.5184V19.5174Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M24.497 18.6561H28.1151C28.1151 18.6561 28.329 23.4933 24.4179 23.4933C23.1608 23.4933 21.8997 23.9344 20.8413 24.7863C18.4539 26.7097 17.9662 30.1881 19.7343 32.6902C21.6544 35.4068 25.4418 35.9694 28.0675 33.9711C29.5618 32.8329 30.3211 31.2131 30.3657 29.3676C30.3657 29.3646 30.3667 29.3615 30.3688 29.3575V2.46688C30.3688 1.06761 31.5366 -0.0787268 32.9356 0.0042377C34.2039 0.0801199 35.2085 1.13034 35.2085 2.41527V28.6361C35.2085 29.9048 35.0433 31.1696 34.6945 32.3897C34.4593 33.2103 34.1289 34.0703 33.6625 34.8625C32.9894 35.9724 32.1003 36.9771 31.0034 37.8128C26.3096 41.3873 19.6004 40.4859 16.0188 35.8004C12.4371 31.1149 13.3404 24.4201 18.0342 20.8455C18.0342 20.8455 19.0804 20.026 20.5585 19.4149C21.6189 18.9778 22.7431 18.7309 23.9475 18.6702L24.497 18.6561Z" />
<!-- Wordmark -->
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M65.8877 16.7821H67.9984C67.9984 16.7821 68.1069 19.873 65.7143 19.873C65.2196 19.873 64.4927 20.1098 63.925 20.5671C62.6426 21.6001 62.38 23.4688 63.3299 24.8135C64.361 26.2734 66.3956 26.5749 67.8058 25.5015C68.6087 24.8903 69.0172 24.0202 69.0395 23.0287C69.0395 23.0267 69.0395 23.0257 69.0416 23.0236V20.6956L69.0365 12.2565H72.1346V22.6038C72.1346 23.3434 72.0383 24.0819 71.8345 24.7932C71.6976 25.2708 71.505 25.7736 71.2333 26.235C70.84 26.8825 70.322 27.4693 69.6823 27.956C66.9441 30.0412 63.0299 29.5151 60.9415 26.7823C58.8521 24.0496 59.3793 20.1432 62.1175 18.0589C62.1175 18.0589 62.7278 17.5814 63.5905 17.2242C64.2079 16.9693 64.8648 16.8256 65.5673 16.7902L65.8877 16.7821Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M116.569 29.4027H118.68C118.68 29.4027 118.787 26.3117 116.396 26.3117C115.901 26.3117 115.174 26.075 114.607 25.6177C113.323 24.5846 113.062 22.7159 114.012 21.3713C115.043 19.9113 117.077 19.6098 118.487 20.6843C119.29 21.2954 119.699 22.1655 119.722 23.1571C119.722 23.1591 119.722 23.1601 119.724 23.1621V25.4902L119.719 29.2893H122.817V23.581C122.817 22.8414 122.721 22.1028 122.517 21.3915C122.38 20.913 122.187 20.4111 121.915 19.9498C121.522 19.3022 121.004 18.7154 120.364 18.2288C117.626 16.1435 113.712 16.6696 111.623 19.4024C109.534 22.1352 110.061 26.0416 112.799 28.1258C112.799 28.1258 113.409 28.6034 114.272 28.9605C114.891 29.2155 115.546 29.3591 116.248 29.3946L116.569 29.4027Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M132.317 29.4027H134.428C134.428 29.4027 134.535 26.3117 132.144 26.3117C131.649 26.3117 130.922 26.075 130.354 25.6177C129.071 24.5846 128.809 22.7159 129.759 21.3713C130.79 19.9113 132.825 19.6098 134.235 20.6843C135.038 21.2954 135.447 22.1655 135.47 23.1571C135.47 23.1591 135.47 23.1601 135.472 23.1621V25.4902L135.467 29.2893H138.565V23.581C138.565 22.8414 138.469 22.1028 138.265 21.3915C138.128 20.913 137.934 20.4111 137.663 19.9498C137.269 19.3022 136.75 18.7154 136.112 18.2288C133.374 16.1435 129.459 16.6696 127.371 19.4024C125.282 22.1352 125.809 26.0416 128.547 28.1258C128.547 28.1258 129.157 28.6034 130.02 28.9605C130.638 29.2155 131.294 29.3591 131.996 29.3946L132.317 29.4027Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M83.4124 16.9528C82.0955 16.9528 80.8029 17.14 80.0233 18.1376V12.2572H76.9272V29.2892H80.0233V23.3279C80.0233 20.9917 81.1446 19.9456 82.9247 19.9456C84.5579 19.9456 85.6315 20.7975 85.6315 23.0608V29.2892H88.7276V22.8655C88.7276 19.3375 86.9231 16.9528 83.4124 16.9528Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M165.904 17.1637V23.3931C165.904 25.5006 164.973 26.3829 163.527 26.4942C162.079 26.3829 161.148 25.5006 161.148 23.3931V17.1637H158.052V23.5874C158.052 27.1154 159.856 29.5001 163.366 29.5001C163.42 29.5001 163.473 29.5001 163.526 29.4981C163.578 29.4981 163.631 29.5001 163.685 29.5001C167.195 29.5001 169 27.1154 169 23.5874V17.1637H165.904Z" />
<path
android:fillColor="#F15625"
android:pathData="M95.9721 17.1224H92.876V29.2889H95.9721V17.1224Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M106.412 16.9529C105.472 16.9529 104.531 17.1684 103.687 17.5761C103.543 17.6449 103.405 17.7208 103.269 17.8007L103.267 17.8028C102.563 18.2105 101.953 18.7467 101.451 19.4034C101.218 19.7089 101.018 20.0327 100.848 20.3676C100.411 21.1628 100.173 22.0623 100.173 22.9891V29.3318H103.27V23.2491C103.255 22.6015 103.451 21.9459 103.849 21.3804C104.435 20.5507 105.393 20.056 106.413 20.056H107.834V16.9549H106.413L106.412 16.9529Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M94.3459 12.6085C95.2451 12.6085 95.973 13.335 95.973 14.2324C95.973 15.1298 95.2451 15.8563 94.3459 15.8563C93.4466 15.8563 92.7188 15.1288 92.7188 14.2324C92.7188 13.336 93.4466 12.6085 94.3459 12.6085Z" />
<path
android:fillColor="#F15625"
android:fillType="evenOdd"
android:pathData="M154.484 26.5717V23.5809C154.484 22.8413 154.387 22.1027 154.184 21.3915C154.047 20.9129 153.853 20.4111 153.582 19.9497C153.19 19.3022 152.671 18.7154 152.031 18.2287C149.293 16.1435 145.379 16.6686 143.291 19.4023C141.201 22.1351 141.728 26.0415 144.467 28.1258C144.467 28.1258 145.077 28.6043 145.94 28.9595C146.558 29.2154 147.214 29.3591 147.916 29.3945L148.237 29.4026H150.348C150.348 29.4026 150.456 26.3107 148.063 26.3107C147.57 26.3107 146.843 26.0739 146.275 25.6166C144.993 24.5846 144.731 22.7149 145.68 21.3712C146.711 19.9113 148.746 19.6098 150.156 20.6832C150.959 21.2943 151.367 22.1655 151.39 23.157C151.39 23.159 151.39 23.16 151.391 23.162V23.6052H151.386V26.5727V28.8906C151.371 30.6349 150.186 31.5202 148.427 31.5202C147.069 31.5445 145.977 31.157 144.813 30.5762L143.067 32.4399C144.376 33.5306 146.196 34.3289 148.428 34.3289C152.214 34.3289 154.625 31.8369 154.478 28.1551V26.5727H154.484V26.5717Z" />
</vector>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The Favara mark alone, cropped square from favara_long.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="317.610"
android:viewportHeight="317.610">
<group
android:translateX="-123.226"
android:translateY="-123.588">
<path
android:fillColor="#005CA3"
android:pathData="M262.476 321.671C254.92 326.026 245.261 323.404 240.951 315.848C236.596 308.314 239.212 298.678 246.735 294.323L301.63 262.653C309.197 258.287 318.845 260.903 323.167 268.47C327.521 275.988 324.905 285.636 317.333 290.001L262.476 321.671ZM272.472 371.936L272.438 371.98L238.014 391.849L237.932 391.894H237.899V391.927H237.854L233.616 394.394C232.887 394.78 232.153 395.167 231.419 395.437C227.368 397.518 223.421 399.212 219.602 400.266C210.043 402.844 201.366 401.828 193.882 394.824C185.962 387.329 178.24 379.646 170.717 371.743C163.195 363.883 155.865 355.77 148.695 347.446C132.016 328.029 123.952 305.157 123.952 282.054C123.952 258.663 132.253 235.172 148.298 215.071C158.569 202.217 169.371 190.146 180.751 178.804C191.945 167.616 203.756 157.118 216.174 147.283C236.16 131.47 258.927 123.588 281.838 123.588C304.831 123.588 327.792 131.503 347.926 147.167C360.207 156.693 372.073 167.196 383.537 178.655C394.852 189.919 405.653 202.101 415.875 215.187C431.809 235.476 440.11 259.049 440.11 282.434C440.11 305.466 432.118 328.195 415.411 347.48C404.759 359.755 394.046 371.323 383.189 382.169C372.355 392.97 360.819 403.727 348.544 414.346C334.238 426.72 317.84 434.514 300.819 437.792C283.389 441.198 265.291 439.763 248.28 433.598C240.095 430.623 235.851 421.56 238.826 413.342C241.801 405.118 250.863 400.873 259.049 403.882C270.645 408.093 282.958 409.053 294.88 406.735C306.575 404.494 317.918 399.058 327.88 390.459C338.908 380.894 349.858 370.666 360.786 359.755C371.698 348.837 381.947 337.832 391.49 326.843C402.959 313.607 408.435 298.098 408.435 282.434C408.435 265.959 402.452 249.219 390.993 234.626C381.66 222.671 371.731 211.522 361.283 201.063C350.978 190.769 340.028 181.166 328.493 172.164C314.06 160.943 297.888 155.302 281.838 155.302C265.87 155.302 249.864 160.833 235.74 172.059C224.509 180.917 213.602 190.654 203.016 201.218C192.646 211.61 182.639 222.754 173.069 234.742C161.622 249.142 155.666 265.689 155.666 282.054C155.666 297.828 161.219 313.53 172.721 326.876C179.482 334.746 186.425 342.435 193.612 349.958C199.854 356.509 206.267 362.956 212.857 369.242C214.712 368.541 216.909 367.432 219.348 366.118L301.553 318.63C309.12 314.253 318.801 316.853 323.167 324.409C327.521 331.921 324.977 341.613 317.41 345.967L272.472 371.936ZM262.476 265.689C254.92 270.016 245.261 267.427 240.951 259.871C236.596 252.304 239.212 242.657 246.735 238.329L301.63 206.626C309.197 202.305 318.845 204.893 323.167 212.449C327.521 220.016 324.905 229.653 317.333 233.975L262.476 265.689Z" />
</group>
</vector>
File diff suppressed because one or more lines are too long
@@ -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>
-9
View File
@@ -1,9 +0,0 @@
<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,2H4c-1.1,0 -2,0.9 -2,2v18l4,-4h14c1.1,0 2,-0.9 2,-2V4c0,-1.1 -0.9,-2 -2,-2zM6,9h12v2H6V9zM14,14H6v-2h8v2zM18,8H6V6h12v2z"/>
</vector>
@@ -1,12 +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:fillColor="@android:color/transparent"
android:strokeColor="#FFFFFFFF"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M4,9l1.5,-5h13L20,9M4,9v11h16V9M4,9h16M9,20v-6h6v6" />
android:pathData="M15,4 L7,12 L15,20" />
</vector>
@@ -1,3 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
@@ -5,5 +6,5 @@
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M16,12V4h1V2H7v2h1v8l-2,2v2h5.2v6h1.6v-6H18v-2l-2,-2z" />
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>
-9
View File
@@ -1,9 +0,0 @@
<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.5,3.5L18,2l-1.5,1.5L15,2l-1.5,1.5L12,2l-1.5,1.5L9,2 7.5,3.5 6,2 4.5,3.5 3,2v20l1.5,-1.5L6,22l1.5,-1.5L9,22l1.5,-1.5L12,22l1.5,-1.5L15,22l1.5,-1.5L18,22l1.5,-1.5L21,22V2l-1.5,1.5zM19,19.09H5V4.91h14v14.18zM6,15h12v2H6zM6,11h12v2H6zM6,7h12v2H6z"/>
</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.
@@ -48,6 +48,7 @@
android:id="@+id/languageToggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:baselineAligned="false"
app:singleSelection="true"
app:selectionRequired="true">
@@ -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>
-330
View File
@@ -1,330 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<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">
<LinearLayout
android:id="@+id/toAccountBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSecondaryContainer"
android:foreground="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="10dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="@string/chat_to"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvToAccount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvToNickname"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/tvToCurrency"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_chat_chip"
android:paddingHorizontal="10dp"
android:paddingVertical="3dp"
android:textAppearance="?attr/textAppearanceLabelLarge"
android:textColor="?attr/colorOnSurface" />
<ImageView
android:id="@+id/ivToSwitch"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="8dp"
android:rotation="90"
android:src="@drawable/ic_arrow_right"
android:importantForAccessibility="no" />
</LinearLayout>
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<LinearLayout
android:id="@+id/saveContactBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/colorSurfaceContainerHigh"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="16dp"
android:paddingEnd="8dp"
android:visibility="gone">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/chat_not_in_contacts"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSaveContact"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/chat_save_contact" />
</LinearLayout>
<LinearLayout
android:id="@+id/summaryCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="8dp"
android:background="@drawable/bg_chat_summary"
android:orientation="horizontal"
android:paddingHorizontal="14dp"
android:paddingVertical="10dp"
android:visibility="gone">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvSumLabel1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvSumValue1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:id="@+id/sumColumn2"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/tvSumLabel2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvSumValue2"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textStyle="bold" />
</LinearLayout>
<LinearLayout
android:id="@+id/sumColumn3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@+id/tvSumLabel3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant" />
<TextView
android:id="@+id/tvSumValue3"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
android:textStyle="bold" />
</LinearLayout>
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvMessages"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:clipToPadding="false"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp" />
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:id="@+id/tvCannotSend"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp"
android:gravity="center"
android:text="@string/chat_cannot_send"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
<LinearLayout
android:id="@+id/composer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingHorizontal="12dp"
android:paddingTop="8dp"
android:paddingBottom="8dp">
<TextView
android:id="@+id/tvCurrencyWarning"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorError"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnFrom"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="start|center_vertical"
android:ellipsize="middle"
android:maxLines="1"
android:textColor="?attr/colorOnSecondaryContainer"
app:icon="@drawable/ic_arrow_right"
app:iconGravity="end"
app:iconSize="16dp"
app:iconTint="?attr/colorOnSecondaryContainer" />
<LinearLayout
android:id="@+id/amountRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilAmount"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1.2"
android:hint="@string/chat_amount"
app:boxCornerRadiusBottomEnd="28dp"
app:boxCornerRadiusBottomStart="28dp"
app:boxCornerRadiusTopEnd="28dp"
app:boxCornerRadiusTopStart="28dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etAmount"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionNext"
android:inputType="numberDecimal"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilNote"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_weight="1"
android:hint="@string/chat_note"
app:boxCornerRadiusBottomEnd="28dp"
app:boxCornerRadiusBottomStart="28dp"
app:boxCornerRadiusTopEnd="28dp"
app:boxCornerRadiusTopStart="28dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etNote"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:imeOptions="actionSend"
android:inputType="textCapSentences"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSend"
style="@style/Widget.Material3.Button.Icon"
android:layout_width="wrap_content"
android:layout_height="56dp"
android:layout_marginStart="8dp"
android:layout_marginTop="4dp"
android:text="@string/chat_send"
app:cornerRadius="28dp"
app:icon="@drawable/ic_send"
app:iconGravity="textEnd" />
</LinearLayout>
</LinearLayout>
<!-- Hosts the Transfer page off-screen when only its confirm dialog is wanted. -->
<FrameLayout
android:id="@+id/hiddenTransferHost"
android:layout_width="0dp"
android:layout_height="0dp"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnPayAgain"
style="@style/Widget.Material3.Button"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginHorizontal="12dp"
android:layout_marginVertical="12dp"
android:text="@string/chat_pay_again"
android:visibility="gone"
app:cornerRadius="28dp" />
</LinearLayout>
@@ -1,81 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<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">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox.Dense"
app:startIconDrawable="@android:drawable/ic_menu_search"
app:boxCornerRadiusTopStart="24dp"
app:boxCornerRadiusTopEnd="24dp"
app:boxCornerRadiusBottomStart="24dp"
app:boxCornerRadiusBottomEnd="24dp">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etSearch"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/chats_search_hint"
android:inputType="text"
android:maxLines="1"
android:imeOptions="actionSearch" />
</com.google.android.material.textfield.TextInputLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvChats"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingTop="4dp"
android:paddingBottom="65dp" />
<TextView
android:id="@+id/emptyView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginHorizontal="32dp"
android:gravity="center"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabScanPay"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:layout_margin="16dp"
android:contentDescription="@string/chat_scan_to_pay"
app:srcCompat="@drawable/ic_qr_scan" />
</FrameLayout>
@@ -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" />
@@ -282,6 +282,7 @@
android:id="@+id/languageToggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:baselineAligned="false"
app:singleSelection="true"
app:selectionRequired="true">
@@ -303,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>
@@ -266,66 +266,6 @@
</com.google.android.material.card.MaterialCardView>
<!-- Service selector: invisible to reserve space, prevents amount/remarks from shifting -->
<LinearLayout
android:id="@+id/layoutServiceSelector"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="8dp"
android:visibility="invisible">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:text="@string/transfer_select_service"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
<com.google.android.material.chip.ChipGroup
android:id="@+id/chipGroupService"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.chip.Chip
android:id="@+id/chipDhiraaguReload"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Dhiraagu Reload"
android:visibility="gone" />
<com.google.android.material.chip.Chip
android:id="@+id/chipDhiraaguBill"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Dhiraagu Bill Pay"
android:visibility="gone" />
<com.google.android.material.chip.Chip
android:id="@+id/chipRaastas"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Raastas"
android:visibility="gone" />
<com.google.android.material.chip.Chip
android:id="@+id/chipOoredooBill"
style="@style/Widget.Material3.Chip.Filter"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Ooredoo Bill Pay"
android:visibility="gone" />
</com.google.android.material.chip.ChipGroup>
</LinearLayout>
<View
android:id="@+id/spacerTo"
android:layout_width="match_parent"
-74
View File
@@ -1,74 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
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:background="?attr/selectableItemBackground"
android:paddingHorizontal="16dp"
android:paddingVertical="10dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivAvatar"
android:layout_width="52dp"
android:layout_height="52dp"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle"
android:scaleType="centerCrop"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<TextView
android:id="@+id/tvName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginEnd="8dp"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintStart_toEndOf="@id/ivAvatar"
app:layout_constraintEnd_toStartOf="@id/tvTime"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/tvPreview"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/tvTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBaseline_toBaselineOf="@id/tvName" />
<TextView
android:id="@+id/tvPreview"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:maxLines="1"
android:ellipsize="end"
app:layout_constraintStart_toEndOf="@id/ivAvatar"
app:layout_constraintEnd_toStartOf="@id/ivPin"
app:layout_constraintTop_toBottomOf="@id/tvName"
app:layout_constraintBottom_toBottomOf="parent" />
<ImageView
android:id="@+id/ivPin"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="8dp"
android:contentDescription="@string/chat_pinned"
android:src="@drawable/ic_pin"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/tvPreview"
app:layout_constraintBottom_toBottomOf="@id/tvPreview" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -1,109 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/bubbleRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingVertical="3dp">
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardBubble"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:cardElevation="0dp"
app:strokeWidth="0dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="150dp"
android:orientation="vertical"
android:paddingStart="14dp"
android:paddingTop="10dp"
android:paddingEnd="14dp"
android:paddingBottom="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tvAmount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="240dp"
android:textAppearance="?attr/textAppearanceTitleLarge"
android:textStyle="bold" />
<Space
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_weight="1" />
<ImageButton
android:id="@+id/btnReceipt"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="12dp"
android:layout_marginEnd="-8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/chat_view_receipt"
android:padding="8dp"
android:scaleType="fitCenter"
android:src="@drawable/ic_receipt"
android:visibility="gone" />
</LinearLayout>
<TextView
android:id="@+id/tvNote"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:maxWidth="240dp"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_marginTop="6dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/tvAccount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxWidth="150dp"
android:layout_marginEnd="8dp"
android:alpha="0.8"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceLabelSmall" />
<TextView
android:id="@+id/tvTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:alpha="0.8"
android:textAppearance="?attr/textAppearanceLabelSmall" />
<TextView
android:id="@+id/tvTicks"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:textAppearance="?attr/textAppearanceLabelMedium" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingVertical="10dp">
<TextView
android:id="@+id/tvDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:paddingHorizontal="12dp"
android:paddingVertical="4dp"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant" />
</FrameLayout>
@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- One tile in the Transfer Type picker grid -->
<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:layout_margin="4dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="12dp"
app:cardElevation="0dp"
app:cardBackgroundColor="@android:color/transparent"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="128dp"
android:orientation="vertical"
android:gravity="center_horizontal"
android:padding="12dp">
<!-- Logo, with an optional badge in the bottom corner naming the wallet it pays from.
The badge's ring matches the dialog background (the tile itself is transparent),
so it reads as a cut-out over the logo. -->
<FrameLayout
android:layout_width="56dp"
android:layout_height="56dp">
<ImageView
android:id="@+id/ivIcon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center"
android:scaleType="fitCenter"
android:importantForAccessibility="no" />
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivBadge"
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_gravity="bottom|end"
android:scaleType="fitCenter"
android:visibility="gone"
android:importantForAccessibility="no"
app:contentPadding="1dp"
app:strokeWidth="2dp"
app:strokeColor="?attr/colorSurfaceContainerHigh"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Badge" />
</FrameLayout>
<TextView
android:id="@+id/tvLabel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:maxLines="2"
android:ellipsize="end"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvSubtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:gravity="center"
android:maxLines="2"
android:ellipsize="end"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.bottomsheet.BottomSheetDragHandleView
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<FrameLayout
android:id="@+id/transferContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
@@ -1,44 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingEnd="12dp">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/ivHeaderAvatar"
android:layout_width="40dp"
android:layout_height="40dp"
android:scaleType="centerCrop"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:orientation="vertical">
<TextView
android:id="@+id/tvHeaderName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface" />
<TextView
android:id="@+id/tvHeaderAccount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
</LinearLayout>
@@ -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>

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