Compare commits

...
48 Commits
Author SHA1 Message Date
shihaam 7574a5e8b0 release v1.0.25 - idk numbers
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m33s
2026-09-21 16:30:19 +05:00
shihaam 992c8d4364 release v1.0.24
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m19s
2026-09-21 15:26:40 +05:00
shihaam 80fd238195 fix BML POS QRs 2026-09-21 15:26:18 +05:00
shihaam 0357d7e0bc release v1.0.23
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 4m28s
2026-09-21 08:16:39 +05:00
shihaam 71dd654edc optimize mfaisa QR, fix bug that fails to switch to default account after ooredoo QR is cancelled and new BML QR is scanned
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-21 08:15:23 +05:00
shihaam a75d8e420a split mfaisa qr pay
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-09-21 07:56:46 +05:00
shihaam ca6ecc4283 split fahipay transfer
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-21 07:41:36 +05:00
shihaam f59c2be6c4 split mib transfer
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-09-21 07:26:54 +05:00
shihaam eb019a20a0 attempt to fix QR scanner
Auto Tag on Version Change / check-version (push) Successful in 7s
2026-09-21 07:16:47 +05:00
shihaam c356762baa split do bml tranfers 2026-09-21 07:13:59 +05:00
shihaam 255f43db24 release v1.0.22
Auto Tag on Version Change / check-version (push) Successful in 6s
Build and Release APK / build (push) Failing after 19m22s
2026-06-27 19:44:36 +05:00
shihaam 01cae559cf update card image mappgin
Auto Tag on Version Change / check-version (push) Failing after 11m59s
2026-06-27 19:43:03 +05:00
shihaam 015919a4ac disable paymvQR for mfaisa accounts 2026-06-27 19:42:52 +05:00
shihaam 93a7c8bbde fix bug that flashes bottom bar before share sheet shows up
Auto Tag on Version Change / check-version (push) Failing after 12m1s
2026-06-27 19:22:59 +05:00
shihaam 8f4672f269 fix Transfer slip share from the share button #42 2026-06-27 19:20:24 +05:00
shihaam 00e6b40ee0 update docs
Auto Tag on Version Change / check-version (push) Failing after 14m24s
2026-06-27 18:35:38 +05:00
shihaam 86e1dc0521 release v1.0.21
Auto Tag on Version Change / check-version (push) Failing after 13m8s
Build and Release APK / build (push) Failing after 18m41s
2026-06-27 16:26:54 +05:00
shihaam a90d832dba mfaisa recipt (prep) and SmartPay support
Auto Tag on Version Change / check-version (push) Failing after 11m43s
2026-06-27 16:18:18 +05:00
shihaam 51c2dff4b2 ooredoo mfaisa
Auto Tag on Version Change / check-version (push) Failing after 12m46s
2026-06-27 14:29:25 +05:00
shihaam 43f3cca2aa ooredoo mfaisa
Auto Tag on Version Change / check-version (push) Failing after 13m41s
2026-06-27 14:25:46 +05:00
shihaam 0e226d17ae Ooredoo MFaisa Login UI
Auto Tag on Version Change / check-version (push) Failing after 14m41s
2026-06-27 02:18:39 +05:00
shihaam 24021d7eeb release v1.0.20
Build and Release APK / build (push) Failing after 13m20s
Auto Tag on Version Change / check-version (push) Failing after 14m16s
2026-06-13 23:19:29 +05:00
shihaam e997969070 contact picker recents will not put most recent contact top
Auto Tag on Version Change / check-version (push) Failing after 10m46s
2026-06-13 23:17:57 +05:00
shihaam 3182e14873 fix mib account history header pill
Auto Tag on Version Change / check-version (push) Failing after 13m52s
2026-06-13 23:04:52 +05:00
shihaam 52d2eb235b display outstanding and unbilled credit card values and also fix 00.00 values on credit card statement
Auto Tag on Version Change / check-version (push) Failing after 12m48s
2026-06-13 22:55:54 +05:00
shihaam ae18a8c6c8 fix MIB USD history shows wrong amounts.. #39
Auto Tag on Version Change / check-version (push) Failing after 10m28s
2026-06-13 21:53:15 +05:00
shihaam a8cd22cbe1 update docs
Auto Tag on Version Change / check-version (push) Failing after 13m32s
2026-06-13 21:30:12 +05:00
shihaam 281864347e update docs
Auto Tag on Version Change / check-version (push) Failing after 14m52s
2026-06-13 19:18:49 +05:00
shihaam 16fd909c7f fix some currency detection issues with MIB to MIB, and also Option to automatically add contact during BML to MIB USD transfer #35 (its not fully auto)
Auto Tag on Version Change / check-version (push) Failing after 13m51s
2026-06-13 19:02:34 +05:00
shihaam a95ca0e7a5 rename settings item to confirm transaction now that it inlcuded Tap to Pay
Auto Tag on Version Change / check-version (push) Failing after 12m17s
2026-06-13 18:16:26 +05:00
shihaam 286a6f845d Fix bug that said Cards in tap to pay screen when launched from shortcut and user didnt have biometrics enabled
Auto Tag on Version Change / check-version (push) Failing after 10m58s
2026-06-13 18:12:42 +05:00
shihaam 5b5f776715 remove huge gap in non-edge-to-edge devices with bottom bar in cards and cards manage screen
Auto Tag on Version Change / check-version (push) Successful in 13s
2026-06-13 18:05:10 +05:00
shihaam 98990544fc release v1.0.19
Build and Release APK / build (push) Successful in 3m17s
Auto Tag on Version Change / check-version (push) Failing after 11m17s
2026-06-13 17:42:28 +05:00
shihaam 798e9da9ca idek what these logs doing here
Auto Tag on Version Change / check-version (push) Failing after 12m26s
2026-06-13 17:41:15 +05:00
shihaam 014c002ebe add BML and MIB card freeze/unfreeze
Auto Tag on Version Change / check-version (push) Failing after 13m35s
2026-06-13 17:40:09 +05:00
shihaam 6f8b7130fe notifcation icon white theme fixed
Auto Tag on Version Change / check-version (push) Failing after 13m37s
2026-06-10 16:20:09 +05:00
shihaam 05430f043a background service and push notifications
Auto Tag on Version Change / check-version (push) Failing after 14m1s
2026-06-10 14:19:43 +05:00
shihaam 80bbacc130 Optimize Notifcation loading
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-06-10 13:46:18 +05:00
shihaam 570e6b750b rever commit/4b1c2419 but keeping contact picker fix
Auto Tag on Version Change / check-version (push) Failing after 14m18s
2026-06-10 13:34:26 +05:00
shihaam 21fbd8b12c reoder cards BML active > MIB > BML not active address #38
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-06-10 01:50:49 +05:00
shihaam d0f46e2118 clean up mib notifications
Auto Tag on Version Change / check-version (push) Failing after 14m59s
2026-06-10 01:23:46 +05:00
shihaam 71002ed70c pull more notifications at once 2026-06-10 00:33:43 +05:00
shihaam fbc34d6435 remove log in notification spam
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-06-10 00:24:26 +05:00
shihaam 4b1c2419ec persist mib sessions on disk instead of refreshing token and fix contact picker render issue #37
Auto Tag on Version Change / check-version (push) Failing after 15m8s
2026-06-10 00:17:43 +05:00
shihaam 26dcb20f7f release v1.0.18
Auto Tag on Version Change / check-version (push) Failing after 11m37s
Build and Release APK / build (push) Failing after 15m28s
2026-06-05 02:46:08 +05:00
shihaam 33eb33e18c Add support for otpauth:// and otpauth-migration:// QR scan during login
Auto Tag on Version Change / check-version (push) Failing after 11m54s
2026-06-05 01:15:59 +05:00
shihaam 6a910facaf add an about page #25
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-06-04 23:12:27 +05:00
shihaam e3c6b3a695 Add NFC related prompts (on/off/default/not supported), release version 1.0.17
Auto Tag on Version Change / check-version (push) Failing after 14m33s
Build and Release APK / build (push) Failing after 18m35s
2026-06-04 02:03:15 +05:00
146 changed files with 9458 additions and 1704 deletions
+2
View File
@@ -17,6 +17,8 @@ jobs:
run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n' | base64 -d > app/key.jks
echo "${{ secrets.DOTENV_BASE64 }}" | tr -d '\n' | base64 -d > .build/release/.env
echo "ACCOUNT_MVR=${{ vars.ACCOUNT_MVR }}" >> .build/release/.env
echo "ACCOUNT_USD=${{ vars.ACCOUNT_USD }}" >> .build/release/.env
- name: Build APK
working-directory: .build/release
+2
View File
@@ -16,5 +16,7 @@ local.properties
docs/mibapi/tmp
docs/bmlapi/tmp
docs/fahipayapi/tmp
docs/mfaisaapi/tmp
tmp
app/key.jks
.kotlin/*
+2 -2
View File
@@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-06-03T08:28:30.389803148Z">
<DropdownSelection timestamp="2026-06-13T17:53:06.478193524Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="Default" identifier="serial=10.0.1.245:5555;connection=d182cf37" />
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" />
</handle>
</Target>
</DropdownSelection>
+3
View File
@@ -30,6 +30,9 @@ API reverse-engineering notes and app internals are in [`docs/`](docs/README.md)
This is an unofficial third-party app. It is not affiliated with, endorsed by, or supported by MIB, BML, or Fahipay. Use at your own risk. Review the source code before entering your banking credentials.
## Contributing
<blockquote class="twitter-tweet"><p lang="en" dir="ltr">Talk is cheap, send patches.</p>&mdash; FFmpeg (@FFmpeg) <a href="https://x.com/FFmpeg/status/1762805900035686805?ref_src=twsrc%5Etfw">February 28, 2024</a></blockquote>
## License
+16 -2
View File
@@ -1,8 +1,18 @@
import java.util.Properties
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
}
val localProps = Properties().also { props ->
val f = rootProject.file("local.properties")
if (f.exists()) props.load(f.inputStream())
}
fun localOrEnv(key: String, envKey: String) =
localProps.getProperty(key) ?: System.getenv(envKey) ?: ""
android {
namespace = "sh.sar.basedbank"
compileSdk = 36
@@ -11,10 +21,13 @@ android {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 36
versionCode = 15
versionName = "1.0.16"
versionCode = 26
versionName = "1.0.25"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "ACCOUNT_MVR", "\"${localOrEnv("account.mvr", "ACCOUNT_MVR")}\"")
buildConfigField("String", "ACCOUNT_USD", "\"${localOrEnv("account.usd", "ACCOUNT_USD")}\"")
}
signingConfigs {
@@ -49,6 +62,7 @@ android {
}
buildFeatures {
viewBinding = true
buildConfig = true
}
}
+9
View File
@@ -9,6 +9,10 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
@@ -69,6 +73,11 @@
android:launchMode="singleTop"
android:theme="@style/Theme.BasedBank" />
<service
android:name=".service.NotificationPollingService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name=".nfc.BmlHostCardEmulatorService"
android:exported="true"
@@ -8,6 +8,7 @@ import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.fahipay.FahipaySession
import sh.sar.basedbank.api.mfaisa.MfaisaSession
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.api.mib.MibProfile
@@ -48,6 +49,10 @@ class BasedBankApp : Application() {
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
var fahipayAccounts: List<BankAccount> = emptyList()
/** Active M-Faisa sessions keyed by loginId (= msisdn). */
val mfaisaSessions: MutableMap<String, MfaisaSession> = mutableMapOf()
var mfaisaAccounts: List<BankAccount> = emptyList()
// ─── MIB helpers ──────────────────────────────────────────────────────────
/** Returns the MIB session for the given account (matched via loginTag). */
@@ -110,6 +115,26 @@ class BasedBankApp : Application() {
fun fahipaySessionFor(account: BankAccount): FahipaySession? =
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
// ─── M-Faisa helpers ──────────────────────────────────────────────────────
/** Returns the M-Faisa session for the given account (matched via loginTag = "mfaisa_${msisdn}"). */
fun mfaisaSessionFor(account: BankAccount): MfaisaSession? =
mfaisaSessions[account.loginTag.removePrefix("mfaisa_")]
/**
* Re-runs `fetchSubscriber` + `doMobileLogin` using the saved credentials for [loginId] and
* replaces the cached session. Call this after catching [sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException].
* Returns the fresh session, or null if no credentials are saved for that login.
*/
fun refreshMfaisaSession(loginId: String): MfaisaSession? {
val creds = CredentialStore(this).loadMfaisaCredentials(loginId) ?: return null
val flow = sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow(this)
flow.fetchSubscriber(creds.msisdn)
val result = flow.doMobileLogin(creds.msisdn, creds.pin)
mfaisaSessions[loginId] = result.session
return result.session
}
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
val mibMutex = Mutex()
@@ -279,7 +279,8 @@ class LockActivity : AppCompatActivity() {
finish()
} else {
val store = CredentialStore(this)
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() ||
store.hasFahipayCredentials() || store.hasMfaisaCredentials()
if (!hasCredentials) {
startActivity(Intent(this, sh.sar.basedbank.ui.login.LoginActivity::class.java))
finish()
@@ -41,7 +41,8 @@ class MainActivity : AppCompatActivity() {
val onboardingDone = prefs.getBoolean("onboarding_done", false)
val securitySet = prefs.getString("security_method", null) != null
val store = CredentialStore(this)
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() ||
store.hasFahipayCredentials() || store.hasMfaisaCredentials()
// Image shared via "Scan to Pay" — decode QR here while we still hold the URI permission
val shareQrText: String? = if (intent?.action == Intent.ACTION_SEND &&
@@ -14,9 +14,15 @@ internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
.readTimeout(30, TimeUnit.SECONDS)
.build()
/**
* Headers are sent in the order (and with the names) BML's own app uses. `accept` is not optional:
* handled errors come back as JSON either way, but when a route throws (HTTP 500) the server
* renders the Internet Banking HTML page — under HTTP 200 — unless the request asks for JSON.
*/
internal fun bmlApiRequest(session: BmlSession, url: String): Request =
Request.Builder().url(url)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("accept", "application/json")
.header("x-app-version", BML_APP_VERSION)
.header("user-agent", BML_USER_AGENT)
.header("authorization", "Bearer ${session.accessToken}")
.build()
@@ -0,0 +1,55 @@
package sh.sar.basedbank.api.bml
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
data class BmlCardActionResult(
val success: Boolean,
val message: String
)
class BmlCardClient {
private val client = newBmlApiClient()
/**
* Freezes or unfreezes a BML card.
* @param cardId BML card UUID (BankAccount.internalId)
* @param action "freeze" or "unfreeze"
*/
fun setCardFreezeState(session: BmlSession, cardId: String, action: String): BmlCardActionResult {
val body = JSONObject().apply {
put("card", cardId)
put("action", action)
}.toString().toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$BML_BASE_URL/api/mobile/services/card/freeze")
.post(body)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("accept", "application/json")
.build()
val resp = client.newCall(request).execute()
val code = resp.code
val responseBody = resp.body?.string()
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
if (code in 500..599) throw BankServerException("BML")
return try {
val json = JSONObject(responseBody ?: "")
val ok = json.optBoolean("success") && json.optInt("code") == 0
BmlCardActionResult(
success = ok,
message = json.optString("payload").ifBlank { json.optString("message") }
)
} catch (_: Exception) {
BmlCardActionResult(success = false, message = "")
}
}
}
@@ -9,6 +9,12 @@ import sh.sar.basedbank.api.models.BankTransaction
import java.text.SimpleDateFormat
import java.util.Locale
data class BmlCardHistoryResult(
val statement: List<BankTransaction>,
val outstanding: List<BankTransaction>,
val unbilled: List<BankTransaction>
)
class BmlHistoryClient {
private val client = newBmlApiClient()
@@ -70,7 +76,7 @@ class BmlHistoryClient {
accountDisplayName: String,
accountNumber: String,
month: String
): List<BankTransaction> {
): BmlCardHistoryResult {
val body = """{"card":"$cardId","month":"$month"}"""
.toRequestBody("application/json".toMediaType())
val resp = client.newCall(
@@ -81,76 +87,56 @@ class BmlHistoryClient {
.build()
).execute()
val code = resp.code
val json = resp.body?.string() ?: return emptyList()
val json = resp.body?.string() ?: return BmlCardHistoryResult(emptyList(), emptyList(), emptyList())
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
if (code in 500..599) throw BankServerException("BML")
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val payload = root.optJSONObject("payload") ?: return emptyList()
val result = mutableListOf<BankTransaction>()
if (!root.optBoolean("success")) return BmlCardHistoryResult(emptyList(), emptyList(), emptyList())
val payload = root.optJSONObject("payload")
?: return BmlCardHistoryResult(emptyList(), emptyList(), emptyList())
val authDetails = payload.optJSONObject("outstanding")
?.optJSONArray("CardOutStdAuthDetails")
if (authDetails != null) {
for (i in 0 until authDetails.length()) {
val item = authDetails.getJSONObject(i)
result.add(BankTransaction(
id = "auth_${item.optString("TranApprCode")}_$i",
date = item.optString("DateTime"),
description = item.optString("TranDesc").trim(),
amount = item.optDouble("BillingAmount", 0.0),
currency = item.optString("BillingCcy", "MVR"),
counterpartyName = null,
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML_CARD"
))
}
}
val outstanding = parseCardArray(
payload.optJSONObject("outstanding")?.optJSONArray("CardOutStdAuthDetails"),
idPrefix = "auth", accountNumber, accountDisplayName
)
val unbilled = parseCardArray(
payload.optJSONObject("unbilled")?.optJSONArray("CardUnbillTxnDetails"),
idPrefix = "unbilled", accountNumber, accountDisplayName
)
val statement = parseCardArray(
payload.optJSONArray("cardstatement"),
idPrefix = "stmt", accountNumber, accountDisplayName
)
val unbilled = payload.optJSONObject("unbilled")
?.optJSONArray("CardUnbillTxnDetails")
if (unbilled != null) {
for (i in 0 until unbilled.length()) {
val item = unbilled.getJSONObject(i)
result.add(BankTransaction(
id = "unbilled_${item.optString("TranApprCode")}_$i",
date = item.optString("DateTime"),
description = item.optString("TranDesc").trim(),
amount = item.optDouble("BillingAmount", 0.0),
currency = item.optString("BillingCcy", "MVR"),
counterpartyName = null,
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML_CARD"
))
}
}
BmlCardHistoryResult(statement, outstanding, unbilled)
} catch (_: Exception) { BmlCardHistoryResult(emptyList(), emptyList(), emptyList()) }
}
val statement = payload.optJSONArray("cardstatement")
if (statement != null) {
for (i in 0 until statement.length()) {
val item = statement.getJSONObject(i)
result.add(BankTransaction(
id = "stmt_${item.optString("TranRef", i.toString())}",
date = item.optString("TransDate", item.optString("TranDate", "")),
description = item.optString("TranDesc", item.optString("Description", "")).trim(),
amount = -item.optDouble("TranAmount", 0.0),
currency = item.optString("TranCcy", "MVR"),
counterpartyName = null,
reference = item.optString("TranRef").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML_CARD"
))
}
}
result
} catch (_: Exception) { emptyList() }
private fun parseCardArray(
arr: org.json.JSONArray?,
idPrefix: String,
accountNumber: String,
accountDisplayName: String
): List<BankTransaction> {
if (arr == null) return emptyList()
return (0 until arr.length()).map { i ->
val item = arr.getJSONObject(i)
val ref = item.optString("TranApprCode")
BankTransaction(
id = "${idPrefix}_${ref.ifBlank { i.toString() }}",
date = item.optString("DateTime"),
description = item.optString("TranDesc").trim(),
amount = item.optDouble("BillingAmount", 0.0),
currency = item.optString("BillingCcy", "MVR"),
counterpartyName = null,
reference = ref.takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML_CARD"
)
}
}
fun fetchPendingHistory(
@@ -74,6 +74,13 @@ data class BmlQrPayInfo(
val currency: String
)
/**
* The pay-request lookup answered `success: false` — [message] is BML's own wording, safe to show
* (e.g. code 103 "The payment request has expired", 112 "Unsupported payment link"). Network and
* parse failures stay plain exceptions so the generic message is used for those instead.
*/
class BmlQrPayLookupException(val code: Int, message: String) : Exception(message)
data class BmlQrPayResult(
val success: Boolean,
val merchant: String = "",
@@ -1,5 +1,6 @@
package sh.sar.basedbank.api.bml
import android.util.Base64
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
@@ -10,17 +11,27 @@ class BmlQrPayClient {
private val client = newBmlApiClient()
/**
* Resolves a BML QR URL to merchant details.
* [base64Url] is the full QR URL Base64-encoded (standard, with padding).
* Resolves a BML QR to merchant details. [payTarget] is the QR URL, or for POS QRs the bare
* `35` → `20` → `01` reference.
*
* The key is Base64-encoded here without padding, as BML's own app sends it. The padded form
* resolves too, so this is parity rather than a requirement.
*/
fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo {
fun lookupPayRequest(session: BmlSession, payTarget: String): BmlQrPayInfo {
val key = Base64.encodeToString(
payTarget.toByteArray(Charsets.UTF_8), Base64.NO_WRAP or Base64.NO_PADDING)
val request = bmlApiRequest(session,
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url")
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$key")
return client.newCall(request).execute().use { response ->
val body = response.body?.string() ?: throw Exception("No response")
// A server-side error renders an HTML page under HTTP 200 rather than an error JSON.
if (!body.trimStart().startsWith("{"))
throw Exception("Unexpected non-JSON response (HTTP ${response.code})")
val json = JSONObject(body)
if (!json.optBoolean("success"))
throw Exception(json.optString("message").ifBlank { "Lookup failed" })
throw BmlQrPayLookupException(
json.optInt("code"),
json.optString("message").ifBlank { "Lookup failed" })
val payload = json.getJSONObject("payload")
val addr2 = payload.optString("narrative2").trim()
val addr3 = payload.optString("narrative3").trim()
@@ -71,7 +71,8 @@ class BmlValidateClient {
originalInput = account,
name = root.optString("name"),
alias = null,
currency = "MVR",
// BML's MIB verify endpoint doesn't return the MIB account's currency.
currency = "",
agnt = root.optString("agnt").takeIf { it.isNotBlank() }
)
} catch (_: Exception) { null }
@@ -0,0 +1,35 @@
package sh.sar.basedbank.api.mfaisa
import sh.sar.basedbank.api.models.BankAccount
object MfaisaAccountClient {
/**
* Build one BankAccount per pocket from a login result.
* `loginTag` is "mfaisa_<msisdn>" (one per M-Faisa account on the device).
*/
fun buildAccounts(result: MfaisaLoginResult, loginTag: String): List<BankAccount> {
val displayName = result.profile.name.ifBlank { "M-Faisa" }
return result.pockets.map { p ->
val balance = "%.2f".format(p.balance)
BankAccount(
bank = "MFAISA",
profileName = displayName,
profileType = if (p.pocketValueType == "PAYPAL_USD") "MFAISA_PAYPAL" else "MFAISA",
accountNumber = p.pocketId,
accountBriefName = p.nickname.ifBlank { p.displayName.ifBlank { "M-Faisa" } },
currencyName = p.currency,
accountTypeName = p.displayName.ifBlank { "Mobile Wallet" },
availableBalance = balance,
currentBalance = balance,
blockedAmount = "0.00",
mvrBalance = if (p.currency == "MVR") balance else "0.00",
statusDesc = p.statusType.ifBlank { "ACTIVE" },
profileImageHash = null,
loginTag = loginTag,
profileId = result.profile.subscriberId,
internalId = result.profile.walletId
)
}
}
}
@@ -0,0 +1,67 @@
package sh.sar.basedbank.api.mfaisa
import android.util.Base64
import java.math.BigInteger
import java.security.KeyFactory
import java.security.PublicKey
import java.security.SecureRandom
import java.security.spec.MGF1ParameterSpec
import java.security.spec.RSAPublicKeySpec
import javax.crypto.Cipher
import javax.crypto.spec.OAEPParameterSpec
import javax.crypto.spec.PSource
/**
* Field-level encryption for Ooredoo M-Faisa request payloads.
*
* Both keys live (obfuscated) in libnative-lib.so and were extracted by hooking
* the live app with Frida.
*/
object MfaisaCrypto {
// 1024-bit RSA key. Used for mdnId / mobileNumber / userName.
// Plaintext is "960" + msisdn. Cipher is OAEP/SHA-256.
private val MOBILE_N = BigInteger(
"125043708524451715642963973698406708755269502293565460606118930542682275971580032704131362488150174351194407172452175275612284031366512484449720820404229217064541745811143629538982383390723079478499614160620616911679256603296752844216620113064874342531851472851319065258962732556596958868200227678294957694889"
)
private val MOBILE_E = BigInteger("65537")
// 2048-bit RSA key. Used for mPin. Plaintext is `pin + <6-char alphanumeric salt>`.
// Cipher is OAEP/SHA-1. Output is hex.
private val PIN_N = BigInteger(
"30853988905151679601945771998041800603731623930944610745590884250489036547584511246061683594739124713335100655247634233703624305850983479131604065498722268916133039937128796419041248167624160300158401049118446352988895953596475734156239882174799821436218294725935232359347780127398770443981734096915599443841496235741614376221345134752344583283770986295156829944214841171989893291834036934949311011654192369326666754259268756426483563391867503815261490458479377640385950664660570354934951526319509191336410208609648686869010157285218492218371799827560010164293202383337546810220755107741865769246084291990864545504123"
)
private val PIN_E = BigInteger("65537")
private val mobileKey: PublicKey by lazy { rsaPublicKey(MOBILE_N, MOBILE_E) }
private val pinKey: PublicKey by lazy { rsaPublicKey(PIN_N, PIN_E) }
private val random = SecureRandom()
private const val SALT_ALPHABET =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
/** Encrypts "960" + MSISDN. Output is non-deterministic (OAEP random padding). */
fun encryptMobile(msisdn: String): String {
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")
val params = OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT)
cipher.init(Cipher.ENCRYPT_MODE, mobileKey, params)
val ct = cipher.doFinal(("960" + msisdn).toByteArray(Charsets.UTF_8))
return Base64.encodeToString(ct, Base64.NO_WRAP)
}
/** Encrypts `pin + <6-char random alphanumeric salt>`. Output is hex (lowercase). */
fun encryptPin(pin: String): String {
val salt = buildString {
repeat(6) { append(SALT_ALPHABET[random.nextInt(SALT_ALPHABET.length)]) }
}
val plaintext = (pin + salt).toByteArray(Charsets.UTF_8)
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding")
val params = OAEPParameterSpec("SHA-1", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT)
cipher.init(Cipher.ENCRYPT_MODE, pinKey, params)
val ct = cipher.doFinal(plaintext)
return ct.joinToString("") { "%02x".format(it) }
}
private fun rsaPublicKey(n: BigInteger, e: BigInteger): PublicKey =
KeyFactory.getInstance("RSA").generatePublic(RSAPublicKeySpec(n, e))
}
@@ -0,0 +1,181 @@
package sh.sar.basedbank.api.mfaisa
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import sh.sar.basedbank.api.models.BankTransaction
import java.security.SecureRandom
import java.util.concurrent.TimeUnit
import java.util.zip.Adler32
/**
* Fetches the M-Faisa transaction summary for the active subscriber session.
*
* Endpoint: POST /transactionInquiry/fetchSummary
*
* Two extra anti-replay fields are required:
* - rndValue : RSA-OAEP-SHA1 encryption of a fresh timestamp+salt with the mPin key
* (i.e. the same routine as [MfaisaCrypto.encryptPin] applied to a timestamp)
* - csValue : Adler32(formDataJson + timestampPlaintext), as a decimal string
*/
class MfaisaHistoryClient {
private val baseUrl = "https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val random = SecureRandom()
/**
* Fetches one page (`pageNo` is 1-based; recordSize defaults to 70 — the official app's value).
* Returns the parsed transactions and a flag indicating whether the server returned a full
* page (= more pages may be available).
*/
data class Page(val transactions: List<BankTransaction>, val hasMore: Boolean)
fun fetchHistory(
session: MfaisaSession,
accountNumber: String,
accountDisplayName: String,
pageNo: Int,
recordSize: Int = 70
): Page {
if (session.loginExchangeKey.isBlank() || session.subscriberId.isBlank() || session.msisdn.isBlank()) {
throw IllegalStateException("M-Faisa session is missing fields required for fetchSummary")
}
val innerMdn = MfaisaCrypto.encryptMobile(session.msisdn)
val outerMdn = MfaisaCrypto.encryptMobile(session.msisdn) // independent encryption
val formData = JSONObject()
.put("actorRole", "RETAIL_SUBSCRIBER")
.put("actorRoleId", session.subscriberId)
.put("fromDate", "")
.put("mdnId", innerMdn)
.put("pageNo", pageNo.toString())
.put("recordSize", recordSize.toString())
.put("toDate", "")
.put("transactionType", "")
val formJson = formData.toString().matchGsonHtmlSafe()
// Anti-replay: nonce_str = (currentTimeMillis() + offset). Offset is small noise (0..5).
val offset = (random.nextInt(5) + 10) xor 0xE
val nonceStr = (System.currentTimeMillis() + offset).toString()
// rndValue uses the same key/cipher as the mPin encryption — see [MfaisaCrypto.encryptPin].
val rndValue = MfaisaCrypto.encryptPin(nonceStr)
val csValue = Adler32().apply { update((formJson + nonceStr).toByteArray(Charsets.UTF_8)) }
.value.toString()
val body = FormBody.Builder()
.add("role", "RETAIL_SUBSCRIBER")
.add("channel", "SubscriberApp")
.add("rndValue", rndValue)
.add("loginExchangeKey", session.loginExchangeKey)
.add("formData", formJson)
.add("mdnId", outerMdn)
.add("csValue", csValue)
.build()
val resp = client.newCall(
Request.Builder().url("$baseUrl/transactionInquiry/fetchSummary").post(body).build()
).execute()
val code = resp.code
val raw = resp.body?.string() ?: throw Exception("Empty history response")
resp.close()
if (code in 500..599) throw BankServerException("Ooredoo M-Faisa")
// The server returns its error envelope as a JSON array even on HTTP 200.
val trimmed = raw.trimStart()
if (trimmed.startsWith("[")) {
val errArr = JSONArray(trimmed)
val first = errArr.optJSONObject(0)
val errObj = first?.optJSONArray("error")?.optJSONObject(0)
val attrVal = errObj?.optString("attributeValue")
val errCode = errObj?.optString("errorCode")
if (attrVal == "SESSION_EXPIRED" || errCode == "SESSION_EXPIRED") {
throw MfaisaSessionExpiredException()
}
val msg = errObj?.optString("errorMessage") ?: first?.optString("message")
throw Exception(msg?.ifBlank { null } ?: "M-Faisa history failed")
}
val obj = JSONObject(trimmed)
val arr = obj.optJSONArray("transactionInquiryDTOList") ?: JSONArray()
val out = mutableListOf<BankTransaction>()
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i) ?: continue
out += parse(o, accountNumber, accountDisplayName)
}
// The server returns nothing useful for "total"; assume more pages exist when this page is full.
return Page(out, hasMore = arr.length() >= recordSize)
}
private fun parse(o: JSONObject, accountNumber: String, accountDisplayName: String): BankTransaction {
val trnDate = o.optString("trnDate") // "yyyy-MM-dd HH:mm:ss" — already in target format
val trnType = o.optString("trnType") // CASH_IN | PURCHASE | TRANSFER | …
val status = o.optString("status") // SUCCESS | FAILED
val amtObj = o.optJSONObject("transactionAmount") ?: JSONObject()
val amount = amtObj.optDouble("amount", 0.0)
val currency = amtObj.optString("currencyCode", "MVR")
val refId = o.optString("referenceId").ifBlank { o.optString("requestId") }
val narration = o.optString("narrationString").ifBlank { trnType.replace("_", " ").lowercase().replaceFirstChar { it.uppercase() } }
// Direction: CASH_IN / TRANSFER_IN etc. are credits, everything else is a debit
val isCredit = trnType.endsWith("_IN") ||
trnType == "CASH_IN" ||
trnType == "RECEIVE_MONEY"
val signedAmount = if (isCredit) amount else -amount
// Counterparty hint: parse the typeSummaryString for richer info if present
val counterparty = extractCounterparty(o)
// Failed transactions still appear in the list — we still show them but tag in the description.
val description = if (status == "FAILED") "$narration · Failed" else narration
return BankTransaction(
id = refId,
date = trnDate,
description = description,
amount = signedAmount,
currency = currency,
counterpartyName = counterparty,
reference = refId.ifBlank { null },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "MFAISA"
)
}
/** Best-effort counterparty / merchant name extraction from the response's nested JSON. */
private fun extractCounterparty(o: JSONObject): String? {
// typeSummaryString is itself a JSON-encoded array string
val ts = o.optString("typeSummaryString").trim()
if (ts.startsWith("[")) {
try {
val arr = JSONArray(ts)
for (i in 0 until arr.length()) {
val item = arr.optJSONObject(i) ?: continue
item.optString("Merchant Name").takeIf { it.isNotBlank() }?.let { return it }
item.optString("Receiver Name").takeIf { it.isNotBlank() }?.let { return it }
item.optString("Sender Name").takeIf { it.isNotBlank() }?.let { return it }
}
} catch (_: Exception) { /* fall through */ }
}
// sourceMDN like "Shiham-DT Pocket-9609198026" — the bit before the first dash is the user-facing name
val source = o.optString("sourceMDN")
if (source.isNotBlank() && source.contains("-")) return source.substringBefore("-")
return null
}
/**
* Match the official app's Gson serialiser: replace `=` with the Unicode-escaped equivalent so
* the M-Faisa server's strict parser accepts the payload (same trick as in [MfaisaLoginFlow]).
*/
private fun String.matchGsonHtmlSafe(): String =
replace("\\/", "/").replace("=", "\\u003d")
}
@@ -0,0 +1,202 @@
package sh.sar.basedbank.api.mfaisa
import android.content.Context
import android.os.Build
import android.provider.Settings
import okhttp3.FormBody
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import java.util.concurrent.TimeUnit
class MfaisaLoginFlow(context: Context) {
private val BASE_URL = "https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web"
// Do NOT set User-Agent explicitly: Cloudflare in front of superapp.ooredoo.mv
// fingerprints header order, and an explicit .header("User-Agent", ...) call
// pushes it to the front of the request, returning 400. Letting OkHttp's
// BridgeInterceptor add its default "okhttp/4.12.0" at the end matches the
// official app's on-wire ordering and gets 200.
private val appContext = context.applicationContext
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
/**
* Step 0: look up the subscriber by MSISDN and verify they have Full KYC.
* Throws [MfaisaKycRequiredException] if kycStatus != "Full KYC".
* Throws [MfaisaWalletNotReadyException] if the wallet isn't registered / activated / PIN-set.
*/
fun fetchSubscriber(msisdn: String): JSONObject {
val body = JSONObject()
.put("mdnId", MfaisaCrypto.encryptMobile(msisdn))
.toString()
.matchGsonHtmlSafe()
.toRequestBody("application/json; charset=UTF-8".toMediaType())
val resp = client.newCall(
Request.Builder().url("$BASE_URL/fetchSubscriberByMDN")
.post(body)
.build()
).execute()
val code = resp.code
val raw = resp.body?.string() ?: throw Exception("Empty subscriber response")
resp.close()
if (code in 500..599) throw BankServerException("Ooredoo M-Faisa")
val obj = JSONObject(raw)
if (!obj.optBoolean("success", false)) {
throw Exception(obj.optString("message").ifBlank { "Could not look up this number" })
}
if (!obj.optBoolean("subscriberRegistered", false)) {
throw MfaisaNotRegisteredException()
}
if (!obj.optBoolean("passwordCreated", false)) {
throw MfaisaWalletNotReadyException("Set your M-Faisa mPIN in the Ooredoo SuperApp first, then try again.")
}
if (obj.optBoolean("activationPending", false)) {
throw MfaisaWalletNotReadyException("Your M-Faisa wallet activation is still pending. Complete it in the Ooredoo SuperApp first.")
}
val kyc = obj.optString("kycStatus")
if (kyc != "Full KYC") {
throw MfaisaKycRequiredException(kyc)
}
return obj
}
/**
* Step 1: submit the PIN. Returns parsed login result on success.
* Throws [MfaisaInvalidPinException] on a rejected PIN (with [MfaisaInvalidPinException.lastAttempt] = true
* if the server's message says "one more wrong attempt will lock your account").
*/
fun doMobileLogin(msisdn: String, pin: String): MfaisaLoginResult {
val mobileEnc = MfaisaCrypto.encryptMobile(msisdn)
val userNameEnc = MfaisaCrypto.encryptMobile(msisdn) // independent encryption, same plaintext
val pinEnc = MfaisaCrypto.encryptPin(pin)
val deviceId = androidId()
val deviceGeo = JSONObject()
.put("appType", "CustomerAndroid")
.put("appversion", "1.0")
.put("deviceId", deviceId)
.put("deviceManufacturer", Build.MANUFACTURER)
.put("imieNumber", deviceId)
.put("ipaddress", "11.22.33.55")
.put("latitude", "0.0")
.put("longitude", "0.0")
.put("simId", deviceId)
val formData = JSONObject()
.put("deviceGeoInfo", deviceGeo)
.put("mPin", pinEnc)
.put("mobileNumber", mobileEnc)
.put("role", "RETAIL_SUBSCRIBER")
.put("tenantCode", "ooredoo")
.put("userName", userNameEnc)
.toString()
.matchGsonHtmlSafe()
val body = FormBody.Builder()
.add("channel", "C03")
.add("formData", formData)
.add("formDataCs", "null")
.build()
val resp = client.newCall(
Request.Builder().url("$BASE_URL/doMobileLogin")
.post(body)
.build()
).execute()
val code = resp.code
val raw = resp.body?.string() ?: throw Exception("Empty login response")
resp.close()
if (code in 500..599) throw BankServerException("Ooredoo M-Faisa")
// Wrong-PIN response is a JSON array; success is an object.
val trimmed = raw.trimStart()
if (trimmed.startsWith("[")) {
val arr = JSONArray(trimmed)
val first = arr.optJSONObject(0)
val errObj = first?.optJSONArray("error")?.optJSONObject(0)
val msg = errObj?.optString("errorMessage")
?: first?.optString("message") ?: "Login failed"
val lastAttempt = msg.contains("one more", ignoreCase = true) ||
msg.contains("will lock", ignoreCase = true)
throw MfaisaInvalidPinException(msg, lastAttempt)
}
val obj = try { JSONObject(trimmed) } catch (e: JSONException) {
throw Exception("Unexpected login response")
}
if (!obj.optBoolean("success", false)) {
throw MfaisaInvalidPinException(obj.optString("message").ifBlank { "Login failed" }, false)
}
// Defensive: server also returns kycStatus on success.
val kyc = obj.optString("kycStatus")
if (kyc.isNotBlank() && kyc != "Full KYC") {
throw MfaisaKycRequiredException(kyc)
}
val session = MfaisaSession(
loginExchangeKey = obj.optString("loginExchangeKey"),
sessionTimeoutSec = obj.optString("mobileLoginSessionTimeout").toIntOrNull() ?: 240,
msisdn = msisdn,
subscriberId = obj.optString("suscriberId")
)
// pocketDetails[0] holds this user's identity + pockets.
val pd = obj.optJSONArray("pocketDetails")?.optJSONObject(0) ?: JSONObject()
val profile = MfaisaUserProfile(
name = pd.optString("name").ifBlank { "M-Faisa" },
email = pd.optString("eMailId"),
mdnId = pd.optString("mdnId").ifBlank { msisdn },
roleId = pd.optString("roleId"),
walletId = pd.optString("walletId"),
subscriberId = obj.optString("suscriberId"),
offerId = pd.optString("offerId")
)
val pockets = mutableListOf<MfaisaPocket>()
val pktArr = pd.optJSONArray("pocketSummaryDetailsArrayDTO") ?: JSONArray()
for (i in 0 until pktArr.length()) {
val p = pktArr.getJSONObject(i)
val bal = p.optJSONObject("balanceAmount") ?: JSONObject()
pockets += MfaisaPocket(
pocketId = p.optString("pocketId"),
pocketType = p.optString("pocketType"),
pocketValueType = p.optString("pocketValueType"),
nickname = p.optString("nickName"),
currency = bal.optString("currencyCode", "MVR"),
balance = bal.optDouble("amount", 0.0),
isDefault = p.optBoolean("isDefaultPocket", false),
isSecondary = p.optBoolean("isSecondaryPocket", false),
statusType = p.optString("statusType"),
displayName = p.optString("displayName")
)
}
return MfaisaLoginResult(session, profile, pockets)
}
private fun androidId(): String {
return Settings.Secure.getString(appContext.contentResolver, Settings.Secure.ANDROID_ID)
?: "0000000000000000"
}
/**
* Make the body byte-identical to what the official app's Gson serializer emits:
* 1. `\/` (org.json's default escape for `/`) → `/`
* 2. `=` → `=` (Gson `htmlSafe` mode)
* Both are technically valid JSON either way, but the M-Faisa server's parser appears strict.
*/
private fun String.matchGsonHtmlSafe(): String =
replace("\\/", "/").replace("=", "\\u003d")
}
@@ -0,0 +1,69 @@
package sh.sar.basedbank.api.mfaisa
data class MfaisaSession(
val loginExchangeKey: String,
val sessionTimeoutSec: Int,
val msisdn: String = "",
val subscriberId: String = ""
)
/** Subset of the doMobileLogin success response we keep around. */
data class MfaisaUserProfile(
val name: String,
val email: String,
val mdnId: String,
val roleId: String,
val walletId: String,
val subscriberId: String,
val offerId: String
)
/** Pocket = M-Faisa balance bucket (E-Money MVR, IMT MVR, PayPal USD). */
data class MfaisaPocket(
val pocketId: String,
val pocketType: String, // INTERNAL, ...
val pocketValueType: String, // EMONEY, PAYPAL_USD, ...
val nickname: String,
val currency: String, // MVR, USD
val balance: Double,
val isDefault: Boolean,
val isSecondary: Boolean,
val statusType: String,
val displayName: String
)
data class MfaisaLoginResult(
val session: MfaisaSession,
val profile: MfaisaUserProfile,
val pockets: List<MfaisaPocket>
)
/** Thrown when the wallet is not "Full KYC" — login must abort. */
class MfaisaKycRequiredException(val kycStatus: String) :
Exception("M-Faisa wallet is not fully verified (kycStatus=$kycStatus)")
/** Thrown when this MSISDN has no M-Faisa wallet at all — user must sign up in the Ooredoo SuperApp. */
class MfaisaNotRegisteredException : Exception("This number does not have an M-Faisa wallet")
/** Thrown when fetchSubscriberByMDN says the wallet exists but is not yet usable (no PIN, activation pending, …). */
class MfaisaWalletNotReadyException(message: String) : Exception(message)
/**
* Thrown for an invalid PIN. The PIN field should be re-enabled.
* [lastAttempt] is true when the server's message warns the user one more wrong attempt will lock their account.
*/
class MfaisaInvalidPinException(message: String, val lastAttempt: Boolean = false) : Exception(message)
/**
* Thrown when a session-scoped M-Faisa endpoint returns the
* `[{ ..., "attributeValue": "SESSION_EXPIRED", ... }]` envelope (still as HTTP 200).
* Callers should re-run the login (`fetchSubscriber` + `doMobileLogin`) using the saved
* credentials and retry the request once.
*/
class MfaisaSessionExpiredException : Exception("M-Faisa session expired")
/** Thrown by [MfaisaTransferClient.searchRecipient] when no M-Faisa wallet exists for the queried MSISDN. */
class MfaisaRecipientNotFoundException : Exception("No M-Faisa wallet found for this number")
/** Thrown by [MfaisaTransferClient.confirmTransfer] when the OTP is rejected. */
class MfaisaInvalidOtpException(message: String) : Exception(message)
@@ -0,0 +1,199 @@
package sh.sar.basedbank.api.mfaisa
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import java.security.SecureRandom
import java.util.concurrent.TimeUnit
import java.util.zip.Adler32
/**
* M-Faisa merchant QR payment ("smart pay") flow:
* 1. [fetchQrDetails] POST /QRCodeUtility/fetchQRCodeById — resolve qrCodeId to merchant
* 2. [initiatePurchase] POST /initiateNewBuy — start the purchase, returns referenceId.
* Server returns 2FARequired=NONE for wallet QR pay,
* so no OTP is required.
* 3. [confirmPurchase] POST /confirmNewBuy — settles the purchase. `transactionAuthDetails`
* is sent as the literal string "null".
*
* Anti-replay scheme is the same as [MfaisaTransferClient]: rndValue = encryptPin(timestampStr),
* csValue = Adler32(formDataJson + timestampStr). The server responds with `[{...}]` envelopes
* for both success and error — callers must check the `success` flag.
*/
class MfaisaQrPayClient {
private val baseUrl = "https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val random = SecureRandom()
/** Resolved merchant for a scanned M-Faisa QR. */
data class QrMerchant(
val qrCodeId: String,
val merchantId: String, // customerId from the lookup response
val merchantName: String, // commercialName
val merchantMsisdn: String, // mobileNumber — already includes "960" prefix
val currencyCode: String, // e.g. "MVR"
/** Pre-set amount for a dynamic QR; null for a static QR (user enters amount). */
val txnAmount: String?,
val status: String // "Active" for usable QRs
)
// ─── Step 1: resolve qrCodeId → merchant details ─────────────────────────
fun fetchQrDetails(session: MfaisaSession, qrCodeId: String): QrMerchant {
val formData = JSONObject()
.put("qrCodeId", qrCodeId)
.put("tenantCode", "ooredoo")
.toString().matchGsonHtmlSafe()
val (rnd, cs) = makeAntiReplay(formData)
// Note: fetchQRCodeById uses role=R01 (not RETAIL_SUBSCRIBER like the other two endpoints).
val body = FormBody.Builder()
.add("role", "R01")
.add("channel", "C03")
.add("rndValue", rnd)
.add("formData", formData)
.add("loginExchangeKey", session.loginExchangeKey)
.add("csValue", cs)
.build()
val first = postAndUnwrap("$baseUrl/QRCodeUtility/fetchQRCodeById", body, "QR lookup failed")
val response = first.optJSONArray("response")?.optJSONObject(0)
?: throw Exception("QR code not found")
if (!response.optString("status").equals("Active", ignoreCase = true)) {
throw Exception("QR code is not active")
}
// The lookup response stores absent values as the literal JSON null (decoded by org.json as
// `JSONObject.NULL`) — optString surfaces that as the string "null". Guard against both.
fun strOrNull(name: String): String? = response.opt(name)
?.takeIf { it != JSONObject.NULL }
?.toString()
?.takeIf { it.isNotBlank() && it != "null" }
return QrMerchant(
qrCodeId = strOrNull("qrCodeId") ?: qrCodeId,
merchantId = strOrNull("customerId") ?: throw Exception("Merchant id missing"),
merchantName = strOrNull("commercialName") ?: throw Exception("Merchant name missing"),
merchantMsisdn = strOrNull("mobileNumber") ?: throw Exception("Merchant number missing"),
currencyCode = strOrNull("currencyCode") ?: "MVR",
txnAmount = strOrNull("txnAmount"),
status = response.optString("status")
)
}
// ─── Step 2: initiate the purchase ───────────────────────────────────────
/** Returns the `referenceId` to be passed to [confirmPurchase]. */
fun initiatePurchase(
session: MfaisaSession,
sourcePocketId: String,
sourceMsisdn: String, // user's "960..." MSISDN
merchant: QrMerchant,
amount: String,
description: String = ""
): String {
val formData = JSONObject()
.put("channel", "SubscriberApp")
.put("commodityType", "WALLET")
.put("description", description)
.put("merchantId", merchant.merchantId)
.put("mobileNumber", merchant.merchantMsisdn)
.put("sourceDetails", JSONObject()
.put("MDNId", sourceMsisdn)
.put("actorRoleType", "RETAIL_SUBSCRIBER")
.put("pocketId", sourcePocketId))
.put("transactionAmount", amount)
.put("transactionCurrency", merchant.currencyCode)
.put("transactionType", "PURCHASE")
.toString().matchGsonHtmlSafe()
val (rnd, cs) = makeAntiReplay(formData)
val body = FormBody.Builder()
.add("role", "RETAIL_SUBSCRIBER")
.add("channel", "C03")
.add("rndValue", rnd)
.add("formData", formData)
.add("loginExchangeKey", session.loginExchangeKey)
.add("csValue", cs)
.build()
val first = postAndUnwrap("$baseUrl/initiateNewBuy", body, "Payment initiation failed")
// We've only seen 2FARequired=NONE for wallet QR pay. If the server ever asks for OTP we
// surface a clear error instead of silently completing a no-op confirm.
val twoFa = first.optString("2FARequired").ifBlank { "NONE" }
if (!twoFa.equals("NONE", ignoreCase = true)) {
throw Exception("This QR requires 2FA ($twoFa) which is not yet supported")
}
val responseObj = first.optJSONArray("response")?.optJSONObject(0)?.optJSONObject("responseObject")
?: throw Exception("Missing responseObject")
val refId = responseObj.optString("referenceId")
if (refId.isBlank()) throw Exception("Server did not return a referenceId")
return refId
}
// ─── Step 3: confirm (no OTP) ────────────────────────────────────────────
fun confirmPurchase(session: MfaisaSession, referenceId: String) {
val formData = JSONObject().put("referenceId", referenceId).toString().matchGsonHtmlSafe()
val (rnd, cs) = makeAntiReplay(formData)
val body = FormBody.Builder()
.add("role", "RETAIL_SUBSCRIBER")
.add("channel", "C03")
.add("rndValue", rnd)
// Literal string "null" — matches the captured request from the official app.
.add("transactionAuthDetails", "null")
.add("formData", formData)
.add("loginExchangeKey", session.loginExchangeKey)
.add("csValue", cs)
.build()
postAndUnwrap("$baseUrl/confirmNewBuy", body, "Payment confirmation failed")
}
// ─── Helpers ─────────────────────────────────────────────────────────────
/** POSTs [body] to [url], unwraps the `[{...}]` envelope, throws on non-success / session expiry. */
private fun postAndUnwrap(url: String, body: okhttp3.RequestBody, fallbackError: String): JSONObject {
val resp = client.newCall(Request.Builder().url(url).post(body).build()).execute()
val code = resp.code
val raw = resp.body?.string() ?: throw Exception("Empty response")
resp.close()
if (code in 500..599) throw BankServerException("Ooredoo M-Faisa")
val arr = JSONArray(raw.trimStart())
val first = arr.optJSONObject(0) ?: throw Exception(fallbackError)
handleSessionExpiry(first)
if (!first.optBoolean("success", false)) {
val errObj = first.optJSONArray("error")?.optJSONObject(0)
throw Exception(errObj?.optString("errorMessage")?.ifBlank { null }
?: first.optString("message").ifBlank { fallbackError })
}
return first
}
private fun handleSessionExpiry(envelope: JSONObject?) {
val attr = envelope?.optJSONArray("error")?.optJSONObject(0)?.optString("attributeValue")
val code = envelope?.optJSONArray("error")?.optJSONObject(0)?.optString("errorCode")
if (attr == "SESSION_EXPIRED" || code == "SESSION_EXPIRED") throw MfaisaSessionExpiredException()
}
private fun makeAntiReplay(formJson: String): Pair<String, String> {
val offset = (random.nextInt(5) + 10) xor 0xE
val nonceStr = (System.currentTimeMillis() + offset).toString()
val rndValue = MfaisaCrypto.encryptPin(nonceStr)
val csValue = Adler32().apply {
update((formJson + nonceStr).toByteArray(Charsets.UTF_8))
}.value.toString()
return rndValue to csValue
}
private fun String.matchGsonHtmlSafe(): String =
replace("\\/", "/").replace("=", "\\u003d")
}
@@ -0,0 +1,269 @@
package sh.sar.basedbank.api.mfaisa
import android.os.Build
import android.provider.Settings
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import java.security.SecureRandom
import java.util.concurrent.TimeUnit
import java.util.zip.Adler32
/**
* Three-step M-Faisa transfer flow:
* 1. [searchRecipient] POST /Pocket/basicBeneDetails — look up the recipient
* 2. [initiateTransfer] POST /initiateFTRequest — kicks off the transfer; server SMSes an OTP
* 3. [confirmTransfer] POST /confirmFTRequest — submit the OTP to actually move the money
*
* Every request uses the same anti-replay scheme as the history endpoint (see [MfaisaHistoryClient]):
* `rndValue` = `encryptPin(timestampStr)` and `csValue` = `Adler32(formDataJson + timestampStr)`.
*
* On a session timeout the server returns `[{... "attributeValue":"SESSION_EXPIRED" ...}]` with HTTP 200;
* each method throws [MfaisaSessionExpiredException] in that case so the caller can re-login and retry.
*/
class MfaisaTransferClient(private val deviceId: String) {
private val baseUrl = "https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val random = SecureRandom()
// ─── Step 1: recipient lookup ────────────────────────────────────────────
/**
* Result of [searchRecipient]. The MVR pocket (`isMvr`) is the only target for outgoing transfers
* in Thijooree — PayPal pockets are not supported as recipients.
*/
data class Recipient(
val name: String,
val msisdn: String, // already includes the "960" prefix, as returned by the server
val mvrPocketId: String?,
val paypalPocketId: String?,
val walletId: String,
val actorId: String
) {
val isMvr: Boolean get() = mvrPocketId != null
}
/** @throws MfaisaRecipientNotFoundException if no M-Faisa wallet exists for [recipientMsisdn]. */
fun searchRecipient(session: MfaisaSession, recipientMsisdn: String): Recipient {
require(session.msisdn.isNotBlank() && session.subscriberId.isNotBlank()) {
"session is missing fields required for basicBeneDetails"
}
val formData = JSONObject()
.put("beneficaryDetails", JSONObject()
.put("MDNId", MfaisaCrypto.encryptMobile(recipientMsisdn))
.put("actorRoleType", "RETAIL_SUBSCRIBER"))
.put("initiatorDetailsDTO", JSONObject()
.put("initiatingMDN", MfaisaCrypto.encryptMobile(session.msisdn))
.put("initiatingRoleId", session.subscriberId)
.put("initiatorRole", "RETAIL_SUBSCRIBER"))
.toString().matchGsonHtmlSafe()
val (rnd, cs) = makeAntiReplay(formData)
val body = FormBody.Builder()
.add("role", "RETAIL_SUBSCRIBER")
.add("channel", "SubscriberApp")
.add("rndValue", rnd)
.add("formData", formData)
.add("loginExchangeKey", session.loginExchangeKey)
.add("csValue", cs)
.build()
val raw = execute("$baseUrl/Pocket/basicBeneDetails", body)
// Response shape: `[{ success, response: [[pocket1, pocket2, ...]] }]`
val arr = JSONArray(raw.trimStart())
val first = arr.optJSONObject(0) ?: throw Exception("Unexpected response")
if (!first.optBoolean("success", false)) {
handleSessionExpiry(first)
val msg = first.optString("message")
if (msg.contains("not found", ignoreCase = true)) throw MfaisaRecipientNotFoundException()
throw Exception(msg.ifBlank { "Recipient lookup failed" })
}
val outer = first.optJSONArray("response") ?: throw Exception("Empty recipient list")
val pockets = outer.optJSONArray(0) ?: throw MfaisaRecipientNotFoundException()
if (pockets.length() == 0) throw MfaisaRecipientNotFoundException()
var name = ""
var msisdn = ""
var mvr: String? = null
var paypal: String? = null
var wallet = ""
var actor = ""
for (i in 0 until pockets.length()) {
val p = pockets.getJSONObject(i)
if (name.isBlank()) name = p.optString("name")
if (msisdn.isBlank()) msisdn = p.optString("MDNId")
if (wallet.isBlank()) wallet = p.optString("walletId")
if (actor.isBlank()) actor = p.optString("actorId")
when (p.optString("pocketValueType")) {
"EMONEY" -> mvr = p.optString("pocketId")
"PAYPAL_USD" -> paypal = p.optString("pocketId")
}
}
return Recipient(name, msisdn, mvr, paypal, wallet, actor)
}
// ─── Step 2: initiate (server sends OTP) ─────────────────────────────────
/** Returns the `referenceId` to be passed to [confirmTransfer]. */
fun initiateTransfer(
session: MfaisaSession,
sourcePocketId: String,
recipient: Recipient,
amount: String,
description: String
): String {
require(recipient.isMvr) { "M-Faisa transfers can only target the recipient's MVR pocket" }
// Inner formData JSON. The server expects PLAINTEXT mobile numbers here (already
// prefixed with "960" — recipient.msisdn already includes that), unlike step 1 which
// encrypts them.
val formData = JSONObject()
.put("MDNId", recipient.msisdn)
.put("beneDetails", JSONObject()
.put("miscDetails", description)
.put("transferMode", "MOBILE"))
.put("channel", "SubscriberApp")
.put("commodityType", "WALLET")
.put("description", description)
.put("inputDetailsDTO", JSONObject()
.put("deviceId", deviceId)
.put("simId", deviceId))
.put("mfs-transactionType", "send-money-to-mobile")
.put("pocketId", "")
.put("sourceDetails", JSONObject()
.put("MDNId", "960${session.msisdn}")
.put("actorRoleType", "RETAIL_SUBSCRIBER")
.put("pocketId", sourcePocketId))
.put("transactionAmount", amount)
.put("transactionCurrency", "MVR")
.put("transferMode", "MOBILE")
.toString().matchGsonHtmlSafe()
val (rnd, cs) = makeAntiReplay(formData)
// The "identifier" top-level field is the recipient MDN re-encrypted (matches step 1's
// `beneficaryDetails.MDNId` plaintext; independent OAEP randomness gives a different ciphertext).
val identifier = MfaisaCrypto.encryptMobile(recipient.msisdn.removePrefix("960"))
val body = FormBody.Builder()
.add("identifier", identifier)
.add("role", "RETAIL_SUBSCRIBER")
.add("transferMode", "MOBILE")
.add("channel", "C03") // NB: top-level "C03", inner formData.channel is "SubscriberApp"
.add("rndValue", rnd)
.add("formData", formData)
.add("loginExchangeKey", session.loginExchangeKey)
.add("tPin", "")
.add("csValue", cs)
.build()
val raw = execute("$baseUrl/initiateFTRequest", body)
val trimmed = raw.trimStart()
if (trimmed.startsWith("[")) {
val errArr = JSONArray(trimmed)
handleSessionExpiry(errArr.optJSONObject(0))
val errObj = errArr.optJSONObject(0)?.optJSONArray("error")?.optJSONObject(0)
throw Exception(errObj?.optString("errorMessage")?.ifBlank { null } ?: "Transfer initiation failed")
}
val obj = JSONObject(trimmed)
if (!obj.optBoolean("success", false)) {
throw Exception(obj.optString("message").ifBlank { "Transfer initiation failed" })
}
val responseArr = obj.optJSONArray("response") ?: throw Exception("Missing response array")
val responseObj = responseArr.optJSONObject(0)?.optJSONObject("responseObject")
?: throw Exception("Missing responseObject")
val refId = responseObj.optString("referenceId")
if (refId.isBlank()) throw Exception("Server did not return a referenceId")
return refId
}
// ─── Step 3: confirm with OTP ────────────────────────────────────────────
/** Submits [otpCode] for [referenceId]. Throws on invalid OTP / server failure. */
fun confirmTransfer(session: MfaisaSession, referenceId: String, otpCode: String) {
val formData = JSONObject().put("referenceId", referenceId).toString().matchGsonHtmlSafe()
val transactionAuthDetails = JSONObject()
.put("authenticationType", "OTP")
.put("authenticationValue", MfaisaCrypto.encryptPin(otpCode)) // same cipher as PIN
.put("otpTransactionType", "TRANSACTION")
.put("referenceId", referenceId)
.toString().matchGsonHtmlSafe()
val (rnd, cs) = makeAntiReplay(formData)
val body = FormBody.Builder()
.add("role", "RETAIL_SUBSCRIBER")
.add("channel", "C03")
.add("rndValue", rnd)
.add("transactionAuthDetails", transactionAuthDetails)
.add("formData", formData)
.add("loginExchangeKey", session.loginExchangeKey)
.add("csValue", cs)
.build()
val raw = execute("$baseUrl/confirmFTRequest", body)
val trimmed = raw.trimStart()
if (trimmed.startsWith("[")) {
val errArr = JSONArray(trimmed)
handleSessionExpiry(errArr.optJSONObject(0))
val errObj = errArr.optJSONObject(0)?.optJSONArray("error")?.optJSONObject(0)
val attr = errObj?.optString("attributeName")
val msg = errObj?.optString("errorMessage")?.ifBlank { null }
?: errArr.optJSONObject(0)?.optString("message")
if (attr.equals("OTP", ignoreCase = true) || (msg ?: "").contains("OTP", ignoreCase = true)) {
throw MfaisaInvalidOtpException(msg ?: "Invalid OTP")
}
throw Exception(msg ?: "Transfer confirmation failed")
}
val obj = JSONObject(trimmed)
if (!obj.optBoolean("success", false)) {
throw Exception(obj.optString("message").ifBlank { "Transfer confirmation failed" })
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────
private fun execute(url: String, body: okhttp3.RequestBody): String {
val resp = client.newCall(Request.Builder().url(url).post(body).build()).execute()
val code = resp.code
val raw = resp.body?.string() ?: throw Exception("Empty response")
resp.close()
if (code in 500..599) throw BankServerException("Ooredoo M-Faisa")
return raw
}
private fun handleSessionExpiry(envelope: JSONObject?) {
val attr = envelope?.optJSONArray("error")?.optJSONObject(0)?.optString("attributeValue")
val code = envelope?.optJSONArray("error")?.optJSONObject(0)?.optString("errorCode")
if (attr == "SESSION_EXPIRED" || code == "SESSION_EXPIRED") throw MfaisaSessionExpiredException()
}
private fun makeAntiReplay(formJson: String): Pair<String, String> {
val offset = (random.nextInt(5) + 10) xor 0xE
val nonceStr = (System.currentTimeMillis() + offset).toString()
val rndValue = MfaisaCrypto.encryptPin(nonceStr)
val csValue = Adler32().apply {
update((formJson + nonceStr).toByteArray(Charsets.UTF_8))
}.value.toString()
return rndValue to csValue
}
private fun String.matchGsonHtmlSafe(): String =
replace("\\/", "/").replace("=", "\\u003d")
companion object {
/** Convenience factory that pulls the device identifier the way [MfaisaLoginFlow] does. */
fun forContext(context: android.content.Context): MfaisaTransferClient {
val id = Settings.Secure.getString(context.applicationContext.contentResolver, Settings.Secure.ANDROID_ID)
?: "0000000000000000"
// suppress "unused" — kept for symmetry with MfaisaLoginFlow if we later read Build.MANUFACTURER.
@Suppress("UNUSED_VARIABLE") val mfg = Build.MANUFACTURER
return MfaisaTransferClient(id)
}
}
}
@@ -10,7 +10,7 @@ import java.text.SimpleDateFormat
import java.util.Locale
import java.util.concurrent.TimeUnit
private val SKIP_TYPES = setOf("Switch Profile")
private val SKIP_TYPES = setOf("Switch Profile", "Log in")
private const val MIB_WV_URL = "https://faisamobilex-wv.mib.com.mv"
class MibActivityHistoryClient {
@@ -117,7 +117,7 @@ class MibActivityHistoryClient {
session: MibSession,
loginId: String,
minCount: Int = 5,
pageSize: Int = 30
pageSize: Int = 100
): FetchResult {
val accumulated = mutableListOf<AppNotification>()
var start = 1
@@ -7,10 +7,18 @@ import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
data class MibCardActionResult(
val success: Boolean,
val message: String,
val currentStatusCode: String
)
class MibCardsClient {
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val USER_AGENT = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
private val client = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
@@ -20,7 +28,7 @@ class MibCardsClient {
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597"
fun fetchCards(session: MibSession, loginTag: String): List<MibCard> {
fun fetchCards(session: MibSession, loginTag: String, profileId: String = ""): List<MibCard> {
val body = FormBody.Builder()
.add("name", "")
.add("start", "1")
@@ -32,7 +40,7 @@ class MibCardsClient {
.url("$BASE_WV_URL/ajaxDebitCard/fetchCardInfos")
.post(body)
.header("Cookie", cookieHeader(session))
.header("User-Agent", "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36")
.header("User-Agent", USER_AGENT)
.header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*")
.header("Origin", BASE_WV_URL)
@@ -55,9 +63,42 @@ class MibCardsClient {
customerId = item.optString("customerId"),
phoneNumber = item.optString("phoneNumber"),
cardHolderName = item.optString("cardHolderName"),
loginTag = loginTag
loginTag = loginTag,
profileId = profileId
)
}
}
}
/** Freezes a MIB card. action = "freeze" or "unfreeze". */
fun setCardFreezeState(session: MibSession, cardId: String, action: String, comments: String): MibCardActionResult {
val body = FormBody.Builder()
.add("cardId", cardId)
.add("comments", comments)
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxDebitCard/$action")
.post(body)
.header("Cookie", cookieHeader(session))
.header("User-Agent", USER_AGENT)
.header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*")
.header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL//debitCards/manage?cardId=$cardId&dashurl=1")
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string()
?: return MibCardActionResult(false, "", "")
val json = try { JSONObject(bodyStr) } catch (_: Exception) {
return MibCardActionResult(false, "", "")
}
MibCardActionResult(
success = json.optBoolean("success"),
message = json.optString("reasonText"),
currentStatusCode = json.optString("currentStatusCode")
)
}
}
}
@@ -73,7 +73,7 @@ class MibHistoryClient {
id = item.optString("trxNumber"),
date = item.optString("trxDate"),
description = item.optString("descr1").trim(),
amount = item.optString("baseAmount", "0").toDoubleOrNull() ?: 0.0,
amount = item.optString("foreignAmount", "0").toDoubleOrNull() ?: 0.0,
currency = item.optString("curCodeDesc"),
counterpartyName = item.optString("benefName").takeIf {
it.isNotBlank() && it != "null"
@@ -42,7 +42,8 @@ data class MibTransferResult(
data class MibIpsAccountInfo(
val accountName: String,
val accountNumber: String,
val bankId: String
val bankId: String,
val currency: String = "" // "MVR", "USD", or "" if unknown
)
@@ -55,7 +56,8 @@ data class MibCard(
val customerId: String,
val phoneNumber: String,
val cardHolderName: String,
val loginTag: String
val loginTag: String,
val profileId: String = ""
)
data class MibFinanceDeal(
@@ -130,7 +130,10 @@ class MibTransferClient {
MibIpsAccountInfo(
accountName = json.optString("accountName").trim(),
accountNumber = accountNumber,
bankId = json.optString("bankBic")
bankId = json.optString("bankBic"),
// MIB IPS only returns success for MVR cross-bank accounts;
// USD cross-bank accounts fail this lookup entirely.
currency = "MVR"
)
}
}
@@ -156,10 +159,18 @@ class MibTransferClient {
// accountName may be at root or inside a "data" object
val name = json.optString("accountName").takeIf { it.isNotBlank() }
?: json.optJSONObject("data")?.optString("accountName") ?: ""
val currencyCode = json.optString("currencyCode").takeIf { it.isNotBlank() }
?: json.optJSONObject("data")?.optString("currencyCode") ?: ""
val currency = when (currencyCode) {
"840" -> "USD"
"462" -> "MVR"
else -> ""
}
MibIpsAccountInfo(
accountName = name.trim(),
accountNumber = accountNumber,
bankId = "MADVMVMV" // MIB
bankId = "MADVMVMV", // MIB
currency = currency
)
}
}
@@ -0,0 +1,174 @@
package sh.sar.basedbank.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.IBinder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlNotificationsClient
import sh.sar.basedbank.api.mib.MibActivityHistoryClient
import sh.sar.basedbank.ui.home.AppNotification
import sh.sar.basedbank.util.NotificationsCache
import java.util.concurrent.atomic.AtomicInteger
class NotificationPollingService : Service() {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val app get() = application as BasedBankApp
private val notifIdCounter = AtomicInteger(2000)
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createChannels()
startForeground(SERVICE_NOTIF_ID, buildServiceNotification())
startPolling()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
private fun startPolling() {
scope.launch {
while (isActive) {
runCatching { poll() }
delay(POLL_INTERVAL_MS)
}
}
}
private suspend fun poll() {
pollBml()
pollMib()
}
private suspend fun pollBml() {
val sessions = app.bmlSessions.toMap()
if (sessions.isEmpty()) return
val client = BmlNotificationsClient()
sessions.forEach { (loginId, session) ->
val result = try { client.fetchNotifications(session, loginId, page = 1) }
catch (_: Exception) { return@forEach }
if (result.items.isEmpty()) return@forEach
val cached = NotificationsCache.loadBml(this@NotificationPollingService, loginId)
if (cached.isEmpty()) {
NotificationsCache.saveBml(this@NotificationPollingService, loginId, result.items)
return@forEach
}
val cachedIds = cached.map { it.id }.toSet()
val newItems = result.items.filter { it.id !in cachedIds }
if (newItems.isNotEmpty()) {
NotificationsCache.saveBml(this@NotificationPollingService, loginId, result.items)
val channelId = ensureLoginChannel("BML", loginId)
newItems.forEach { postBankNotification(it, channelId) }
}
}
}
private suspend fun pollMib() {
val sessions = app.mibSessions.toMap()
if (sessions.isEmpty()) return
val client = MibActivityHistoryClient()
sessions.forEach { (loginId, session) ->
val result = try { client.fetchActivity(session, loginId, 1, 100) }
catch (_: Exception) { return@forEach }
val readIds = NotificationsCache.getMibReadIds(this@NotificationPollingService)
val cached = NotificationsCache.loadMib(this@NotificationPollingService, loginId, readIds)
if (cached.isEmpty()) {
NotificationsCache.saveMib(this@NotificationPollingService, loginId, result.items)
return@forEach
}
val cachedIds = cached.map { it.id }.toSet()
val newItems = result.items.filter { it.id !in cachedIds }
if (newItems.isNotEmpty()) {
val all = (cached + newItems).sortedByDescending { it.timestampMs }
NotificationsCache.saveMib(this@NotificationPollingService, loginId, all)
val channelId = ensureLoginChannel("MIB", loginId)
newItems.forEach { postBankNotification(it, channelId) }
}
}
}
private fun ensureLoginChannel(bank: String, loginId: String): String {
val channelId = "bank_${bank.lowercase()}_$loginId"
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
if (nm.getNotificationChannel(channelId) == null) {
val profileName = when (bank) {
"BML" -> app.bmlProfilesMap[loginId]?.firstOrNull()?.name
"MIB" -> app.mibProfilesMap[loginId]?.firstOrNull()?.name
else -> null
} ?: loginId
nm.createNotificationChannel(
NotificationChannel(channelId, "$bank · $profileName", NotificationManager.IMPORTANCE_DEFAULT)
)
}
return channelId
}
private fun postBankNotification(notif: AppNotification, channelId: String) {
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
val pi = PendingIntent.getActivity(
this, 0,
packageManager.getLaunchIntentForPackage(packageName),
PendingIntent.FLAG_IMMUTABLE
)
val n = Notification.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_bell)
.setContentTitle(notif.title)
.setContentText(notif.message)
.setContentIntent(pi)
.setAutoCancel(true)
.build()
nm.notify(notifIdCounter.getAndIncrement(), n)
}
private fun buildServiceNotification(): Notification {
val pi = PendingIntent.getActivity(
this, 0,
packageManager.getLaunchIntentForPackage(packageName),
PendingIntent.FLAG_IMMUTABLE
)
return Notification.Builder(this, CHANNEL_SERVICE)
.setSmallIcon(R.drawable.ic_bell)
.setContentTitle(getString(R.string.notif_service_title))
.setContentText(getString(R.string.notif_service_desc))
.setContentIntent(pi)
.setOngoing(true)
.build()
}
private fun createChannels() {
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(
NotificationChannel(
CHANNEL_SERVICE,
getString(R.string.notif_channel_service),
NotificationManager.IMPORTANCE_MIN
).apply { setShowBadge(false) }
)
}
companion object {
private const val POLL_INTERVAL_MS = 30_000L
private const val SERVICE_NOTIF_ID = 1001
const val CHANNEL_SERVICE = "notif_polling_service"
}
}
@@ -74,9 +74,18 @@ class AccountHistoryAdapter(
}
fun setPendingTransactions(transactions: List<BankTransaction>) {
setLeadingSections(listOf("Pending" to transactions))
}
/**
* Sets one or more labeled sections that render above the main statement list
* (e.g. card "Outstanding" + "Unbilled"). Empty sections are skipped.
*/
fun setLeadingSections(sections: List<Pair<String, List<BankTransaction>>>) {
pendingItems.clear()
if (transactions.isNotEmpty()) {
pendingItems.add(Item.DateHeader("Pending"))
for ((label, transactions) in sections) {
if (transactions.isEmpty()) continue
pendingItems.add(Item.DateHeader(label))
for (trx in transactions) pendingItems.add(Item.Trx(trx, showDate = true))
}
notifyDataSetChanged()
@@ -84,9 +84,10 @@ class AccountHistoryFragment : Fragment() {
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
// Show default account toggle only for non-card accounts
// Show default account toggle only for non-card, non-M-Faisa accounts.
// M-Faisa pockets (including PayPal) cannot be set as the default transfer/QR account.
val isCard = AccountListParser.from(account)?.isCard ?: false
if (!isCard) {
if (!isCard && account.bank != "MFAISA") {
val store = CredentialStore(requireContext())
adapter.showDefaultToggle = true
adapter.isDefaultAccount = store.getDefaultAccountNumber() == account.accountNumber
@@ -228,6 +229,13 @@ class AccountHistoryFragment : Fragment() {
}
(activity as? HomeActivity)?.hideConnectivityBanner()
fetcher.takeCardPendingSections()?.let { (outstanding, unbilled) ->
adapter.setLeadingSections(listOf(
"Outstanding" to outstanding,
"Unbilled" to unbilled
))
}
if (transactions.isNotEmpty()) {
val existingIds = allTransactions.map { it.id }.toHashSet()
val newOnes = transactions.filter { it.id !in existingIds }
@@ -75,10 +75,12 @@ class AccountsAdapter(
"BML" -> "Bank of Maldives"
"FAHIPAY" -> "Fahipay"
"MIB" -> "Maldives Islamic Bank"
"MFAISA" -> if (account.profileType == "MFAISA_PAYPAL") "PayPal · M-Faisa" else "M-Faisa"
else -> account.bank
}
val profileLabel = when (account.bank) {
"MIB" -> account.productCode.ifBlank { account.profileName }
"MFAISA" -> "" // bank-level grouping is already specific (M-Faisa / PayPal · M-Faisa)
else -> account.profileName
}
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
@@ -144,6 +146,7 @@ class AccountsAdapter(
"BML" -> R.drawable.bml_logo_vector
"FAHIPAY" -> R.drawable.fahipay_logo
"MIB" -> R.drawable.mib_logo
"MFAISA" -> R.drawable.ooredoo_logo
else -> null
}
if (staticLogo != null) binding.ivBankLogo.setImageResource(staticLogo)
@@ -89,6 +89,52 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
categories = cats.filter { it.id != "BML" }
if (selectedDest?.isBml == false) setupCategoryDropdown()
}
applyPrefillArgs()
}
private fun applyPrefillArgs() {
val args = arguments ?: return
val bmlProfileId = args.getString(ARG_BML_PROFILE_ID)
val accountNumber = args.getString(ARG_ACCOUNT_NUMBER)
val recipientName = args.getString(ARG_RECIPIENT_NAME)
val currency = args.getString(ARG_CURRENCY)
if (bmlProfileId != null) {
val match = destinations.firstOrNull { it.isBml && it.bmlLoginId == bmlProfileId }
if (match != null) {
selectedDest = match
binding.actvDestination.setText(match.label, false)
updateMibOnlyVisibility()
}
}
if (accountNumber != null) {
binding.etAccount.setText(accountNumber)
}
// Skip lookup only when we have a MIB-verified name+currency from the caller.
if (selectedDest != null && accountNumber != null &&
!recipientName.isNullOrBlank() && !currency.isNullOrBlank()
) {
val bankBic = when {
accountNumber.matches(Regex("^9\\d{16}$")) -> "MADVMVMV"
accountNumber.matches(Regex("^7\\d{12}$")) -> "MALBMVMV"
else -> ""
}
val trnType = if (accountNumber.matches(Regex("^9\\d{16}$"))) "DOT" else "IAT"
val validation = BmlAccountValidation(
trnType = trnType,
validationType = "prefilled",
account = accountNumber,
originalInput = accountNumber,
name = recipientName,
alias = null,
currency = currency,
agnt = bankBic.takeIf { it.isNotBlank() }
)
showLookupResult(validation, accountNumber)
}
}
private fun buildDestinations(): List<DestinationOption> {
@@ -517,5 +563,24 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
companion object {
// BML's internal UUID for MIB bank — used as the "swift" field when saving DOT contacts
private const val MIB_SWIFT_ON_BML = "F4E79935-3E73-E611-80DD-00155D020F0A"
private const val ARG_BML_PROFILE_ID = "bml_profile_id"
private const val ARG_ACCOUNT_NUMBER = "account_number"
private const val ARG_RECIPIENT_NAME = "recipient_name"
private const val ARG_CURRENCY = "currency"
fun newInstance(
bmlProfileId: String? = null,
accountNumber: String? = null,
recipientName: String? = null,
currency: String? = null
) = AddContactSheetFragment().apply {
arguments = Bundle().apply {
if (bmlProfileId != null) putString(ARG_BML_PROFILE_ID, bmlProfileId)
if (accountNumber != null) putString(ARG_ACCOUNT_NUMBER, accountNumber)
if (recipientName != null) putString(ARG_RECIPIENT_NAME, recipientName)
if (currency != null) putString(ARG_CURRENCY, currency)
}
}
}
}
@@ -5,7 +5,6 @@ import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.os.Bundle
import android.util.Base64
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
@@ -128,7 +127,6 @@ class BmlQrPayFragment : Fragment() {
}
private fun lookupMerchant(qrUrl: String) {
val base64Url = Base64.encodeToString(qrUrl.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
val app = requireActivity().application as BasedBankApp
val session = app.anyBmlSession() ?: run {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
@@ -141,7 +139,7 @@ class BmlQrPayFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch {
val info = withContext(Dispatchers.IO) {
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
try { BmlQrPayClient().lookupPayRequest(session, qrUrl) }
catch (_: Exception) { null }
}
if (_binding == null) return@launch
@@ -151,7 +151,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
(activity as? HomeActivity)?.triggerRefresh()
(activity as? HomeActivity)?.loadAllContacts()
}
private fun attachMediator(pages: List<TabDef>) {
@@ -172,6 +172,10 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
bundle.putString(KEY_SUBTITLE, "BML QR Merchant")
bundle.putString(KEY_COLOR, "#0066A1")
}
accountNumber.startsWith("mfaisaqr:") -> {
bundle.putString(KEY_SUBTITLE, "M-Faisa QR Merchant")
bundle.putString(KEY_COLOR, "#ED1C24")
}
account != null -> {
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
bundle.putString(KEY_COLOR, "#FE860E")
@@ -195,6 +199,17 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val hide = viewModel.hideAmounts.value ?: false
val items = mutableListOf<ContactPickerAdapter.PickerItem>()
val accounts = viewModel.accounts.value ?: emptyList()
val contacts = viewModel.contacts.value ?: emptyList()
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
val fromCurrency = fromAccount?.currencyName ?: ""
val fromLoginTag = fromAccount?.loginTag ?: ""
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT" || fromAccount?.profileType == "BML_DEBIT"
val fromIsMfaisa = fromAccount?.bank == "MFAISA"
// TODO: when M-Faisa-supported contacts are wired up, swap this for a per-row check
// (e.g. is the recipient also an M-Faisa wallet) instead of disabling everything.
val mfaisaInactive = if (fromIsMfaisa) "Unsupported recipient from M-Faisa" else null
if (tabTag == RECENTS_TAG) {
val recents = RecentsCache.load(requireContext())
val filtered = if (search.isBlank()) recents else recents.filter {
@@ -208,19 +223,15 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
subtitle = r.subtitle,
colorHex = r.colorHex,
isSameAsFrom = r.accountNumber == fromAccountNumber,
imageHash = r.imageHash
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
))
}
return items
}
val accounts = viewModel.accounts.value ?: emptyList()
val contacts = viewModel.contacts.value ?: emptyList()
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
val fromCurrency = fromAccount?.currencyName ?: ""
val fromLoginTag = fromAccount?.loginTag ?: ""
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT" || fromAccount?.profileType == "BML_DEBIT"
if (tabTag == MY_ACCOUNTS_TAG) {
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" }
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
@@ -251,9 +262,12 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
colorHex = "#FE860E",
isSameAsFrom = isSame,
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
inactiveReason = if (isSame) null
else if (fromIsCard && acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
else currencyMismatchReason(fromCurrency, acc.currencyName),
inactiveReason = when {
isSame -> null
mfaisaInactive != null && acc.bank != "MFAISA" -> mfaisaInactive
fromIsCard && acc.loginTag != fromLoginTag -> "Cards can only be used within the same BML account"
else -> currencyMismatchReason(fromCurrency, acc.currencyName)
},
balance = balance,
bankLogoRes = logoRes
))
@@ -283,10 +297,13 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
colorHex = "#FE860E",
isSameAsFrom = isSame,
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
inactiveReason = if (isSame) null
else if (!isActive) acc.statusDesc
else if (acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
else currencyMismatchReason(fromCurrency, acc.currencyName),
inactiveReason = when {
isSame -> null
mfaisaInactive != null -> mfaisaInactive
!isActive -> acc.statusDesc
acc.loginTag != fromLoginTag -> "Cards can only be used within the same BML account"
else -> currencyMismatchReason(fromCurrency, acc.currencyName)
},
balance = balance,
bankLogoRes = logoRes
))
@@ -311,7 +328,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
colorHex = contact.bankColor,
isSameAsFrom = contact.benefAccount == fromAccountNumber,
imageHash = contact.customerImgHash,
inactiveReason = currencyMismatchReason(fromCurrency, contact.transferCyDesc)
inactiveReason = mfaisaInactive ?: currencyMismatchReason(fromCurrency, contact.transferCyDesc)
))
}
return items
@@ -26,6 +26,7 @@ import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.NfcPaymentUtil
import sh.sar.basedbank.util.PaymvQrParser
import kotlin.math.abs
import sh.sar.basedbank.databinding.FragmentDashboardBinding
@@ -42,10 +43,10 @@ class DashboardFragment : Fragment() {
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
if (bmlTarget != null) {
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
)
} else {
val qr = PaymvQrParser.parse(raw)
@@ -115,12 +116,12 @@ class DashboardFragment : Fragment() {
val credStore = CredentialStore(requireContext())
val hidden = credStore.getHiddenDashboardCardNumbers()
val mibItems = (viewModel.mibCards.value ?: emptyList())
.filter { !hidden.contains(it.maskedCardNumber) }
.filter { CardsFragment.isMibCardActive(it.cardStatus) && !hidden.contains(it.maskedCardNumber) }
.map { CardItem.Mib(it) }
val bmlItems = (viewModel.accounts.value ?: emptyList())
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
.map { CardItem.Bml(it) }
val all = mibItems + bmlItems
val all = bmlItems + mibItems
val defaultNum = credStore.getDefaultCardAccountNumber()
val ordered = if (defaultNum != null) {
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
@@ -426,11 +427,13 @@ class DashboardFragment : Fragment() {
if (isMib) {
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
} else {
val accountNumber = (item as CardItem.Bml).account.accountNumber
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_pay_with_card,
CardsFragment.newInstanceWithAutoTapMode(accountNumber)
)
NfcPaymentUtil.checkAndProceed(requireContext()) {
val accountNumber = (item as CardItem.Bml).account.accountNumber
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_pay_with_card,
CardsFragment.newInstanceWithAutoTapMode(accountNumber)
)
}
}
}
}
@@ -202,9 +202,9 @@ class HomeActivity : AppCompatActivity() {
}
// Load data
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty() || app.mfaisaAccounts.isNotEmpty()) {
// Came from fresh manual login — accounts ready, rest fetched in background
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts + app.mfaisaAccounts
viewModel.accounts.value = merged.filterVisibleAccounts()
if (app.mibAccounts.isNotEmpty()) AccountCache.save(this, app.mibAccounts)
if (app.bmlAccounts.isNotEmpty()) {
@@ -215,6 +215,10 @@ class HomeActivity : AppCompatActivity() {
val byLoginId = app.fahipayAccounts.groupBy { it.loginTag.removePrefix("fahipay_") }
byLoginId.forEach { (loginId, accs) -> AccountCache.saveFahipay(this, loginId, accs) }
}
if (app.mfaisaAccounts.isNotEmpty()) {
val byLoginId = app.mfaisaAccounts.groupBy { it.loginTag.removePrefix("mfaisa_") }
byLoginId.forEach { (loginId, accs) -> AccountCache.saveMfaisa(this, loginId, accs) }
}
val cachedCards = CardsCache.load(this)
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
@@ -238,7 +242,8 @@ class HomeActivity : AppCompatActivity() {
val cachedMib = AccountCache.load(this)
val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds())
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
val merged = cachedMib + cachedBml + cachedFahipay
val cachedMfaisa = AccountCache.loadMfaisa(this, store.getMfaisaLoginIds())
val merged = cachedMib + cachedBml + cachedFahipay + cachedMfaisa
if (merged.isNotEmpty()) viewModel.accounts.value = merged
val cachedCards = CardsCache.load(this)
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
@@ -523,9 +528,9 @@ fun applyNavLabelVisibility() {
private fun routeSharedQrText(text: String) {
val store = CredentialStore(this)
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text)
if (text.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: text, store.getDefaultCardAccountNumber()))
val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text)
if (bmlTarget != null) {
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber()))
return
}
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
@@ -695,17 +700,20 @@ fun applyNavLabelVisibility() {
val mibLoginIds = store.getMibLoginIds()
val bmlLoginIds = store.getBmlLoginIds()
val fahipayLoginIds = store.getFahipayLoginIds()
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) {
val mfaisaLoginIds = store.getMfaisaLoginIds()
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty() && mfaisaLoginIds.isEmpty()) {
startActivity(Intent(this, LoginActivity::class.java))
finish()
return
}
// Immediately drop accounts for logged-out logins from the displayed list
val current = viewModel.accounts.value ?: emptyList()
val mfaisaLoginIdsForFilter = store.getMfaisaLoginIds()
viewModel.accounts.value = current.filter { acc ->
if (acc.bank == "MIB") return@filter acc.loginTag.removePrefix("mib_") in mibLoginIds
if (acc.bank == "BML") return@filter acc.loginTag.removePrefix("bml_") in bmlLoginIds
if (acc.bank == "FAHIPAY") return@filter acc.loginTag.removePrefix("fahipay_") in fahipayLoginIds
if (acc.bank == "MFAISA") return@filter acc.loginTag.removePrefix("mfaisa_") in mfaisaLoginIdsForFilter
true
}
autoRefresh(store)
@@ -724,7 +732,8 @@ fun applyNavLabelVisibility() {
val mibLoginIds = store.getMibLoginIds()
val bmlLoginIds = store.getBmlLoginIds()
val fahipayLoginIds = store.getFahipayLoginIds()
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) return
val mfaisaLoginIds = store.getMfaisaLoginIds()
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty() && mfaisaLoginIds.isEmpty()) return
binding.refreshIndicator.visibility = View.VISIBLE
hideConnectivityBanner()
@@ -898,17 +907,58 @@ fun applyNavLabelVisibility() {
}
}
// One async job per M-Faisa login, all run in parallel.
// M-Faisa has no session refresh — sessions expire after ~240s — so we re-login each refresh.
val mfaisaJobs = mfaisaLoginIds.mapNotNull { loginId ->
val creds = store.loadMfaisaCredentials(loginId) ?: return@mapNotNull null
loginId to async(Dispatchers.IO) {
val loginTag = "mfaisa_$loginId"
val flow = sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow(this@HomeActivity)
try {
flow.fetchSubscriber(creds.msisdn)
val result = flow.doMobileLogin(creds.msisdn, creds.pin)
val accounts = sh.sar.basedbank.api.mfaisa.MfaisaAccountClient.buildAccounts(result, loginTag)
val app = application as BasedBankApp
app.mfaisaSessions[loginId] = result.session
store.saveMfaisaUserProfile(
loginId,
CredentialStore.MfaisaUserProfile(
name = result.profile.name,
email = result.profile.email,
mdnId = result.profile.mdnId,
subscriberId = result.profile.subscriberId,
walletId = result.profile.walletId,
roleId = result.profile.roleId,
offerId = result.profile.offerId
)
)
AccountCache.saveMfaisa(this@HomeActivity, loginId, accounts)
accounts
} catch (e: java.io.IOException) {
refreshErrors.add("NO_INTERNET")
AccountCache.loadMfaisa(this@HomeActivity, loginId)
} catch (e: BankServerException) {
refreshErrors.add("SERVER:${e.bankName}")
AccountCache.loadMfaisa(this@HomeActivity, loginId)
} catch (_: Exception) {
AccountCache.loadMfaisa(this@HomeActivity, loginId)
}
}
}
val mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() }
val mibAccounts = mibResults.flatMap { it.second }
val bmlAccounts = bmlJobs.flatMap { (_, job) -> job.await() }
val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() }
val mfaisaAccounts = mfaisaJobs.flatMap { (_, job) -> job.await() }
val app = application as BasedBankApp
app.mibAccounts = mibAccounts
AccountCache.save(this@HomeActivity, mibAccounts)
app.bmlAccounts = bmlAccounts
app.fahipayAccounts = fahipayAccounts
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts())
app.mfaisaAccounts = mfaisaAccounts
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts + mfaisaAccounts).filterVisibleAccounts())
binding.refreshIndicator.visibility = View.GONE
val noInternet = refreshErrors.any { it == "NO_INTERNET" }
@@ -955,6 +1005,11 @@ fun applyNavLabelVisibility() {
val hidden = store.getHiddenBmlProfileIds(loginId)
hidden.isEmpty() || acc.profileId !in hidden
}
"MFAISA" -> {
val loginId = acc.loginTag.removePrefix("mfaisa_")
val hidden = store.getHiddenMfaisaPocketIds(loginId)
hidden.isEmpty() || acc.accountNumber !in hidden
}
else -> true
}
}
@@ -1130,14 +1185,14 @@ fun applyNavLabelVisibility() {
val fresh = withContext(Dispatchers.IO) {
val sess = app.bmlSessionFor(src) ?: return@withContext null
try {
val accounts = BmlAccountClient().fetchAccounts(sess, src.loginTag)
val accounts = BmlAccountClient().fetchAccounts(sess, src.loginTag, src.profileName, src.profileId)
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag }
val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag || it.profileId != src.profileId }
app.bmlAccounts = otherBml + accounts
accounts
} catch (_: Exception) { null }
} ?: return@launch
val otherAccounts = current.filter { it.bank != "BML" || it.loginTag != src.loginTag }
val otherAccounts = current.filter { it.bank != "BML" || it.loginTag != src.loginTag || it.profileId != src.profileId }
viewModel.accounts.postValue(otherAccounts + fresh)
} else {
val loginId = src.loginTag.removePrefix("mib_")
@@ -1220,7 +1275,7 @@ fun applyNavLabelVisibility() {
for (profile in profiles) {
try {
flow.switchProfile(session, profile)
for (card in client.fetchCards(session, "mib_$loginId")) {
for (card in client.fetchCards(session, "mib_$loginId", profile.profileId)) {
if (seen.add(card.cardId)) result += card
}
} catch (_: Exception) { }
@@ -13,10 +13,12 @@ import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
@@ -62,6 +64,23 @@ private fun toGroupedList(notifications: List<AppNotification>): List<NotifListI
return result
}
private class NotifDiff(
private val old: List<NotifListItem>,
private val new: List<NotifListItem>
) : DiffUtil.Callback() {
override fun getOldListSize() = old.size
override fun getNewListSize() = new.size
override fun areItemsTheSame(oldPos: Int, newPos: Int): Boolean {
val o = old[oldPos]; val n = new[newPos]
return when {
o is NotifListItem.Header && n is NotifListItem.Header -> o.label == n.label
o is NotifListItem.Entry && n is NotifListItem.Entry -> o.n.id == n.n.id
else -> false
}
}
override fun areContentsTheSame(oldPos: Int, newPos: Int) = old[oldPos] == new[newPos]
}
class NotificationsSheetFragment : BottomSheetDialogFragment() {
var onUnreadCountChanged: ((hasUnread: Boolean) -> Unit)? = null
@@ -148,11 +167,16 @@ class NotificationsSheetFragment : BottomSheetDialogFragment() {
}
}
private fun setSpinner(show: Boolean) {
tabAdapters.forEach { it?.showLoadingSpinner = show }
}
private fun refreshFromNetwork() {
val bmlSessions = app.bmlSessions.toMap()
val mibSessions = app.mibSessions.toMap()
lifecycleScope.launch {
setSpinner(true)
val bmlClient = BmlNotificationsClient()
bmlSessions.forEach { (loginId, session) ->
val result = withContext(Dispatchers.IO) {
@@ -172,22 +196,32 @@ class NotificationsSheetFragment : BottomSheetDialogFragment() {
val mibClient = MibActivityHistoryClient()
mibSessions.forEach { (loginId, session) ->
val cachedIds = allNotifications
.filter { it.bank == "MIB" && it.loginId == loginId }
.map { it.id }.toSet()
val result = withContext(Dispatchers.IO) {
mibClient.fetchUntilEnough(session, loginId)
mibClient.fetchActivity(session, loginId, 1, 100)
}
if (result.items.isNotEmpty() && isAdded) {
if (isAdded) {
val readIds = NotificationsCache.getMibReadIds(requireContext())
val resolved = result.items.map { it.copy(isRead = it.id in readIds) }
allNotifications.removeAll { it.bank == "MIB" && it.loginId == loginId }
allNotifications.addAll(resolved)
allNotifications.sortByDescending { it.timestampMs }
val hasOverlap = cachedIds.isNotEmpty() && result.items.any { it.id in cachedIds }
val newItems = result.items
.filter { it.id !in cachedIds }
.map { it.copy(isRead = it.id in readIds) }
if (newItems.isNotEmpty()) {
allNotifications.addAll(newItems)
allNotifications.sortByDescending { it.timestampMs }
val allForLogin = allNotifications.filter { it.bank == "MIB" && it.loginId == loginId }
NotificationsCache.saveMib(requireContext(), loginId, allForLogin)
refreshAdapters()
broadcastUnread()
}
mibNextStart[loginId] = result.nextStart
mibDone[loginId] = result.nextStart > result.totalCount
NotificationsCache.saveMib(requireContext(), loginId, result.items)
refreshAdapters()
broadcastUnread()
mibDone[loginId] = hasOverlap || result.nextStart > result.totalCount
}
}
if (isAdded) setSpinner(false)
}
}
@@ -200,6 +234,7 @@ class NotificationsSheetFragment : BottomSheetDialogFragment() {
if (!anyLeft) return
isLoadingMore = true
setSpinner(true)
lifecycleScope.launch {
val bmlClient = BmlNotificationsClient()
bmlSessions.forEach { (loginId, session) ->
@@ -221,24 +256,36 @@ class NotificationsSheetFragment : BottomSheetDialogFragment() {
val mibClient = MibActivityHistoryClient()
mibSessions.forEach { (loginId, session) ->
if (mibDone[loginId] == true) return@forEach
val start = mibNextStart[loginId] ?: 1
val result = withContext(Dispatchers.IO) {
mibClient.fetchActivity(session, loginId, start, start + 29)
}
if (result.items.isNotEmpty() && isAdded) {
while (mibDone[loginId] != true && isAdded) {
val start = mibNextStart[loginId] ?: 101
val cachedIds = allNotifications
.filter { it.bank == "MIB" && it.loginId == loginId }
.map { it.id }.toSet()
val result = withContext(Dispatchers.IO) {
mibClient.fetchActivity(session, loginId, start, start + 99)
}
if (result.rawCount == 0) break
val readIds = NotificationsCache.getMibReadIds(requireContext())
val resolved = result.items.map { it.copy(isRead = it.id in readIds) }
allNotifications.addAll(resolved.filter { n -> allNotifications.none { it.id == n.id } })
allNotifications.sortByDescending { it.timestampMs }
val newItems = result.items
.filter { it.id !in cachedIds }
.map { it.copy(isRead = it.id in readIds) }
if (newItems.isNotEmpty()) {
allNotifications.addAll(newItems)
allNotifications.sortByDescending { it.timestampMs }
val allForLogin = allNotifications.filter { it.bank == "MIB" && it.loginId == loginId }
NotificationsCache.saveMib(requireContext(), loginId, allForLogin)
}
mibNextStart[loginId] = result.nextStart
mibDone[loginId] = result.nextStart > result.totalCount
val allForLogin = allNotifications.filter { it.bank == "MIB" && it.loginId == loginId }
NotificationsCache.saveMib(requireContext(), loginId, allForLogin)
if (newItems.isNotEmpty()) break
}
}
isLoadingMore = false
if (isAdded) refreshAdapters()
if (isAdded) {
setSpinner(false)
refreshAdapters()
}
}
}
@@ -342,16 +389,35 @@ class NotificationsSheetFragment : BottomSheetDialogFragment() {
private val displayItems = mutableListOf<NotifListItem>()
var showLoadingSpinner: Boolean = false
set(value) {
if (field == value) return
field = value
if (displayItems.isEmpty()) {
notifyItemChanged(0)
} else if (value) {
notifyItemInserted(displayItems.size)
} else {
notifyItemRemoved(displayItems.size)
}
}
fun update(filtered: List<AppNotification>) {
val newItems = toGroupedList(filtered)
val diff = DiffUtil.calculateDiff(NotifDiff(displayItems.toList(), newItems))
displayItems.clear()
displayItems.addAll(toGroupedList(filtered))
notifyDataSetChanged()
displayItems.addAll(newItems)
diff.dispatchUpdatesTo(this)
}
override fun getItemCount() = if (displayItems.isEmpty()) 1 else displayItems.size
override fun getItemCount(): Int {
if (displayItems.isEmpty()) return 1
return displayItems.size + if (showLoadingSpinner) 1 else 0
}
override fun getItemViewType(position: Int): Int {
if (displayItems.isEmpty()) return 2 // empty
if (displayItems.isEmpty()) return if (showLoadingSpinner) 3 else 2
if (showLoadingSpinner && position == displayItems.size) return 3
return when (displayItems[position]) {
is NotifListItem.Header -> 0
is NotifListItem.Entry -> 1
@@ -362,6 +428,7 @@ class NotificationsSheetFragment : BottomSheetDialogFragment() {
when (viewType) {
0 -> HeaderVH(buildHeaderView(parent.context))
1 -> ItemVH(buildRowView(parent.context))
3 -> SpinnerVH(buildSpinnerView(parent.context))
else -> EmptyVH(buildEmptyView(parent.context))
}
@@ -422,6 +489,28 @@ class NotificationsSheetFragment : BottomSheetDialogFragment() {
}
}
// ── Loading spinner ───────────────────────────────────────────────────────
inner class SpinnerVH(v: View) : RecyclerView.ViewHolder(v)
private fun buildSpinnerView(ctx: android.content.Context): View {
val dp = ctx.resources.displayMetrics.density
val pad = (16 * dp).toInt()
val size = (28 * dp).toInt()
return LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
setPadding(pad, pad, pad, pad)
addView(ProgressBar(ctx).apply {
layoutParams = LinearLayout.LayoutParams(size, size)
})
}
}
// ── Notification row ──────────────────────────────────────────────────────
inner class ItemVH(v: View) : RecyclerView.ViewHolder(v) {
@@ -83,6 +83,7 @@ class PayMvQrFragment : Fragment() {
val eligible = accounts.filter {
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" &&
it.bank != "MIB" && // TODO: MIB does not support PayMV QR
it.bank != "MFAISA" && // TODO: M-Faisa PayMV QR not implemented yet
!(it.bank == "BML" && it.currencyName.contains("USD", ignoreCase = true)) // TODO: BML USD not supported by MMA
}
val adapter = QrAccountAdapter(requireContext(), eligible)
@@ -37,17 +37,25 @@ import com.google.android.material.button.MaterialButton
import com.google.android.material.color.MaterialColors
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
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.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.FragmentCardsBinding
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.Totp
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.NfcPaymentUtil
import sh.sar.basedbank.util.PaymvQrParser
import kotlin.math.abs
@@ -62,15 +70,17 @@ class CardsFragment : Fragment() {
private var cardWidth: Int = 0
private var pendingQrCardNumber: String? = null
private var isManageMode: Boolean = false
private var managedCardKey: String? = null
private var freezeInFlight: Boolean = false
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 cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
if (bmlTarget != null) {
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
)
} else {
val qr = PaymvQrParser.parse(raw)
@@ -145,17 +155,22 @@ class CardsFragment : Fragment() {
}
})
ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(0, 0, 0, (16 * resources.displayMetrics.density).toInt() + extraBottom)
ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
val navBarBottom = if (bottomNav?.visibility == View.VISIBLE) 0
else insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
v.setPadding(0, 0, 0, (16 * resources.displayMetrics.density).toInt() + navBarBottom)
insets
}
viewModel.mibCards.observe(viewLifecycleOwner) { rebuildCards() }
viewModel.accounts.observe(viewLifecycleOwner) { rebuildCards() }
viewModel.mibCards.observe(viewLifecycleOwner) {
rebuildCards()
rebindManagedCardIfNeeded()
}
viewModel.accounts.observe(viewLifecycleOwner) {
rebuildCards()
rebindManagedCardIfNeeded()
}
val cached = CardsCache.load(requireContext())
if (cached.isNotEmpty()) {
@@ -232,11 +247,13 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
return@setOnClickListener
}
val bmlItem = item as CardItem.Bml
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
showBiometricPromptForTap(bmlItem)
} else {
setTapMode(true, bmlItem)
NfcPaymentUtil.checkAndProceed(requireContext()) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
showBiometricPromptForTap(bmlItem)
} else {
setTapMode(true, bmlItem)
}
}
}
@@ -244,20 +261,161 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
binding.btnChangePin.setOnClickListener(wip)
binding.btnFreeze.setOnClickListener(wip)
binding.btnFreeze.setOnClickListener {
when (val item = cards.getOrNull(currentCardPosition)) {
is CardItem.Bml -> confirmBmlFreezeToggle(item)
is CardItem.Mib -> confirmMibFreezeToggle(item)
null -> {}
}
}
binding.btnBlock.setOnClickListener(wip)
}
private fun confirmBmlFreezeToggle(item: CardItem.Bml) {
if (freezeInFlight) return
val frozen = isBmlFrozen(item.account.statusDesc)
val titleRes = if (frozen) R.string.card_unfreeze_confirm_title else R.string.card_freeze_confirm_title
val messageRes = if (frozen) R.string.card_unfreeze_confirm_message else R.string.card_freeze_confirm_message
val confirmRes = if (frozen) R.string.card_action_unfreeze else R.string.card_action_freeze
MaterialAlertDialogBuilder(requireContext())
.setTitle(titleRes)
.setMessage(messageRes)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(confirmRes) { _, _ -> performBmlFreezeToggle(item, freeze = !frozen) }
.show()
}
private fun confirmMibFreezeToggle(item: CardItem.Mib) {
if (freezeInFlight) return
val frozen = isMibCardFrozen(item.card.cardStatus)
val titleRes = if (frozen) R.string.card_unfreeze_confirm_title else R.string.card_freeze_confirm_title
val messageRes = if (frozen) R.string.card_unfreeze_confirm_message else R.string.card_freeze_confirm_message
val confirmRes = if (frozen) R.string.card_action_unfreeze else R.string.card_action_freeze
val ctx = requireContext()
val dp = resources.displayMetrics.density
val inputLayout = TextInputLayout(ctx).apply {
hint = getString(R.string.card_freeze_comments_hint)
val pad = (16 * dp).toInt()
setPadding(pad, pad / 2, pad, 0)
}
val input = TextInputEditText(ctx).apply {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
maxLines = 3
}
inputLayout.addView(input)
MaterialAlertDialogBuilder(ctx)
.setTitle(titleRes)
.setMessage(messageRes)
.setView(inputLayout)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(confirmRes) { _, _ ->
val comments = input.text?.toString()?.trim().orEmpty()
performMibFreezeToggle(item, freeze = !frozen, comments = comments)
}
.show()
}
private fun performMibFreezeToggle(item: CardItem.Mib, freeze: Boolean, comments: String) {
val app = requireActivity().application as BasedBankApp
val action = if (freeze) "freeze" else "unfreeze"
val successRes = if (freeze) R.string.card_freeze_success else R.string.card_unfreeze_success
val loginId = item.card.loginTag.removePrefix("mib_")
val session = app.mibSessions[loginId]
if (session == null) {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val ownerProfile = profiles.firstOrNull { it.profileId == item.card.profileId }
?: profiles.firstOrNull { it.customerId == item.card.customerId }
freezeInFlight = true
binding.btnFreeze.isEnabled = false
(activity as? HomeActivity)?.setRefreshing(true)
viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching {
app.mibMutex.withLock {
if (ownerProfile != null) {
app.mibFlowFor(loginId).switchProfile(session, ownerProfile)
}
MibCardsClient().setCardFreezeState(session, item.card.cardId, action, comments)
}
}
}
freezeInFlight = false
if (!isAdded || _binding == null) {
(activity as? HomeActivity)?.setRefreshing(false)
return@launch
}
binding.btnFreeze.isEnabled = true
(activity as? HomeActivity)?.setRefreshing(false)
val response = result.getOrNull()
if (response?.success == true) {
Toast.makeText(requireContext(), successRes, Toast.LENGTH_SHORT).show()
(activity as? HomeActivity)?.triggerRefreshCards()
} else {
val msg = response?.message?.takeIf { it.isNotBlank() }
?: result.exceptionOrNull()?.message
?: getString(R.string.card_freeze_failed)
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
}
}
}
private fun performBmlFreezeToggle(item: CardItem.Bml, freeze: Boolean) {
val app = requireActivity().application as BasedBankApp
val action = if (freeze) "freeze" else "unfreeze"
val successRes = if (freeze) R.string.card_freeze_success else R.string.card_unfreeze_success
freezeInFlight = true
binding.btnFreeze.isEnabled = false
(activity as? HomeActivity)?.setRefreshing(true)
viewLifecycleOwner.lifecycleScope.launch {
val session = app.bmlSessionFor(item.account)
if (session == null) {
freezeInFlight = false
if (_binding != null) binding.btnFreeze.isEnabled = true
(activity as? HomeActivity)?.setRefreshing(false)
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return@launch
}
val result = withContext(Dispatchers.IO) {
runCatching { BmlCardClient().setCardFreezeState(session, item.account.internalId, action) }
}
freezeInFlight = false
if (!isAdded || _binding == null) {
(activity as? HomeActivity)?.setRefreshing(false)
return@launch
}
binding.btnFreeze.isEnabled = true
(activity as? HomeActivity)?.setRefreshing(false)
val response = result.getOrNull()
if (response?.success == true) {
Toast.makeText(requireContext(), successRes, Toast.LENGTH_SHORT).show()
(activity as? HomeActivity)?.refreshBalances(item.account)
} else {
val msg = response?.message?.takeIf { it.isNotBlank() }
?: result.exceptionOrNull()?.message
?: getString(R.string.card_freeze_failed)
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
}
}
}
private fun setManageMode(enabled: Boolean) {
isManageMode = enabled
if (!enabled) managedCardKey = null
requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card)
if (enabled) enterManageMode() else exitManageMode()
}
private fun enterManageMode() {
val item = cards.getOrNull(currentCardPosition) ?: return
private fun cardItemKey(item: CardItem): String = when (item) {
is CardItem.Bml -> "bml:${item.account.accountNumber}"
is CardItem.Mib -> "mib:${item.card.cardId}"
}
// Bind card data
private fun bindManageCardData(item: CardItem) {
val cv = binding.manageCardView
when (item) {
is CardItem.Mib -> {
@@ -267,7 +425,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
if (assetPath != null) loadCardImage(cv.ivCardImage, assetPath)
else cv.ivCardImage.setImageDrawable(null)
bindCardStatus(cv.tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
cv.root.alpha = 1f
cv.root.alpha = if (isMibCardActive(item.card.cardStatus)) 1f else 0.45f
}
is CardItem.Bml -> {
cv.tvCardOwner.text = item.account.accountBriefName
@@ -278,6 +436,37 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
cv.root.alpha = if (isActive) 1f else 0.45f
}
}
val isFrozen = when (item) {
is CardItem.Bml -> isBmlFrozen(item.account.statusDesc)
is CardItem.Mib -> isMibCardFrozen(item.card.cardStatus)
}
binding.btnFreeze.setText(if (isFrozen) R.string.card_action_unfreeze else R.string.card_action_freeze)
// MIB doesn't allow change PIN / block while a card is frozen; BML still does.
val mibFrozen = item is CardItem.Mib && isMibCardFrozen(item.card.cardStatus)
binding.btnChangePin.isEnabled = !mibFrozen
binding.btnBlock.isEnabled = !mibFrozen
}
private fun rebindManagedCardIfNeeded() {
if (!isManageMode) return
val key = managedCardKey ?: return
val newPos = cards.indexOfFirst { cardItemKey(it) == key }
if (newPos < 0) return
if (newPos != currentCardPosition) {
currentCardPosition = newPos
binding.rvCards.scrollToPosition(newPos)
}
bindManageCardData(cards[newPos])
}
private fun isBmlFrozen(statusDesc: String): Boolean =
statusDesc.equals("Block Plastic", ignoreCase = true)
private fun enterManageMode() {
val item = cards.getOrNull(currentCardPosition) ?: return
managedCardKey = cardItemKey(item)
bindManageCardData(item)
// Capture positions BEFORE layout changes (for enter animation + exit animation later)
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
@@ -330,6 +519,16 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
}
}
val isInactiveBml = item is CardItem.Bml && !item.account.statusDesc.equals("Active", ignoreCase = true)
binding.switchDefaultCard.isEnabled = !isInactiveBml
if (isInactiveBml) {
binding.switchHideFromDashboard.setOnCheckedChangeListener(null)
binding.switchHideFromDashboard.isChecked = true
binding.switchHideFromDashboard.isEnabled = false
} else {
binding.switchHideFromDashboard.isEnabled = true
}
// After layout pass, compute offsets, save carousel snapshot, and animate
binding.contentLayout.doOnNextLayout {
val mgr = binding.manageCardView.root
@@ -701,7 +900,11 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
val bmlItems = (viewModel.accounts.value ?: emptyList())
.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
.map { CardItem.Bml(it) }
val all: List<CardItem> = mibItems + bmlItems
val bmlActive = bmlItems.filter { it.account.statusDesc.equals("Active", ignoreCase = true) }
val bmlInactive = bmlItems.filter { !it.account.statusDesc.equals("Active", ignoreCase = true) }
val mibActive = mibItems.filter { isMibCardActive(it.card.cardStatus) }
val mibInactive = mibItems.filter { !isMibCardActive(it.card.cardStatus) }
val all: List<CardItem> = bmlActive + mibActive + bmlInactive + mibInactive
// Move default BML card to front
cards = if (defaultNum != null) {
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
@@ -742,11 +945,13 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
currentCardPosition = pos
binding.rvCards.scrollToPosition(pos)
}
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
showBiometricPromptForTap(targetCard)
} else {
setTapMode(true, targetCard)
NfcPaymentUtil.checkAndProceed(requireContext()) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
showBiometricPromptForTap(targetCard)
} else {
setTapMode(true, targetCard)
}
}
}
}
@@ -804,6 +1009,10 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
is CardItem.Mib -> item.card.cardTypeDesc
is CardItem.Bml -> item.account.accountTypeName
}
val isInactiveBml = item is CardItem.Bml && !item.account.statusDesc.equals("Active", ignoreCase = true)
val nfcAvailable = android.nfc.NfcAdapter.getDefaultAdapter(requireContext()) != null
binding.btnTapToPay.isEnabled = !isInactiveBml && nfcAvailable
binding.btnScanToPay.isEnabled = !isInactiveBml
}
fun onBackPressed(): Boolean {
@@ -828,7 +1037,11 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_pay_with_card)
requireActivity().title = getString(when {
isTapMode -> R.string.card_pay_nfc
isManageMode -> R.string.card_manage
else -> R.string.nav_pay_with_card
})
}
override fun onDestroyView() {
@@ -882,7 +1095,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
if (assetPath != null) loadCardImage(ivCardImage, assetPath)
else ivCardImage.setImageDrawable(null)
bindCardStatus(tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
itemView.alpha = 1f
itemView.alpha = if (isMibCardActive(item.card.cardStatus)) 1f else 0.45f
}
is CardItem.Bml -> {
tvCardOwner.text = item.account.accountBriefName
@@ -1017,9 +1230,13 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
fun mibCardStatusLabel(cardStatus: String): String? = when (cardStatus) {
"CHST0" -> null
"CHST20" -> "Temporary blocked by client"
else -> cardStatus
}
fun isMibCardActive(cardStatus: String): Boolean = cardStatus == "CHST0"
fun isMibCardFrozen(cardStatus: String): Boolean = cardStatus == "CHST20"
fun bindCardStatus(tv: TextView, statusLabel: String?) {
if (statusLabel == null) { tv.visibility = View.GONE; return }
tv.visibility = View.VISIBLE
@@ -0,0 +1,80 @@
package sh.sar.basedbank.ui.home
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import sh.sar.basedbank.BuildConfig
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentSettingsAboutBinding
class SettingsAboutFragment : Fragment() {
private var _binding: FragmentSettingsAboutBinding? = null
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsAboutBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
val basePaddingBottom = binding.root.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
insets
}
binding.tvAppName.text = getString(R.string.app_name)
binding.tvVersion.text = getString(R.string.about_version, BuildConfig.VERSION_NAME)
binding.rowMibTerms.setOnClickListener { openUrl("https://faisanet.mib.com.mv/terms") }
binding.rowBmlTerms.setOnClickListener { openUrl("https://www.bankofmaldives.com.mv/storage/file/121/10289/terms-conditions-online-banking-en.pdf") }
binding.rowFahipayTerms.setOnClickListener { openUrl("https://fahipay.mv/tos/") }
val hasMvr = BuildConfig.ACCOUNT_MVR.isNotEmpty()
val hasUsd = BuildConfig.ACCOUNT_USD.isNotEmpty()
if (!hasMvr && !hasUsd) {
binding.sectionDonate.visibility = View.GONE
} else {
if (!hasMvr) binding.btnDonateMvr.visibility = View.GONE
else binding.btnDonateMvr.setOnClickListener { openDonate(BuildConfig.ACCOUNT_MVR) }
if (!hasUsd) binding.btnDonateUsd.visibility = View.GONE
else binding.btnDonateUsd.setOnClickListener { openDonate(BuildConfig.ACCOUNT_USD) }
}
}
private fun openUrl(url: String) {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
private fun openDonate(accountNumber: String) {
val fragment = TransferFragment.newInstance(
accountNumber = accountNumber,
displayName = getString(R.string.app_name),
subtitle = accountNumber,
colorHex = "#607D8B",
imageHash = null
)
(requireActivity() as HomeActivity).showWithBackStack(fragment)
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.settings_about)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
@@ -27,7 +27,9 @@ class SettingsFragment : Fragment() {
SettingsItem(R.drawable.ic_contacts, R.string.settings_logins, R.string.settings_desc_logins) { SettingsLoginsFragment() },
SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance, R.string.settings_desc_appearance) { SettingsAppearanceFragment() },
SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security, R.string.settings_desc_privacy_security) { SettingsSecurityFragment() },
SettingsItem(R.drawable.ic_bell_filled, R.string.settings_notifications, R.string.settings_desc_notifications) { SettingsNotificationsFragment() },
SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage, R.string.settings_desc_storage) { SettingsStorageFragment() },
SettingsItem(R.drawable.ic_info, R.string.settings_about, R.string.settings_desc_about) { SettingsAboutFragment() },
)
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
@@ -368,8 +368,9 @@ class SettingsLoginsFragment : Fragment() {
val mibLoginIds = store.getMibLoginIds()
val bmlLoginIds = store.getBmlLoginIds()
val fahipayLoginIds = store.getFahipayLoginIds()
val mfaisaLoginIds = store.getMfaisaLoginIds()
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty() || mfaisaLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
for (loginId in mibLoginIds) {
val profile = store.loadMibUserProfile(loginId)
@@ -396,6 +397,14 @@ class SettingsLoginsFragment : Fragment() {
showFahipayLoginDetails(store, loginId, profile)
}
}
for (loginId in mfaisaLoginIds) {
val profile = store.loadMfaisaUserProfile(loginId)
val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name)
addLoginRow(container, R.drawable.ooredoo_logo, displayName) {
showMfaisaLoginDetails(store, loginId, profile)
}
}
}
private fun addLoginRow(container: LinearLayout, logoRes: Int, displayName: String, onClick: () -> Unit) {
@@ -1065,6 +1074,152 @@ class SettingsLoginsFragment : Fragment() {
buildLoginsSection()
}
private fun showMfaisaLoginDetails(
store: CredentialStore,
loginId: String,
profile: CredentialStore.MfaisaUserProfile?
) {
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val hide = viewModel.hideAmounts.value ?: false
val masked = "••••••"
val pockets = sh.sar.basedbank.util.AccountCache.loadMfaisa(ctx, loginId)
val hidden = store.getHiddenMfaisaPocketIds(loginId).toMutableSet()
val originalHidden = hidden.toSet()
// The user-visible "profiles" are: M-Faisa (every non-PayPal pocket) and PayPal (if linked).
// Each toggle covers the set of pocket account numbers that belong to that profile.
data class MfaisaProfileRow(val label: String, val pocketIds: Set<String>)
val mfaisaPockets = pockets.filter { it.profileType != "MFAISA_PAYPAL" }
val paypalPockets = pockets.filter { it.profileType == "MFAISA_PAYPAL" }
val profileRows = buildList {
if (mfaisaPockets.isNotEmpty()) {
add(MfaisaProfileRow("M-Faisa", mfaisaPockets.map { it.accountNumber }.toSet()))
}
if (paypalPockets.isNotEmpty()) {
add(MfaisaProfileRow("PayPal", paypalPockets.map { it.accountNumber }.toSet()))
}
}
val scroll = android.widget.ScrollView(ctx)
val container = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val pad = (16 * dp).toInt()
setPadding(pad, (8 * dp).toInt(), pad, pad)
}
scroll.addView(container)
listOfNotNull(
profile?.name?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: ${if (hide) masked else it}" },
profile?.mdnId?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: ${if (hide) masked else it}" }
).forEach { line ->
container.addView(TextView(ctx).apply {
text = line
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
bottomMargin = (4 * dp).toInt()
}
})
}
if (profileRows.isNotEmpty()) {
if (profile != null) {
container.addView(View(ctx).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
}
setBackgroundColor(0x1F000000)
})
}
container.addView(TextView(ctx).apply {
text = getString(R.string.login_detail_profiles)
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
it.bottomMargin = (8 * dp).toInt()
}
})
}
val toggleRows = profileRows.map { row ->
val v = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
it.bottomMargin = (4 * dp).toInt()
}
}
val label = TextView(ctx).apply {
text = row.label
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
}
val toggle = MaterialSwitch(ctx).apply {
isChecked = row.pocketIds.any { it !in hidden }
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
marginStart = (4 * dp).toInt()
}
}
v.addView(label)
v.addView(toggle)
container.addView(v)
row to toggle
}
fun updateToggleStates(saveBtn: android.widget.Button) {
val visibleCount = toggleRows.count { (row, _) -> row.pocketIds.any { it !in hidden } }
toggleRows.forEach { (_, toggle) ->
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
}
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
}
val dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(getString(R.string.ooredoo_name))
.setView(scroll)
.apply {
if (profileRows.isNotEmpty()) setPositiveButton(R.string.save, null)
setNeutralButton(R.string.close, null)
setNegativeButton(R.string.settings_logout) { _, _ ->
confirmLogout(getString(R.string.ooredoo_name)) { logoutMfaisa(store, loginId) }
}
}
.show()
if (profileRows.isNotEmpty()) {
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
saveBtn.isEnabled = false
updateToggleStates(saveBtn)
toggleRows.forEach { (row, toggle) ->
toggle.setOnCheckedChangeListener { _, checked ->
if (checked) hidden.removeAll(row.pocketIds) else hidden.addAll(row.pocketIds)
updateToggleStates(saveBtn)
}
}
saveBtn.setOnClickListener {
store.setHiddenMfaisaPocketIds(loginId, hidden)
clearAllCaches(ctx)
dialog.dismiss()
(activity as? HomeActivity)?.relogin()
}
}
}
private fun logoutMfaisa(store: CredentialStore, loginId: String) {
val ctx = requireContext()
store.clearMfaisaCredentials(loginId)
val app = requireActivity().application as BasedBankApp
app.mfaisaSessions.remove(loginId)
app.mfaisaAccounts = app.mfaisaAccounts.filter { it.loginTag != "mfaisa_$loginId" }
app.accounts = app.accounts.filter { it.loginTag != "mfaisa_$loginId" }
clearAllCaches(ctx)
(activity as HomeActivity).relogin()
buildLoginsSection()
}
private fun clearAllCaches(ctx: Context) {
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
@@ -0,0 +1,203 @@
package sh.sar.basedbank.ui.home
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.SwitchCompat
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import com.google.android.material.color.MaterialColors
import sh.sar.basedbank.R
import sh.sar.basedbank.service.NotificationPollingService
class SettingsNotificationsFragment : Fragment() {
private var switchView: SwitchCompat? = null
// Step 1: notification permission — on grant, proceed to battery opt check
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) checkBatteryOptimization() else switchView?.isChecked = false
}
// Step 2: battery optimization — proceed to enableService regardless of user choice
private val batteryOptLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
enableService()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val prefs = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
val scroll = ScrollView(ctx).apply { clipToPadding = false }
val col = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
val p = (20 * dp).toInt()
setPadding(p, p, p, p)
}
// Section header
col.addView(TextView(ctx).apply {
setText(R.string.settings_notif_section)
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
setTextColor(MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimary, Color.CYAN))
setPadding(0, 0, 0, (12 * dp).toInt())
})
// Enable toggle row
val sw = SwitchCompat(ctx).apply {
isChecked = prefs.getBoolean(PREF_ENABLED, false)
}
switchView = sw
sw.setOnCheckedChangeListener { _, on -> if (on) requestEnableNotifications() else disableService() }
val toggleRow = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
val vp = (10 * dp).toInt()
setPadding(0, vp, 0, vp)
}
val textCol = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
}
textCol.addView(TextView(ctx).apply {
setText(R.string.settings_notif_enable)
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
})
textCol.addView(TextView(ctx).apply {
setText(R.string.settings_notif_enable_desc)
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
alpha = 0.65f
})
toggleRow.addView(textCol)
toggleRow.addView(sw.apply {
layoutParams = (layoutParams as? LinearLayout.LayoutParams ?: LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
)).also { it.marginStart = (12 * dp).toInt() }
})
col.addView(toggleRow)
// Description
col.addView(TextView(ctx).apply {
setText(R.string.settings_notif_description)
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
alpha = 0.65f
setPadding(0, (4 * dp).toInt(), 0, (20 * dp).toInt())
})
// Notification channels nav row — same style as settings menu items
val colPad = (20 * dp).toInt()
val navRow = inflater.inflate(R.layout.item_more_nav, col, false).apply {
layoutParams = (layoutParams as LinearLayout.LayoutParams).apply {
marginStart = -colPad
marginEnd = -colPad
topMargin = (8 * dp).toInt()
}
findViewById<ImageView>(R.id.ivIcon).setImageResource(R.drawable.ic_bell_filled)
findViewById<TextView>(R.id.tvLabel).setText(R.string.settings_notif_open_system)
findViewById<TextView>(R.id.tvDescription).setText(R.string.settings_notif_channels_desc)
setOnClickListener {
startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, ctx.packageName)
})
}
}
col.addView(navRow)
scroll.addView(col)
return scroll
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val basePad = view.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
val isBottom = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getBoolean("bottom_nav", false)
val nav = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePad + if (isBottom) 0 else nav.bottom)
insets
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.settings_notifications)
}
override fun onDestroyView() {
switchView = null
super.onDestroyView()
}
// ── Enable flow ───────────────────────────────────────────────────────────────
private fun requestEnableNotifications() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
return
}
checkBatteryOptimization()
}
private fun checkBatteryOptimization() {
val ctx = requireContext()
val pm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
if (!pm.isIgnoringBatteryOptimizations(ctx.packageName)) {
batteryOptLauncher.launch(
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${ctx.packageName}")
}
)
return
}
enableService()
}
private fun enableService() {
val ctx = requireContext()
ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
.edit().putBoolean(PREF_ENABLED, true).apply()
ctx.startForegroundService(Intent(ctx, NotificationPollingService::class.java))
switchView?.isChecked = true
}
private fun disableService() {
val ctx = requireContext()
ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
.edit().putBoolean(PREF_ENABLED, false).apply()
ctx.stopService(Intent(ctx, NotificationPollingService::class.java))
switchView?.isChecked = false
}
companion object {
const val PREF_ENABLED = "notifications_enabled"
}
}
File diff suppressed because it is too large Load Diff
@@ -27,6 +27,7 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlHistoryClient
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
import sh.sar.basedbank.api.mfaisa.MfaisaHistoryClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibHistoryClient
@@ -61,12 +62,18 @@ class TransferHistoryFragment : Fragment() {
var bmlTotalPages: Int = -1,
var cardMonthOffset: Int = 0,
var fahipayNextStart: Int = 0,
var fahipayTotal: Int = -1
var fahipayTotal: Int = -1,
var mfaisaNextPage: Int = 1,
var mfaisaHasMore: Boolean = true
) {
// PayPal pockets have no known history endpoint, so they don't paginate here.
private val isMfaisaPaypal get() = account.profileType == "MFAISA_PAYPAL"
fun hasMore(): Boolean = when {
account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT" -> cardMonthOffset < 2
account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
account.bank == "MFAISA" -> !isMfaisaPaypal && mfaisaHasMore
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
}
}
@@ -209,13 +216,14 @@ class TransferHistoryFragment : Fragment() {
cal.add(Calendar.MONTH, -state.cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
state.cardMonthOffset++
BmlHistoryClient().fetchCardHistory(
val cardResult = BmlHistoryClient().fetchCardHistory(
session = session,
cardId = state.account.internalId,
accountDisplayName = state.account.accountBriefName,
accountNumber = state.account.accountNumber,
month = month
)
cardResult.statement + cardResult.outstanding + cardResult.unbilled
}
else -> {
val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
@@ -235,6 +243,36 @@ class TransferHistoryFragment : Fragment() {
}
}.awaitAll().flatten())
// M-Faisa accounts (PayPal pockets are skipped by hasMore())
val mfaisaStates = activeStates.filter { it.account.bank == "MFAISA" }
for (state in mfaisaStates) {
var session = app.mfaisaSessionFor(state.account) ?: continue
try {
val page = try {
MfaisaHistoryClient().fetchHistory(
session = session,
accountNumber = state.account.accountNumber,
accountDisplayName = state.account.accountBriefName,
pageNo = state.mfaisaNextPage,
recordSize = 70
)
} catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) {
val loginId = state.account.loginTag.removePrefix("mfaisa_")
session = app.refreshMfaisaSession(loginId) ?: continue
MfaisaHistoryClient().fetchHistory(
session = session,
accountNumber = state.account.accountNumber,
accountDisplayName = state.account.accountBriefName,
pageNo = state.mfaisaNextPage,
recordSize = 70
)
}
state.mfaisaHasMore = page.hasMore
state.mfaisaNextPage++
results.addAll(page.transactions)
} catch (e: Exception) { trackError(e) }
}
// Fahipay accounts
val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" }
for (state in fahipayStates) {
@@ -19,4 +19,10 @@ data class TransferReceiptData(
val bmlReference: String = "",
val bmlTimestamp: String = "",
val bmlMessage: String = "",
// M-Faisa receipt fields
val mfaisaTransactionType: String = "",
val mfaisaFromName: String = "",
val mfaisaFromMsisdn: String = "",
val mfaisaToMsisdn: String = "",
val mfaisaTimestamp: Long = 0L,
)
@@ -11,17 +11,13 @@ import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.Rect
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.util.Base64
import android.view.LayoutInflater
import android.view.PixelCopy
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
@@ -38,6 +34,7 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.databinding.FragmentReceiptBmlBinding
import sh.sar.basedbank.databinding.FragmentReceiptMfaisaBinding
import sh.sar.basedbank.databinding.FragmentReceiptMibBinding
import java.io.File
import java.io.FileOutputStream
@@ -68,6 +65,11 @@ class TransferReceiptFragment : Fragment() {
private const val ARG_BML_REFERENCE = "bml_reference"
private const val ARG_BML_TIMESTAMP = "bml_timestamp"
private const val ARG_BML_MESSAGE = "bml_message"
private const val ARG_MFAISA_TXN_TYPE = "mfaisa_txn_type"
private const val ARG_MFAISA_FROM_NAME = "mfaisa_from_name"
private const val ARG_MFAISA_FROM_MSISDN = "mfaisa_from_msisdn"
private const val ARG_MFAISA_TO_MSISDN = "mfaisa_to_msisdn"
private const val ARG_MFAISA_TIMESTAMP = "mfaisa_timestamp"
// Holds the already-rendered to-avatar bitmap from TransferFragment
var pendingToAvatarBitmap: Bitmap? = null
@@ -91,22 +93,36 @@ class TransferReceiptFragment : Fragment() {
putString(ARG_BML_REFERENCE, data.bmlReference)
putString(ARG_BML_TIMESTAMP, data.bmlTimestamp)
putString(ARG_BML_MESSAGE, data.bmlMessage)
putString(ARG_MFAISA_TXN_TYPE, data.mfaisaTransactionType)
putString(ARG_MFAISA_FROM_NAME, data.mfaisaFromName)
putString(ARG_MFAISA_FROM_MSISDN, data.mfaisaFromMsisdn)
putString(ARG_MFAISA_TO_MSISDN, data.mfaisaToMsisdn)
putLong(ARG_MFAISA_TIMESTAMP, data.mfaisaTimestamp)
}
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
return if (bank == "MIB") {
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
bindMib(binding)
_receiptCard = binding.receiptCard
binding.root
} else {
val binding = FragmentReceiptBmlBinding.inflate(inflater, container, false)
bindBml(binding)
_receiptCard = binding.receiptCard
binding.root
return when (bank) {
"MIB" -> {
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
bindMib(binding)
_receiptCard = binding.receiptCard
binding.root
}
"MFAISA" -> {
val binding = FragmentReceiptMfaisaBinding.inflate(inflater, container, false)
bindMfaisa(binding)
_receiptCard = binding.receiptCard
binding.root
}
else -> {
val binding = FragmentReceiptBmlBinding.inflate(inflater, container, false)
bindBml(binding)
_receiptCard = binding.receiptCard
binding.root
}
}
}
@@ -139,9 +155,6 @@ class TransferReceiptFragment : Fragment() {
}
})
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
parentFragmentManager.popBackStack()
}
view.findViewById<MaterialButton>(R.id.btnShare).setOnClickListener {
shareReceipt()
}
@@ -250,6 +263,49 @@ class TransferReceiptFragment : Fragment() {
)
}
private fun bindMfaisa(binding: FragmentReceiptMfaisaBinding) {
val args = requireArguments()
val currency = args.getString(ARG_CURRENCY, "MVR")
val amountStr = args.getString(ARG_AMOUNT, "")
val formattedAmount = try {
val d = amountStr.toDouble()
val intFmt = NumberFormat.getNumberInstance(Locale.US).apply { maximumFractionDigits = 0 }
intFmt.format(d.toLong()) + "%.2f".format(d).takeLast(3)
} catch (_: Exception) { amountStr }
binding.tvAmount.text = "$currency $formattedAmount"
binding.tvTransactionType.text = args.getString(ARG_MFAISA_TXN_TYPE, "")
.ifBlank { "Transfer to mobile" }
binding.tvFromName.text = args.getString(ARG_MFAISA_FROM_NAME, "")
.ifBlank { args.getString(ARG_FROM_LABEL, "") }
binding.tvFromMsisdn.text = args.getString(ARG_MFAISA_FROM_MSISDN, "")
binding.tvToName.text = args.getString(ARG_TO_LABEL, "")
binding.tvToMsisdn.text = args.getString(ARG_MFAISA_TO_MSISDN, "")
.ifBlank { args.getString(ARG_TO_ACCOUNT, "") }
binding.tvDateTime.text = formatMfaisaTimestamp(args.getLong(ARG_MFAISA_TIMESTAMP, 0L))
val remarks = args.getString(ARG_REMARKS, "")
if (!remarks.isNullOrBlank()) {
binding.tvRemarks.text = remarks
binding.remarksDivider.visibility = View.VISIBLE
binding.remarksRow.visibility = View.VISIBLE
}
copyOnLongClick(
binding.tvAmount, binding.tvStatus, binding.tvTransactionType,
binding.tvFromName, binding.tvFromMsisdn,
binding.tvToName, binding.tvToMsisdn,
binding.tvDateTime, binding.tvRemarks
)
}
private fun formatMfaisaTimestamp(millis: Long): String {
val effective = if (millis > 0) millis else System.currentTimeMillis()
val sdf = java.text.SimpleDateFormat("EEEE d MMMM yyyy HH:mm:ss z", Locale.US)
return sdf.format(java.util.Date(effective))
}
// ── Share / Save ──────────────────────────────────────────────────────────
private fun shareReceipt() {
@@ -314,21 +370,19 @@ class TransferReceiptFragment : Fragment() {
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Captures the receipt card using PixelCopy, which correctly handles
* hardware-accelerated views (avoids the black-square problem with view.draw()).
* Draws the receipt card to an offscreen bitmap at its natural (unscaled)
* dimensions, so the captured image isn't affected by the on-screen scale
* 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 bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
val location = IntArray(2)
view.getLocationInWindow(location)
val srcRect = Rect(location[0], location[1], location[0] + view.width, location[1] + view.height)
PixelCopy.request(requireActivity().window, srcRect, bitmap, { result ->
callback(if (result == PixelCopy.SUCCESS) bitmap else null)
}, Handler(Looper.getMainLooper()))
val canvas = Canvas(bitmap)
canvas.drawColor(Color.WHITE)
view.draw(canvas)
callback(bitmap)
}
private fun formatBmlTimestamp(raw: String): String {
@@ -366,14 +420,22 @@ class TransferReceiptFragment : Fragment() {
setBackgroundColor(Color.BLACK)
}
val cardView = if (bank == "MIB") {
val binding = FragmentReceiptMibBinding.inflate(layoutInflater)
bindMib(binding)
binding.receiptCard
} else {
val binding = FragmentReceiptBmlBinding.inflate(layoutInflater)
bindBml(binding)
binding.receiptCard
val cardView = when (bank) {
"MIB" -> {
val binding = FragmentReceiptMibBinding.inflate(layoutInflater)
bindMib(binding)
binding.receiptCard
}
"MFAISA" -> {
val binding = FragmentReceiptMfaisaBinding.inflate(layoutInflater)
bindMfaisa(binding)
binding.receiptCard
}
else -> {
val binding = FragmentReceiptBmlBinding.inflate(layoutInflater)
bindBml(binding)
binding.receiptCard
}
}
(cardView.parent as? ViewGroup)?.removeView(cardView)
cardView.setOnClickListener { dialog.dismiss() }
@@ -423,13 +485,9 @@ class TransferReceiptFragment : Fragment() {
(activity as? HomeActivity)?.setBottomNavVisible(false)
}
override fun onPause() {
super.onPause()
(activity as? HomeActivity)?.setBottomNavVisible(true)
}
override fun onDestroyView() {
super.onDestroyView()
(activity as? HomeActivity)?.setBottomNavVisible(true)
_receiptCard = null
pendingToAvatarBitmap = null
}
@@ -0,0 +1,739 @@
package sh.sar.basedbank.ui.home.transfer
import android.graphics.Bitmap
import android.view.Gravity
import android.view.View
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
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.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlOtpChannel
import sh.sar.basedbank.api.bml.BmlQrPayClient
import sh.sar.basedbank.api.bml.BmlQrPayInfo
import sh.sar.basedbank.api.bml.BmlQrPayLookupException
import sh.sar.basedbank.api.bml.BmlQrPayResult
import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.bml.BmlTransferClient
import sh.sar.basedbank.api.bml.BmlValidateClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
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.ui.home.TransferReceiptData
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache
import sh.sar.basedbank.util.Totp
/**
* Owns the BML-only parts of the Transfer screen: session lookup, the CASA/card transfer itself,
* the business-profile OTP state machine (channel pick → initiate → verify), and merchant QR
* payment (card/gateway QR lookup + pre-initiate/initiate/confirm).
*
* Lives alongside [TransferFragment], which dispatches to it whenever the selected source's
* [BankAccount.bank] is "BML", and mirrors [MfaisaTransferHandler]'s shape: the fragment keeps
* the shared confirm dialog, the "To" lookup and the form state; the handler keeps everything
* BML-specific.
*
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
* [androidx.fragment.app.Fragment.viewLifecycleOwner] and Context) — and must be re-created when
* the view is recreated.
*/
class BmlTransferHandler(
private val fragment: TransferFragment,
private val binding: FragmentTransferBinding,
private val viewModel: HomeViewModel,
/** Reads the fragment's currently-selected source account. */
private val currentSource: () -> BankAccount?,
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
private val selectSource: (BankAccount) -> 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. */
private val onTransferSuccess: (TransferReceiptData, Bitmap?) -> Unit,
) {
private val app get() = fragment.requireActivity().application as BasedBankApp
private val ctx get() = fragment.requireContext()
private val host get() = fragment.activity as? HomeActivity
// ─── State ───────────────────────────────────────────────────────────────
/** 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
private var otpChannel: String? = null
private data class PendingTransfer(
val src: BankAccount,
val debitAccount: String,
val creditAccount: String,
val amount: Double,
val amountStr: String,
val remarks: String,
val transferType: String,
val currency: String,
val bank: String?,
val destDisplay: String,
val destAccount: String,
val toBank: String,
val toAvatar: Bitmap?
)
private var pendingTransfer: PendingTransfer? = null
/** Merchant QR payment mode (set when navigated from a card/gateway QR scan). */
var qrInfo: BmlQrPayInfo? = null
private set
/** 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
// ─── Public API the fragment calls ───────────────────────────────────────
/** Whether the business OTP flow is mid-way — the fragment freezes the Send button while it is. */
val isOtpFlowActive: Boolean get() = otpState != OtpState.NONE
/** Whether the Send button should verify an OTP instead of starting a new transfer. */
val isAwaitingOtp: Boolean get() = otpState == OtpState.AWAITING_OTP
/** Whether a merchant QR is loaded — counts as a resolved recipient for the Send button. */
val hasQrMerchant: Boolean get() = qrInfo != null
fun sessionFor(account: BankAccount?): BmlSession? =
account?.let { app.bmlSessionFor(it) } ?: app.anyBmlSession()
fun isBusinessProfile(account: BankAccount): Boolean {
val loginId = account.loginTag.removePrefix("bml_")
val profiles = app.bmlProfilesMap[loginId] ?: return false
return profiles.firstOrNull { it.profileId == account.profileId }?.profileType == "business"
}
/**
* Resolves a destination through BML — `verifyMibAccount` when [verifyAsMib], otherwise the
* regular alias/account validation. Blocking; call from IO. Returns null when BML can't
* resolve it, so the caller can fall back to the MIB IPS lookup.
*
* The result is mapped onto [MibIpsAccountInfo] because that is the Transfer screen's common
* "resolved recipient" shape, whichever bank did the resolving. Note that the MIB-verify
* endpoint leaves `currency` blank — the caller enriches it via
* [MibTransferHandler.lookupCurrency] when a MIB session is available.
*/
fun validateDestination(
session: BmlSession,
accountNumber: String,
verifyAsMib: Boolean
): MibIpsAccountInfo? {
val result = try {
if (verifyAsMib) BmlValidateClient().verifyMibAccount(session, accountNumber)
else BmlValidateClient().validateAccount(session, accountNumber)
} catch (_: Exception) { null } ?: return null
val bankId = when (result.trnType) {
"IAT" -> "MALBMVMV"
else -> result.agnt ?: result.account
}
return MibIpsAccountInfo(
accountName = result.name,
accountNumber = result.account,
bankId = bankId,
currency = result.currency
)
}
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
fun clearQrMerchant() {
if (qrInfo == null) return
qrInfo = null
gatewayQr = false
binding.tilAmount.isEnabled = true
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
binding.etAmount.setText("")
}
/** Called when the view tears down — an in-flight OTP flow cannot sensibly resume. */
fun clearState() {
otpState = OtpState.NONE
otpChannel = null
pendingTransfer = null
}
// ─── Merchant QR ─────────────────────────────────────────────────────────
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)
val session = app.anyBmlSession() ?: return
// Lock the "To" input row while loading
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
host?.setRefreshing(true)
fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
}
host?.setRefreshing(false)
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.
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
if (info.amount == 0.0) {
RecentsCache.save(ctx, RecentPick(
accountNumber = "bmlqr:$qrUrl",
displayName = info.merchantName,
subtitle = info.merchantAddress.ifBlank { "BML Merchant" },
colorHex = "#0066A1",
imageHash = null,
isProfileImage = false
))
}
// Auto-select the user's default BML card if no card was pre-selected
if (currentSource() == null) {
val defaultNum = CredentialStore(ctx).getDefaultCardAccountNumber()
if (defaultNum != null) {
val allAccounts = viewModel.accounts.value ?: emptyList()
val defaultCard = allAccounts.firstOrNull {
it.accountNumber == defaultNum && isCard(it) &&
it.statusDesc.equals("Active", ignoreCase = true)
}
if (defaultCard != null) selectSource(defaultCard)
}
}
// 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()
}
}
/**
* 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.
*/
fun submitQrPayment() {
val info = qrInfo ?: return
val src = currentSource() ?: run {
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
val amount = amountStr.toDoubleOrNull()
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
binding.tilAmount.error = null
val debitAccount = src.internalId.ifBlank {
Toast.makeText(ctx, R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
return
}
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 confirmView = fragment.buildTransferConfirmView(
amountCurrency = info.currency,
amountValue = "%.2f".format(amount),
fromName = src.accountBriefName,
fromNumber = src.accountNumber,
fromDetail = fromDetail,
toName = info.merchantName,
toNumber = "",
toDetail = info.merchantAddress.ifBlank { "BML Merchant" }
)
fragment.showConfirmWithBiometric(
title = ctx.getString(R.string.transfer),
customView = confirmView,
biometricSubtitle = "${info.currency} ${"%.2f".format(amount)}${info.merchantName}",
onConfirmed = { dialog, frame ->
fragment.showProcessingInDialog(dialog, frame)
executeQrPayment(src, debitAccount, info, amount, dialog, frame)
}
)
}
private fun executeQrPayment(
src: BankAccount,
debitAccount: String,
info: BmlQrPayInfo,
amount: Double,
dialog: AlertDialog,
frame: android.widget.FrameLayout
) {
val loginId = src.loginTag.removePrefix("bml_")
val session = sessionFor(src) ?: run {
dialog.dismiss()
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val otp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) }
?: run { dialog.dismiss(); Toast.makeText(ctx, "OTP unavailable", Toast.LENGTH_SHORT).show(); return }
binding.btnTransfer.isEnabled = false
fragment.viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
try {
if (gatewayQr) {
val preOk = BmlQrPayClient().preInitiatePayment(
session, debitAccount, info.requestId, amount, info.currency)
if (!preOk) return@withContext null
}
val initiated = BmlQrPayClient().initiatePayment(
session, debitAccount, info.requestId, amount, info.currency)
if (!initiated) return@withContext null
val confirmOtp = CredentialStore(ctx).loadBmlCredentials(loginId)
?.otpSeed?.let { Totp.generate(it) } ?: otp
BmlQrPayClient().confirmPayment(
session, debitAccount, info.requestId, amount, info.currency, confirmOtp)
} catch (e: Exception) {
BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
}
}
if (fragment.view == null) return@launch
if (result == null) {
dialog.dismiss()
binding.btnTransfer.isEnabled = true
Toast.makeText(ctx, "Failed to initiate payment", Toast.LENGTH_LONG).show()
return@launch
}
if (result.success) {
fragment.showSuccessInDialog(
dialog, frame,
amountCurrency = result.currency.ifBlank { info.currency },
amountValue = result.amount.ifBlank { "%.2f".format(amount) },
fromName = src.accountBriefName,
toName = result.merchant.ifBlank { info.merchantName }
) {
fragment.clearForm()
host?.triggerRefresh()
}
} else {
dialog.dismiss()
binding.btnTransfer.isEnabled = true
Toast.makeText(ctx, result.errorMessage, Toast.LENGTH_LONG).show()
}
}
}
// ─── Personal-profile transfer (token OTP, no user interaction) ──────────
/**
* Runs initiate + confirm on the calling (IO) thread and returns
* `(ok, message, receipt)`. Message is "CONNECTIVITY" when the network was unreachable.
*/
fun doTransfer(
src: BankAccount,
destAccount: String,
destDisplay: String,
amount: Double,
amountStr: String,
remarks: String,
isSrcCard: Boolean,
isDestMib: Boolean,
currency: String,
allAccounts: List<BankAccount>,
allContacts: List<BankContact>
): Triple<Boolean, String, TransferReceiptData?> {
val loginId = src.loginTag.removePrefix("bml_")
val sess = sessionFor(src) ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
val otp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) }
?: return Triple(false, "OTP unavailable", null)
val debitAccount = src.internalId.ifBlank {
return Triple(false, ctx.getString(R.string.transfer_missing_internal_id), null)
}
val routed = routeTransfer(destAccount, isSrcCard, isDestMib, currency, allAccounts, allContacts)
?: return Triple(false, "BML contact not found for this account", null)
val (transferType, creditAccount, bank) = routed
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
// Step 1: initiate
val initiated = try {
BmlTransferClient().initiateTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, bank)
} catch (e: Exception) { return Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Initiation failed"), null) }
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
// Step 2: confirm with fresh OTP
val confirmOtp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) } ?: otp
return try {
val result = BmlTransferClient().confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
if (result.success) {
val receipt = TransferReceiptData(
bank = "BML",
amount = "%.2f".format(amount),
currency = currency,
fromLabel = src.accountBriefName,
fromColorHex = "#0066A1",
toLabel = destDisplay.ifBlank { destAccount },
toAccount = destAccount,
toBank = toBank,
remarks = remarks,
bmlFromName = src.accountBriefName,
bmlReference = result.reference,
bmlTimestamp = result.timestamp,
bmlMessage = result.message
)
val time = result.timestamp.take(19).replace("T", " ")
Triple(true, "Reference: ${result.reference}\n$time", receipt)
} else {
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
}
} catch (e: Exception) {
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
}
}
// ─── Business profile OTP flow ───────────────────────────────────────────
fun startBusinessOtpFlow(
src: BankAccount,
destAccount: String,
destDisplay: String,
amount: Double,
amountStr: String,
remarks: String,
isSrcCard: Boolean,
isDestMib: Boolean,
currency: String,
allAccounts: List<BankAccount>,
allContacts: List<BankContact>,
toAvatar: Bitmap?
) {
val debitAccount = src.internalId.ifBlank {
Toast.makeText(ctx, ctx.getString(R.string.transfer_missing_internal_id), Toast.LENGTH_SHORT).show()
return
}
val routed = routeTransfer(destAccount, isSrcCard, isDestMib, currency, allAccounts, allContacts)
if (routed == null) {
Toast.makeText(ctx, "BML contact not found for this account", Toast.LENGTH_SHORT).show()
return
}
val (transferType, creditAccount, bank) = routed
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
pendingTransfer = PendingTransfer(
src = src,
debitAccount = debitAccount,
creditAccount = creditAccount,
amount = amount,
amountStr = amountStr,
remarks = remarks,
transferType = transferType,
currency = currency,
bank = bank,
destDisplay = destDisplay,
destAccount = destAccount,
toBank = toBank,
toAvatar = toAvatar
)
otpState = OtpState.SELECTING_CHANNEL
binding.btnTransfer.isEnabled = false
host?.setRefreshing(true)
fragment.viewLifecycleOwner.lifecycleScope.launch {
val sess = sessionFor(src)
val channels = if (sess != null) {
withContext(Dispatchers.IO) {
try { BmlAccountClient().fetchTransferChannels(sess) }
catch (_: Exception) { emptyList() }
}
} else emptyList<BmlOtpChannel>()
host?.setRefreshing(false)
if (channels.isEmpty()) {
Toast.makeText(ctx, "Could not load OTP channels", Toast.LENGTH_SHORT).show()
resetOtpState()
onStateChanged()
return@launch
}
showChannelSelection(channels)
}
}
private fun showChannelSelection(channels: List<BmlOtpChannel>) {
val dp = ctx.resources.displayMetrics.density
binding.containerBmlChannels.removeAllViews()
for (channel in channels) {
val iconRes = when (channel.channel) {
"email" -> R.drawable.ic_channel_email
"mobile" -> R.drawable.ic_channel_sms
else -> R.drawable.ic_channel_sms
}
val iconSize = (24 * dp).toInt()
val textCol = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
marginStart = (12 * dp).toInt()
}
}
textCol.addView(TextView(ctx).apply {
text = channel.description
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
})
textCol.addView(TextView(ctx).apply {
text = channel.masked
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
alpha = 0.6f
})
val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
background = ta.getDrawable(0); ta.recycle()
isClickable = true; isFocusable = true
val hp = (16 * dp).toInt(); val vp = (12 * dp).toInt()
setPadding(hp, vp, hp, vp)
}
row.addView(ImageView(ctx).apply { setImageResource(iconRes) },
LinearLayout.LayoutParams(iconSize, iconSize))
row.addView(textCol)
row.setOnClickListener { selectOtpChannel(channel) }
binding.containerBmlChannels.addView(row)
}
disableTransferFields()
binding.layoutBmlChannelSelection.visibility = View.VISIBLE
}
private fun selectOtpChannel(channel: BmlOtpChannel) {
otpChannel = channel.channel
binding.layoutBmlChannelSelection.visibility = View.GONE
val pending = pendingTransfer ?: return
val sess = sessionFor(pending.src) ?: run {
Toast.makeText(ctx, ctx.getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
resetOtpState()
onStateChanged()
return
}
binding.btnTransfer.isEnabled = false
host?.setRefreshing(true)
fragment.viewLifecycleOwner.lifecycleScope.launch {
val initiated = withContext(Dispatchers.IO) {
try {
BmlTransferClient().initiateTransfer(
sess, pending.debitAccount, pending.creditAccount,
pending.amount, pending.transferType, pending.currency,
pending.bank, channel.channel
)
} catch (_: Exception) { false }
}
host?.setRefreshing(false)
if (!initiated) {
Toast.makeText(ctx, "Failed to initiate transfer — check your session", Toast.LENGTH_SHORT).show()
resetOtpState()
onStateChanged()
return@launch
}
otpState = OtpState.AWAITING_OTP
binding.tvBmlOtpSentVia.text = "OTP code sent via: ${channel.description} (${channel.masked})"
binding.tvBmlOtpSentVia.visibility = View.VISIBLE
binding.tilBmlOtp.visibility = View.VISIBLE
binding.etBmlOtp.requestFocus()
binding.btnTransfer.text = ctx.getString(R.string.transfer_verify_payment)
binding.btnTransfer.isEnabled = true
}
}
/** Send-button action while [isAwaitingOtp]: confirms the pending transfer with the typed code. */
fun verifyOtp() {
val otp = binding.etBmlOtp.text?.toString()?.trim() ?: ""
if (otp.isEmpty()) {
binding.tilBmlOtp.error = "Enter the verification code"
return
}
binding.tilBmlOtp.error = null
val pending = pendingTransfer ?: return
val channel = otpChannel ?: return
val sess = sessionFor(pending.src) ?: run {
Toast.makeText(ctx, ctx.getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
return
}
binding.btnTransfer.isEnabled = false
host?.setRefreshing(true)
val capturedToAvatar = pending.toAvatar
fragment.viewLifecycleOwner.lifecycleScope.launch {
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
try {
val result = BmlTransferClient().confirmTransfer(
sess, pending.debitAccount, pending.creditAccount,
pending.amount, pending.transferType, pending.currency,
otp, pending.remarks, pending.bank, channel
)
if (result.success) {
val r = TransferReceiptData(
bank = "BML",
amount = "%.2f".format(pending.amount),
currency = pending.currency,
fromLabel = pending.src.accountBriefName,
fromColorHex = "#0066A1",
toLabel = pending.destDisplay.ifBlank { pending.destAccount },
toAccount = pending.destAccount,
toBank = pending.toBank,
remarks = pending.remarks,
bmlFromName = pending.src.accountBriefName,
bmlReference = result.reference,
bmlTimestamp = result.timestamp,
bmlMessage = result.message
)
Triple(true, "", r)
} else {
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null as TransferReceiptData?)
}
} catch (e: Exception) {
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null as TransferReceiptData?)
}
}
host?.setRefreshing(false)
if (ok && receipt != null) {
resetOtpState()
onTransferSuccess(receipt, capturedToAvatar)
} else {
binding.btnTransfer.isEnabled = true
if (msg == "CONNECTIVITY") {
host?.showConnectivityBanner(ctx.getString(R.string.connectivity_no_internet))
} else {
binding.tilBmlOtp.error = msg
}
}
}
}
/** Tears down the OTP UI and re-enables the form. Safe to call when no flow is running. */
fun resetOtpState() {
otpState = OtpState.NONE
otpChannel = null
pendingTransfer = null
if (fragment.view == null) return
binding.layoutBmlChannelSelection.visibility = View.GONE
binding.tvBmlOtpSentVia.visibility = View.GONE
binding.tilBmlOtp.visibility = View.GONE
binding.etBmlOtp.setText("")
binding.tilBmlOtp.error = null
enableTransferFields()
binding.btnTransfer.text = ctx.getString(R.string.transfer)
}
private fun disableTransferFields() {
binding.tilAmount.isEnabled = false
binding.tilRemarks.isEnabled = false
binding.cardFromInfo.alpha = 0.5f
binding.btnClearFromInfo.isEnabled = false
binding.cardToInfo.alpha = 0.5f
binding.btnClearToInfo.isEnabled = false
}
private fun enableTransferFields() {
binding.tilAmount.isEnabled = true
binding.tilRemarks.isEnabled = true
binding.cardFromInfo.alpha = 1f
binding.btnClearFromInfo.isEnabled = true
binding.cardToInfo.alpha = 1f
binding.btnClearToInfo.isEnabled = true
}
// ─── Shared routing ──────────────────────────────────────────────────────
/**
* Picks the BML transfer type and the credit-account identifier the API expects.
* Returns null only for the USD → MIB case with no matching saved BML contact, which both
* callers report as "BML contact not found".
*/
private fun routeTransfer(
destAccount: String,
isSrcCard: Boolean,
isDestMib: Boolean,
currency: String,
allAccounts: List<BankAccount>,
allContacts: List<BankContact>
): Triple<String, String, String?>? {
val isDestMyCard = allAccounts.any { isCard(it) && it.accountNumber == destAccount }
return when {
isSrcCard -> {
// CAD: card → own BML account
val destBml = allAccounts.firstOrNull { it.accountNumber == destAccount && it.profileType == "BML" }
Triple("CAD", destBml?.internalId?.ifBlank { destAccount } ?: destAccount, null)
}
isDestMyCard -> {
// CPA: BML CASA → own card top-up
val card = allAccounts.first { isCard(it) && it.accountNumber == destAccount }
Triple("CPA", card.internalId.ifBlank { destAccount }, null)
}
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
isDestMib -> {
// USD DOT: requires BML contact numeric ID
val contact = allContacts.firstOrNull { it.benefCategoryId == "BML" && it.benefAccount == destAccount }
?: return null
Triple("DOT", contact.benefNo.removePrefix("bml_"), null)
}
else -> Triple("IAT", destAccount, null)
}
}
private fun isCard(account: BankAccount) =
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
}
@@ -0,0 +1,198 @@
package sh.sar.basedbank.ui.home.transfer
import android.view.View
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.dhiraagu.DhiraaguClient
import sh.sar.basedbank.api.fahipay.OoredooClient
import sh.sar.basedbank.databinding.FragmentTransferBinding
import sh.sar.basedbank.ui.home.HomeViewModel
import sh.sar.basedbank.ui.home.TransferFragment
import sh.sar.basedbank.util.AccountInputParser
/**
* 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.
*
* 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.
*/
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"),
}
/**
* 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.
*
* 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.
*
* 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.
*/
class FahipayTransferHandler(
private val fragment: TransferFragment,
private val binding: FragmentTransferBinding,
private val viewModel: HomeViewModel,
) {
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
/** How the confirm dialog names the destination, or "" when nothing is selected. */
val destinationLabel: String get() = service?.destinationLabel.orEmpty()
// ─── Public API the fragment calls ───────────────────────────────────────
/**
* Resolves a destination for a Fahipay source. Only phone numbers are payable, so anything
* else is rejected inline on the "To" field.
*/
fun lookupRecipient(rawInput: String) {
if (AccountInputParser.detect(rawInput) != AccountInputParser.InputType.PHONE) {
binding.tilTo.error = ctx.getString(R.string.transfer_fahipay_phone_only)
return
}
lookupCarrier(rawInput)
}
/** Clears the selected service and hides the chip picker. */
fun clearState() {
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)
}
}
/**
* 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.
*/
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)
}
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)
}
// ─── 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
val contacts = viewModel.contacts.value ?: emptyList()
val displayName = ownerName
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
?: number
fragment.prefillToDirectly(
accountNumber = number,
displayName = displayName,
subtitle = "${picked.label} · $number",
colorHex = "#FF6B00",
imageHash = null
)
}
}
@@ -0,0 +1,653 @@
package sh.sar.basedbank.ui.home.transfer
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.view.Gravity
import android.view.View
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
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.mfaisa.MfaisaInvalidOtpException
import sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient
import sh.sar.basedbank.api.mfaisa.MfaisaRecipientNotFoundException
import sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient
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.ui.home.TransferReceiptData
import sh.sar.basedbank.util.AccountInputParser
import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache
/**
* Owns the M-Faisa-only parts of the Transfer screen: phone-based recipient lookup,
* initiate-with-OTP and confirm-with-OTP for wallet transfers, plus merchant QR pay
* (qrCodeId lookup + initiate/confirm purchase). Lives alongside [TransferFragment] which
* dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source, or whenever
* an M-Faisa merchant QR is loaded.
*
* Mirrors [BmlTransferHandler]: the fragment keeps the shared confirm dialog, the recipient card
* and the form state; the handler keeps everything M-Faisa-specific.
*
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
* [Fragment.viewLifecycleOwner] and Context) and must be re-created when the view is recreated.
*/
class MfaisaTransferHandler(
private val fragment: TransferFragment,
private val binding: FragmentTransferBinding,
private val viewModel: HomeViewModel,
/** Reads the fragment's currently-selected source account. */
private val currentSource: () -> BankAccount?,
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
private val selectSource: (BankAccount) -> Unit,
/** Hook called when M-Faisa successfully resolves or clears a recipient — fragment uses this to update Send-button state. */
private val onRecipientChanged: () -> Unit,
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes account balances. */
private val onTransferSuccess: (TransferReceiptData, Bitmap?) -> Unit,
) {
private val app get() = fragment.requireActivity().application as BasedBankApp
private val ctx get() = fragment.requireContext()
private val host get() = fragment.activity as? HomeActivity
/** Set to the resolved recipient after a successful search; null otherwise. */
var recipient: MfaisaTransferClient.Recipient? = null
private set
/** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */
var qrInfo: MfaisaQrPayClient.QrMerchant? = null
private set
private var lookupInFlight = false
// ─── Public API the fragment calls ───────────────────────────────────────
/** Whether the recipient lookup has resolved — gates the Send button. */
fun isRecipientReady(): Boolean = recipient?.isMvr == true
/** Triggered when the user taps the search end-icon in `tilTo` (and source bank is MFAISA). */
fun searchRecipient(rawInput: String) {
if (lookupInFlight) return
// Reuse the shared normalizer so "+960", "960", and embedded spaces work the same as
// they do for MIB/BML lookup. The result is a bare 7-digit MSISDN when input was a
// local phone number, untouched otherwise.
val phone = AccountInputParser.normalize(rawInput)
if (AccountInputParser.detect(phone) != AccountInputParser.InputType.PHONE) {
binding.tilTo.error = "Enter a valid mobile number"
return
}
binding.tilTo.error = null
val source = mfaisaSource() ?: return
val session = app.mfaisaSessionFor(source) ?: run {
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
lookupInFlight = true
(fragment.activity as? HomeActivity)?.setRefreshing(true)
fragment.viewLifecycleOwner.lifecycleScope.launch {
try {
val result = withContext(Dispatchers.IO) {
try {
MfaisaTransferClient.forContext(ctx).searchRecipient(session, phone)
} catch (_: MfaisaSessionExpiredException) {
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
?: throw IllegalStateException("Could not refresh M-Faisa session")
MfaisaTransferClient.forContext(ctx).searchRecipient(fresh, phone)
}
}
if (!result.isMvr) {
// Server returned the user but only with a PayPal pocket — not supported.
binding.tilTo.error = "This number doesn't have an MVR M-Faisa pocket"
} else {
recipient = result
showResolvedRecipient(result)
}
} catch (_: MfaisaRecipientNotFoundException) {
binding.tilTo.error = "No M-Faisa wallet found for this number"
} catch (e: java.io.IOException) {
Toast.makeText(ctx, R.string.connectivity_no_internet, Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
binding.tilTo.error = e.message ?: "Lookup failed"
} finally {
lookupInFlight = false
(fragment.activity as? HomeActivity)?.setRefreshing(false)
onRecipientChanged()
}
}
}
/** Triggered when the user taps the Send button (and source bank is MFAISA). */
fun submit() {
val source = mfaisaSource() ?: return
val r = recipient ?: run {
Toast.makeText(ctx, "Search for a recipient first", Toast.LENGTH_SHORT).show()
return
}
val amountStr = binding.etAmount.text?.toString()?.trim().orEmpty()
val amount = amountStr.toDoubleOrNull()
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
binding.tilAmount.error = null
val remarks = binding.etRemarks.text?.toString()?.trim().orEmpty()
val sourcePocketId = source.accountNumber // pocketId IS the accountNumber for M-Faisa accounts
binding.btnTransfer.isEnabled = false
(fragment.activity as? HomeActivity)?.setRefreshing(true)
fragment.viewLifecycleOwner.lifecycleScope.launch {
val refId = try {
withContext(Dispatchers.IO) { initiateWithRetry(source, sourcePocketId, r, amountStr, remarks) }
} catch (e: Exception) {
(fragment.activity as? HomeActivity)?.setRefreshing(false)
binding.btnTransfer.isEnabled = true
showError(e)
return@launch
}
(fragment.activity as? HomeActivity)?.setRefreshing(false)
// Server has now SMSed an OTP to the user's phone. Prompt them for it.
promptForOtp(source, r, amountStr, remarks, refId, errorMsg = null)
}
}
/** Whether a merchant QR is loaded — counts as a resolved recipient for the Send button. */
val hasQrMerchant: Boolean get() = qrInfo != null
/** Called when the source account changes away from M-Faisa (or the view tears down). */
fun clearState() {
recipient = null
lookupInFlight = false
}
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
fun clearQrMerchant() {
if (qrInfo == null) return
qrInfo = null
binding.tilAmount.isEnabled = true
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
binding.etAmount.setText("")
}
// ─── Merchant QR ─────────────────────────────────────────────────────────
/**
* Resolves an M-Faisa qrCodeId to a merchant, paints it in the "To" card, auto-selects an
* M-Faisa source if none is selected (or the current one is the wrong bank), and pre-fills
* the amount if the QR is dynamic.
*/
fun lookupQrMerchant(qrCodeId: String) {
val source = mfaisaSource() ?: run {
Toast.makeText(ctx, "No M-Faisa account available", Toast.LENGTH_SHORT).show()
return
}
val session = app.mfaisaSessionFor(source) ?: run {
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
// 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
host?.setRefreshing(true)
fragment.viewLifecycleOwner.lifecycleScope.launch {
val merchant = withContext(Dispatchers.IO) {
try {
MfaisaQrPayClient().fetchQrDetails(session, qrCodeId)
} catch (_: MfaisaSessionExpiredException) {
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
?: return@withContext null
try { MfaisaQrPayClient().fetchQrDetails(fresh, qrCodeId) }
catch (_: Exception) { null }
} catch (_: Exception) { null }
}
host?.setRefreshing(false)
if (merchant == null) {
Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
fragment.resetToFieldVisibility()
return@launch
}
qrInfo = merchant
// Static QRs (no preset amount) make sense to keep in Recents — the merchant is
// reusable. Dynamic QRs are one-off so we skip them, same rule as BML QR pay.
if (merchant.txnAmount.isNullOrBlank()) {
RecentsCache.save(ctx, RecentPick(
accountNumber = "mfaisaqr:${merchant.qrCodeId}",
displayName = merchant.merchantName,
subtitle = "M-Faisa merchant · ${merchant.merchantMsisdn}",
colorHex = "#ED1C24",
imageHash = null,
isProfileImage = false,
bank = "MFAISA"
))
}
// 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()
}
}
/**
* 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
* (2FARequired=NONE), so unlike [submit] there is no code to enter.
*/
fun submitQrPayment() {
val merchant = qrInfo ?: return
val src = currentSource() ?: run {
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
if (src.bank != "MFAISA") {
Toast.makeText(ctx, "Switch to an M-Faisa account to pay this QR", Toast.LENGTH_SHORT).show()
return
}
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
val amount = amountStr.toDoubleOrNull()
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
binding.tilAmount.error = null
val remarks = binding.etRemarks.text?.toString()?.trim().orEmpty()
val amountValue = "%.2f".format(amount)
val confirmView = fragment.buildTransferConfirmView(
amountCurrency = merchant.currencyCode,
amountValue = amountValue,
fromName = src.accountBriefName,
fromNumber = src.accountNumber,
fromDetail = "M-Faisa",
toName = merchant.merchantName,
toNumber = merchant.merchantMsisdn,
toDetail = "Ooredoo M-Faisa merchant"
)
fragment.showConfirmWithBiometric(
title = ctx.getString(R.string.transfer),
customView = confirmView,
biometricSubtitle = "${merchant.currencyCode} $amountValue${merchant.merchantName}",
onConfirmed = { dialog, frame ->
fragment.showProcessingInDialog(dialog, frame)
executeQrPayment(src, merchant, amountValue, remarks, dialog)
}
)
}
private fun executeQrPayment(
src: BankAccount,
merchant: MfaisaQrPayClient.QrMerchant,
amountStr: String,
remarks: String,
dialog: AlertDialog
) {
val loginId = src.loginTag.removePrefix("mfaisa_")
val initialSession = app.mfaisaSessionFor(src) ?: run {
dialog.dismiss()
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
// M-Faisa expects the user's MSISDN with the "960" country prefix (the session stores the
// bare 7-digit form). The pocket itself is identified by [BankAccount.accountNumber].
val sourceMdn = "960${initialSession.msisdn}"
binding.btnTransfer.isEnabled = false
fragment.viewLifecycleOwner.lifecycleScope.launch {
val outcome = withContext(Dispatchers.IO) {
val client = MfaisaQrPayClient()
try {
val refId = try {
client.initiatePurchase(initialSession, src.accountNumber, sourceMdn, merchant, amountStr, remarks)
} catch (_: MfaisaSessionExpiredException) {
val fresh = app.refreshMfaisaSession(loginId)
?: throw IllegalStateException("Could not refresh M-Faisa session")
client.initiatePurchase(fresh, src.accountNumber, "960${fresh.msisdn}", merchant, amountStr, remarks)
}
val confirmSession = app.mfaisaSessionFor(src) ?: initialSession
try {
client.confirmPurchase(confirmSession, refId)
} catch (_: MfaisaSessionExpiredException) {
val fresh = app.refreshMfaisaSession(loginId)
?: throw IllegalStateException("Could not refresh M-Faisa session")
client.confirmPurchase(fresh, refId)
}
Result.success(refId)
} catch (e: Exception) {
Result.failure<String>(e)
}
}
if (fragment.view == null) return@launch
outcome.fold(
onSuccess = { _ ->
val receipt = TransferReceiptData(
bank = "MFAISA",
amount = amountStr,
currency = merchant.currencyCode,
fromLabel = src.accountBriefName,
fromColorHex = "#ED1C24",
toLabel = merchant.merchantName,
toAccount = merchant.merchantMsisdn,
toBank = "Ooredoo M-Faisa",
remarks = remarks,
mfaisaTransactionType = "Merchant payment",
mfaisaFromName = src.accountBriefName,
mfaisaFromMsisdn = src.accountNumber,
mfaisaToMsisdn = merchant.merchantMsisdn,
mfaisaTimestamp = System.currentTimeMillis()
)
dialog.dismiss()
onTransferSuccess(receipt, null)
},
onFailure = { e ->
dialog.dismiss()
binding.btnTransfer.isEnabled = true
showError(e, fallback = "Payment failed")
}
)
}
}
// ─── Internal ────────────────────────────────────────────────────────────
/**
* The M-Faisa account to act on: the selected source when it is one, otherwise the first
* M-Faisa account available. The fallback is what lets a scanned merchant QR auto-switch
* the source away from another bank.
*/
private fun mfaisaSource(): BankAccount? =
currentSource()?.takeIf { it.bank == "MFAISA" }
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" }
private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) {
// 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 }
binding.tvToBankBic.text = r.msisdn
binding.tvToAccountDetails.text = "Ooredoo M-Faisa · MVR"
binding.tvToAccountDetails.visibility = View.VISIBLE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE
RecentsCache.save(ctx, RecentPick(
accountNumber = r.msisdn,
displayName = r.name.ifBlank { r.msisdn },
subtitle = "Ooredoo M-Faisa · ${r.msisdn}",
colorHex = "#ED1C24",
imageHash = null,
isProfileImage = false,
bank = "MFAISA"
))
}
/** Initiate with one automatic retry if the session has expired. */
private fun initiateWithRetry(
source: BankAccount,
sourcePocketId: String,
r: MfaisaTransferClient.Recipient,
amountStr: String,
remarks: String
): String {
val session = app.mfaisaSessionFor(source) ?: throw IllegalStateException("No M-Faisa session")
return try {
MfaisaTransferClient.forContext(ctx).initiateTransfer(session, sourcePocketId, r, amountStr, remarks)
} catch (_: MfaisaSessionExpiredException) {
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
?: throw IllegalStateException("Could not refresh M-Faisa session")
MfaisaTransferClient.forContext(ctx).initiateTransfer(fresh, sourcePocketId, r, amountStr, remarks)
}
}
private fun confirmWithRetry(source: BankAccount, refId: String, otpCode: String) {
val session = app.mfaisaSessionFor(source) ?: throw IllegalStateException("No M-Faisa session")
try {
MfaisaTransferClient.forContext(ctx).confirmTransfer(session, refId, otpCode)
} catch (_: MfaisaSessionExpiredException) {
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
?: throw IllegalStateException("Could not refresh M-Faisa session")
MfaisaTransferClient.forContext(ctx).confirmTransfer(fresh, refId, otpCode)
}
}
/**
* Shows the unified "confirm + enter OTP" dialog. Body is the standard transfer-confirm
* view (amount + from/to blocks via [TransferFragment.buildTransferConfirmView]) plus an
* OTP input. The Confirm button stays disabled until a 6-digit code is entered. Biometric
* gating + invalid-OTP re-prompt + session-refresh retry are all preserved.
*
* The displayed "code sent to" line uses the SOURCE M-Faisa login's MSISDN (where the SMS
* was actually sent) the old standalone OTP dialog mistakenly showed the recipient.
*/
private fun promptForOtp(
source: BankAccount,
r: MfaisaTransferClient.Recipient,
amountStr: String,
remarks: String,
refId: String,
errorMsg: String?
) {
val view = fragment.view ?: return
val dp = ctx.resources.displayMetrics.density
val colorMuted = MaterialColors.getColor(
view, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
val colorOutline = MaterialColors.getColor(
view, com.google.android.material.R.attr.colorOutlineVariant, Color.LTGRAY)
val amountValue = try { "%.2f".format(amountStr.toDouble()) } catch (_: Exception) { amountStr }
val confirmView = fragment.buildTransferConfirmView(
amountCurrency = "MVR",
amountValue = amountValue,
fromName = source.accountBriefName,
fromNumber = source.accountNumber,
fromDetail = "M-Faisa",
toName = r.name.ifBlank { r.msisdn },
toNumber = r.msisdn,
toDetail = "Ooredoo M-Faisa"
)
// The user's own M-Faisa MSISDN (where the SMS is sent). The session stores the bare
// 7 digits; prefix with 960 for display.
val userMsisdn = app.mfaisaSessionFor(source)?.msisdn
?.takeIf { it.isNotBlank() }
?.let { "960$it" }
?: "your registered number"
val otpHeader = TextView(ctx).apply {
text = "A 6-digit verification code has been sent to $userMsisdn"
textSize = 13f
setTextColor(colorMuted)
gravity = Gravity.CENTER
}
val otpInput = android.widget.EditText(ctx).apply {
hint = "Enter 6-digit code"
inputType = android.text.InputType.TYPE_CLASS_NUMBER
filters = arrayOf(android.text.InputFilter.LengthFilter(6))
textSize = 20f
gravity = Gravity.CENTER
letterSpacing = 0.3f
}
val errorView = errorMsg?.let {
TextView(ctx).apply {
text = it
textSize = 13f
setTextColor(Color.RED)
gravity = Gravity.CENTER
}
}
val divider = View(ctx).apply {
setBackgroundColor(colorOutline)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).apply {
topMargin = (8 * dp).toInt()
}
}
val otpSection = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
setPadding((20 * dp).toInt(), (12 * dp).toInt(), (20 * dp).toInt(), (4 * dp).toInt())
addView(otpHeader, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT))
addView(otpInput, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
topMargin = (8 * dp).toInt()
})
if (errorView != null) {
addView(errorView, LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
topMargin = (8 * dp).toInt()
})
}
}
val container = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
addView(confirmView)
addView(divider)
addView(otpSection)
}
// Hide any previously-open keyboard so the OTP field can claim focus cleanly.
val imm = ctx.getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
val dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.transfer)
.setView(container)
.setPositiveButton(R.string.transfer_confirm, null)
.setNegativeButton(R.string.cancel) { d, _ ->
d.dismiss()
binding.btnTransfer.isEnabled = true
}
.setCancelable(false)
.show()
val confirmBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
confirmBtn.isEnabled = false
otpInput.addTextChangedListener { text ->
confirmBtn.isEnabled = (text?.length ?: 0) == 6
}
val prefs = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false)
val canAuth = BiometricManager.from(ctx)
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
val runConfirm: () -> Unit = {
val otp = otpInput.text?.toString()?.trim().orEmpty()
dialog.dismiss()
(fragment.activity as? HomeActivity)?.setRefreshing(true)
fragment.viewLifecycleOwner.lifecycleScope.launch {
try {
withContext(Dispatchers.IO) { confirmWithRetry(source, refId, otp) }
(fragment.activity as? HomeActivity)?.setRefreshing(false)
val receipt = TransferReceiptData(
bank = "MFAISA",
amount = amountValue,
currency = "MVR",
fromLabel = source.accountBriefName,
fromColorHex = "#ED1C24",
toLabel = r.name.ifBlank { r.msisdn },
toAccount = r.msisdn,
toBank = "Ooredoo M-Faisa",
remarks = remarks,
mfaisaTransactionType = "Transfer to mobile",
mfaisaFromName = source.accountBriefName,
mfaisaFromMsisdn = source.accountNumber,
mfaisaToMsisdn = r.msisdn,
mfaisaTimestamp = System.currentTimeMillis()
)
onTransferSuccess(receipt, null)
} catch (e: MfaisaInvalidOtpException) {
(fragment.activity as? HomeActivity)?.setRefreshing(false)
// Server kept the referenceId alive — re-prompt without restarting initiate.
promptForOtp(source, r, amountStr, remarks, refId, e.message)
} catch (e: Exception) {
(fragment.activity as? HomeActivity)?.setRefreshing(false)
binding.btnTransfer.isEnabled = true
showError(e)
}
}
}
confirmBtn.setOnClickListener {
val otp = otpInput.text?.toString()?.trim().orEmpty()
if (otp.length != 6) { otpInput.error = "Enter 6 digits"; return@setOnClickListener }
if (biometricTransferConfirm && canAuth) {
val prompt = BiometricPrompt(fragment, ContextCompat.getMainExecutor(ctx),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
runConfirm()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (errorCode != BiometricPrompt.ERROR_CANCELED &&
errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
Toast.makeText(ctx, errString, Toast.LENGTH_SHORT).show()
}
}
override fun onAuthenticationFailed() { /* keep dialog open */ }
})
prompt.authenticate(
BiometricPrompt.PromptInfo.Builder()
.setTitle(ctx.getString(R.string.biometric_transfer_title))
.setSubtitle("MVR $amountValue${r.name.ifBlank { r.msisdn }}")
.setNegativeButtonText(ctx.getString(android.R.string.cancel))
.build()
)
} else {
runConfirm()
}
}
}
private fun showError(e: Throwable, fallback: String = "Transfer failed") {
val msg = when {
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
!e.message.isNullOrBlank() -> e.message!!
else -> fallback
}
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
}
}
@@ -0,0 +1,173 @@
package sh.sar.basedbank.ui.home.transfer
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.fragment.app.Fragment
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
import sh.sar.basedbank.api.mib.MibLookupException
import sh.sar.basedbank.api.mib.MibSession
import sh.sar.basedbank.api.mib.MibTransferClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.ui.home.TransferReceiptData
import sh.sar.basedbank.util.AccountInputParser
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.Totp
/**
* Owns the MIB-only parts of the Transfer screen: session resolution, IPS destination lookup,
* the transfer itself (profile switch + TOTP + `MibTransferClient`), and the profile/contact
* avatar API that every bank's "To" card borrows.
*
* Unlike [BmlTransferHandler] and [MfaisaTransferHandler] this one holds no view binding and
* paints nothing every method is either a session accessor or a blocking API call meant for
* `Dispatchers.IO`. [sh.sar.basedbank.ui.home.TransferFragment] keeps the coroutine plumbing and
* the view updates, so this handler can outlive a view recreation.
*/
class MibTransferHandler(
private val fragment: Fragment,
/** Reads the fragment's currently-selected source account. */
private val currentSource: () -> BankAccount?,
) {
private val app get() = fragment.requireActivity().application as BasedBankApp
private val ctx get() = fragment.requireContext()
/** The selected source's MIB session, falling back to any logged-in MIB session. */
val session: MibSession?
get() = currentSource()?.let { app.mibSessionFor(it) } ?: app.anyMibSession()
/** Any logged-in MIB session, ignoring which source is selected. */
val anySession: MibSession? get() = app.anyMibSession()
// ─── Destination lookup ──────────────────────────────────────────────────
/** Either a resolved destination or the message to show the user — never both. */
data class LookupOutcome(val info: MibIpsAccountInfo?, val error: String?)
/**
* IPS lookup for a destination account/alias. Blocking call from IO.
* A missing MIB session is reported as "account not found", same as a failed lookup.
*/
fun lookupDestination(accountNumber: String): LookupOutcome {
val sess = session ?: return LookupOutcome(null, notFound())
return try {
LookupOutcome(MibTransferClient().lookup(sess, accountNumber), null)
} catch (e: MibLookupException) {
LookupOutcome(null, e.message)
} catch (_: Exception) {
LookupOutcome(null, notFound())
}
}
/**
* Currency-only lookup, used to enrich a BML "verify MIB account" result that endpoint
* resolves the name but not the currency. Blocking; returns "" when it can't be determined.
*/
fun lookupCurrency(accountNumber: String): String {
val sess = session ?: return ""
return try { MibTransferClient().lookup(sess, accountNumber).currency }
catch (_: Exception) { "" }
}
private fun notFound() = ctx.getString(R.string.transfer_account_not_found)
// ─── Avatars ─────────────────────────────────────────────────────────────
/**
* Fetches and decodes a profile image (P41, [isProfile] true) or a saved contact's image.
* Blocking call from IO. Returns null on any failure, including a missing image.
*
* The session is passed in rather than read from [session] because callers differ: the "To"
* card uses the selected source's session, the account dropdown uses [anySession].
*/
fun fetchAvatar(session: MibSession, hash: String, isProfile: Boolean): Bitmap? = try {
val base64 = if (isProfile) {
app.anyMibFlow()?.fetchProfileImage(session, hash)
} else {
MibContactsClient().fetchProfileImageBase64(session, hash)
}
if (base64 == null) null else {
val bytes = Base64.decode(base64, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}
} catch (_: Exception) { null }
// ─── Transfer ────────────────────────────────────────────────────────────
/**
* Switches to the profile that owns [src], then runs the transfer with a freshly generated
* TOTP. Blocking call from IO. Returns `(ok, message, receipt)`; the message is
* "CONNECTIVITY" when the network was unreachable.
*/
fun doTransfer(
src: BankAccount,
destAccount: String,
destName: String,
destDisplay: String,
amount: String,
remarks: String,
bankName: String
): Triple<Boolean, String, TransferReceiptData?> {
val sess = session ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
val loginId = src.loginTag.removePrefix("mib_")
val otp = CredentialStore(ctx).loadMibCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) }
?: return Triple(false, "OTP unavailable", null)
val currencyCode = if (src.currencyName == "USD") "840" else "462"
val currency = if (src.currencyName == "USD") "USD" else "MVR"
val isDestMib = AccountInputParser.detect(destAccount) == AccountInputParser.InputType.MIB_ACCOUNT
val bankNo = if (isDestMib) 2 else 3
val toBank = when {
isDestMib -> "MIB"
else -> when (bankName.uppercase()) {
"MALBMVMV" -> "BML"
"MADVMVMV" -> "MIB"
else -> bankName.ifBlank { "LOCAL" }
}
}
return try {
// Switch to the profile that owns the source account
if (src.profileId.isNotBlank()) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = profiles.firstOrNull { it.profileId == src.profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
}
val result = MibTransferClient().transfer(
session = sess,
fromAccount = src.accountNumber,
toAccount = destAccount,
amount = amount,
currencyCode = currencyCode,
benefName = destName.ifBlank { "Recipient" },
bankNo = bankNo,
purpose = remarks,
otp = otp
)
if (result.success) {
val receipt = TransferReceiptData(
bank = "MIB",
amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0),
currency = currency,
fromLabel = src.accountBriefName,
fromColorHex = "#FE860E",
fromProfileImageHash = src.profileImageHash,
toLabel = destDisplay.ifBlank { destName },
toAccount = destAccount,
toBank = toBank,
remarks = remarks,
mibReferenceNo = result.trxId,
mibTransactionDate = result.date
)
Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt)
} else {
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
}
} catch (e: Exception) {
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
}
}
}
@@ -31,6 +31,10 @@ class BankSelectionFragment : Fragment() {
val args = android.os.Bundle().apply { putString("bankType", "FAHIPAY") }
findNavController().navigate(R.id.action_bankSelection_to_credentials_fahipay, args)
}
binding.cardOoredoo.setOnClickListener {
val args = android.os.Bundle().apply { putString("bankType", "OOREDOO") }
findNavController().navigate(R.id.action_bankSelection_to_credentials_ooredoo, args)
}
}
override fun onDestroyView() {
@@ -1,5 +1,6 @@
package sh.sar.basedbank.ui.login
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
@@ -13,11 +14,13 @@ import android.os.Looper
import android.text.Editable
import android.text.TextWatcher
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.util.OtpauthParser
import sh.sar.basedbank.util.Totp
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
@@ -27,6 +30,12 @@ import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.fahipay.FahipayAccountClient
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.fahipay.FahipaySession
import sh.sar.basedbank.api.mfaisa.MfaisaAccountClient
import sh.sar.basedbank.api.mfaisa.MfaisaInvalidPinException
import sh.sar.basedbank.api.mfaisa.MfaisaKycRequiredException
import sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow
import sh.sar.basedbank.api.mfaisa.MfaisaNotRegisteredException
import sh.sar.basedbank.api.mfaisa.MfaisaWalletNotReadyException
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.api.mib.MibProfileClient
@@ -34,6 +43,7 @@ import sh.sar.basedbank.util.AccountCache
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
import sh.sar.basedbank.ui.home.HomeActivity
import sh.sar.basedbank.ui.home.QrScannerActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder
class CredentialsFragment : Fragment() {
@@ -60,6 +70,25 @@ class CredentialsFragment : Fragment() {
private var bmlLoginId: String = ""
private var bmlAccumulatedAccounts = mutableListOf<BankAccount>()
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 entries = OtpauthParser.parse(raw)
when {
entries.isEmpty() -> Toast.makeText(requireContext(), "No OTP data found in QR", Toast.LENGTH_SHORT).show()
entries.size == 1 -> binding.etOtpSeed.setText(entries[0].secret)
else -> {
val labels = entries.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 -> binding.etOtpSeed.setText(entries[i].secret) }
.show()
}
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
return binding.root
@@ -75,7 +104,20 @@ class CredentialsFragment : Fragment() {
binding.ivBankLogo.setImageResource(R.drawable.fahipay_logo_long)
binding.tvSignInDesc.setText(R.string.fahipay_sign_in_desc)
binding.tilUsername.hint = getString(R.string.fahipay_id_card)
binding.tilOtpSeed.visibility = android.view.View.GONE
binding.rowOtpSeed.visibility = android.view.View.GONE
binding.etOtpSeed.isEnabled = false
binding.etOtpSeed.isFocusable = false
}
"OOREDOO" -> {
binding.ivBankLogo.setImageResource(R.drawable.ooredoo_logo_long)
binding.tvSignInDesc.setText(R.string.ooredoo_sign_in_desc)
binding.tilUsername.hint = getString(R.string.ooredoo_phone)
binding.etUsername.inputType = android.text.InputType.TYPE_CLASS_PHONE
binding.etPassword.inputType =
android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD
binding.etPassword.filters = arrayOf<android.text.InputFilter>(android.text.InputFilter.LengthFilter(4))
binding.tilPassword.hint = getString(R.string.ooredoo_pin)
binding.rowOtpSeed.visibility = android.view.View.GONE
binding.etOtpSeed.isEnabled = false
binding.etOtpSeed.isFocusable = false
}
@@ -83,6 +125,9 @@ class CredentialsFragment : Fragment() {
binding.btnLogin.isEnabled = false
binding.btnLogin.setOnClickListener { attemptLogin() }
binding.btnScanOtpSeed.setOnClickListener {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
binding.cardOtp.setOnClickListener {
val code = binding.tvOtpCode.text.toString().replace(" ", "")
@@ -107,7 +152,7 @@ class CredentialsFragment : Fragment() {
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
})
if (bankType != "FAHIPAY") {
if (bankType != "FAHIPAY" && bankType != "OOREDOO") {
binding.etOtpSeed.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) {
updateOtpDisplay()
@@ -121,7 +166,7 @@ class CredentialsFragment : Fragment() {
override fun onResume() {
super.onResume()
if (bankType != "FAHIPAY") otpHandler.post(otpRunnable)
if (bankType != "FAHIPAY" && bankType != "OOREDOO") otpHandler.post(otpRunnable)
}
override fun onPause() {
@@ -144,6 +189,7 @@ class CredentialsFragment : Fragment() {
val otpSeed = resolveOtpSeed(otpSeedRaw)
binding.btnLogin.isEnabled = when (bankType) {
"FAHIPAY" -> username.isNotEmpty() && password.isNotEmpty()
"OOREDOO" -> username.isNotEmpty() && password.length == 4
else -> username.isNotEmpty() && password.isNotEmpty() && otpSeed.isNotEmpty() && password != otpSeedRaw
}
}
@@ -178,6 +224,7 @@ class CredentialsFragment : Fragment() {
when (bankType) {
"BML" -> { attemptBmlLogin(); return }
"FAHIPAY" -> { attemptFahipayLogin(); return }
"OOREDOO" -> { attemptMfaisaLogin(); return }
}
val username = binding.etUsername.text.toString().trim()
@@ -385,6 +432,97 @@ class CredentialsFragment : Fragment() {
startActivity(intent)
}
private fun attemptMfaisaLogin() {
val msisdn = binding.etUsername.text.toString().trim()
val pin = binding.etPassword.text.toString()
if (msisdn.isEmpty() || pin.length != 4) {
binding.tvError.text = "Please enter your phone number and 4-digit mPIN"
binding.tvError.visibility = View.VISIBLE
return
}
binding.tvError.visibility = View.GONE
binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false
binding.etUsername.isEnabled = false
binding.etPassword.isEnabled = false
val store = CredentialStore(requireContext())
val flow = MfaisaLoginFlow(requireContext())
val loginTag = "mfaisa_$msisdn"
viewLifecycleOwner.lifecycleScope.launch {
try {
withContext(Dispatchers.IO) { flow.fetchSubscriber(msisdn) }
val result = withContext(Dispatchers.IO) { flow.doMobileLogin(msisdn, pin) }
val accounts = MfaisaAccountClient.buildAccounts(result, loginTag)
store.saveMfaisaCredentials(msisdn, msisdn, pin)
store.saveMfaisaUserProfile(
msisdn,
CredentialStore.MfaisaUserProfile(
name = result.profile.name,
email = result.profile.email,
mdnId = result.profile.mdnId,
subscriberId = result.profile.subscriberId,
walletId = result.profile.walletId,
roleId = result.profile.roleId,
offerId = result.profile.offerId
)
)
AccountCache.saveMfaisa(requireContext(), msisdn, accounts)
val app = requireActivity().application as BasedBankApp
app.mfaisaSessions[msisdn] = result.session
app.mfaisaAccounts = app.mfaisaAccounts.filter { it.loginTag != loginTag } + accounts
app.accounts = app.accounts.filter { it.loginTag != loginTag } + accounts
app.isUnlocked = true
val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
} catch (e: MfaisaNotRegisteredException) {
MaterialAlertDialogBuilder(requireContext())
.setTitle("User not registered")
.setMessage("Please use the Ooredoo SuperApp to register your M-Faisa wallet and complete KYC, then come back to Thijooree.")
.setPositiveButton("OK", null)
.show()
binding.tvError.visibility = View.GONE
} catch (e: MfaisaKycRequiredException) {
MaterialAlertDialogBuilder(requireContext())
.setTitle("KYC incomplete")
.setMessage("Your M-Faisa wallet needs Full KYC. Please complete KYC in the Ooredoo SuperApp, then come back to Thijooree.")
.setPositiveButton("OK", null)
.show()
binding.tvError.visibility = View.GONE
} catch (e: MfaisaWalletNotReadyException) {
binding.tvError.text = e.message
binding.tvError.visibility = View.VISIBLE
} catch (e: MfaisaInvalidPinException) {
val message = if (e.lastAttempt)
"${e.message}\n\nOne more wrong mPIN will lock your account."
else
e.message
binding.tvError.text = message ?: "Incorrect mPIN"
binding.tvError.visibility = View.VISIBLE
// Re-enable PIN input only — leave phone number locked
binding.etPassword.isEnabled = true
binding.etPassword.setText("")
binding.etPassword.requestFocus()
} catch (e: Exception) {
binding.tvError.text = e.message ?: "Login failed"
binding.tvError.visibility = View.VISIBLE
binding.etUsername.isEnabled = true
binding.etPassword.isEnabled = true
} finally {
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
// After PIN error, keep phone disabled; for any other resolution above we already restored as needed.
}
}
}
private fun attemptFahipayLogin() {
if (fahipayAwaitingTotp) {
submitFahipayTotp()
@@ -11,6 +11,7 @@ object AccountCache {
private const val KEY_MIB = "mib_accounts"
private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
private fun fahipayKey(loginId: String) = "fahipay_accounts_$loginId"
private fun mfaisaKey(loginId: String) = "mfaisa_accounts_$loginId"
fun save(context: Context, accounts: List<BankAccount>) {
val arr = JSONArray()
@@ -150,6 +151,62 @@ object AccountCache {
fun loadFahipay(context: Context, loginIds: List<String>): List<BankAccount> =
loginIds.flatMap { loadFahipay(context, it) }
fun saveMfaisa(context: Context, loginId: String, accounts: List<BankAccount>) {
val arr = JSONArray()
for (acc in accounts) {
arr.put(JSONObject().apply {
put("profileName", acc.profileName)
put("profileType", acc.profileType)
put("accountNumber", acc.accountNumber)
put("accountBriefName", acc.accountBriefName)
put("currencyName", acc.currencyName)
put("accountTypeName", acc.accountTypeName)
put("availableBalance", acc.availableBalance)
put("currentBalance", acc.currentBalance)
put("blockedAmount", acc.blockedAmount)
put("mvrBalance", acc.mvrBalance)
put("statusDesc", acc.statusDesc)
put("loginTag", acc.loginTag)
put("profileId", acc.profileId)
put("internalId", acc.internalId)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(mfaisaKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
}
fun loadMfaisa(context: Context, loginId: String): List<BankAccount> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(mfaisaKey(loginId), null) ?: return emptyList()
return try {
val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
BankAccount(
bank = "MFAISA",
profileName = o.optString("profileName"),
profileType = o.optString("profileType"),
accountNumber = o.optString("accountNumber"),
accountBriefName = o.optString("accountBriefName"),
currencyName = o.optString("currencyName"),
accountTypeName = o.optString("accountTypeName"),
availableBalance = o.optString("availableBalance"),
currentBalance = o.optString("currentBalance"),
blockedAmount = o.optString("blockedAmount"),
mvrBalance = o.optString("mvrBalance"),
statusDesc = o.optString("statusDesc"),
profileImageHash = null,
loginTag = o.optString("loginTag"),
profileId = o.optString("profileId", ""),
internalId = o.optString("internalId", "")
)
}
} catch (_: Exception) { emptyList() }
}
fun loadMfaisa(context: Context, loginIds: List<String>): List<BankAccount> =
loginIds.flatMap { loadMfaisa(context, it) }
fun clear(context: Context) {
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
}
@@ -7,7 +7,7 @@ package sh.sar.basedbank.util
data class AccountHistoryDisplay(
val name: String,
val number: String,
val bankPill: String?, // "BML", "FP", null for MIB (no pill)
val bankPill: String?, // "BML", "FP", "MIB" — bank label shown on the pill
val typeLabel: String, // e.g. "Savings", "Current", "Visa Platinum"
val availableBalance: String, // formatted "CCY amount"
val workingBalance: String, // ledger/working balance — formatted "CCY amount"
@@ -3,6 +3,7 @@ package sh.sar.basedbank.util
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.util.bmlapi.BmlHistoryParser
import sh.sar.basedbank.util.fahipayapi.FahipayHistoryParser
import sh.sar.basedbank.util.mfaisaapi.MfaisaHistoryParser
import sh.sar.basedbank.util.mibapi.MibHistoryParser
object AccountHistoryParser {
@@ -11,6 +12,7 @@ object AccountHistoryParser {
"BML" -> BmlHistoryParser.displayData(account)
"FAHIPAY" -> FahipayHistoryParser.displayData(account)
"MIB" -> MibHistoryParser.displayData(account)
"MFAISA" -> MfaisaHistoryParser.displayData(account)
else -> null
}
}
@@ -3,6 +3,7 @@ package sh.sar.basedbank.util
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser
import sh.sar.basedbank.util.mfaisaapi.MfaisaAccountParser
import sh.sar.basedbank.util.mibapi.MibAccountParser
object AccountListParser {
@@ -11,6 +12,7 @@ object AccountListParser {
"BML" -> BmlDashboardParser.displayData(account)
"FAHIPAY" -> FahipayAccountParser.displayData(account)
"MIB" -> MibAccountParser.displayData(account)
"MFAISA" -> MfaisaAccountParser.displayData(account)
else -> null
}
}
@@ -23,6 +23,7 @@ object CardsCache {
put("phoneNumber", c.phoneNumber)
put("cardHolderName", c.cardHolderName)
put("loginTag", c.loginTag)
put("profileId", c.profileId)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -45,7 +46,8 @@ object CardsCache {
customerId = o.optString("customerId"),
phoneNumber = o.optString("phoneNumber"),
cardHolderName = o.optString("cardHolderName"),
loginTag = o.optString("loginTag")
loginTag = o.optString("loginTag"),
profileId = o.optString("profileId")
)
}
} catch (_: Exception) { emptyList() }
@@ -21,6 +21,7 @@ class CredentialStore(context: Context) {
data class MibCredentials(val username: String, val passwordHash: String, val otpSeed: String)
data class BmlCredentials(val username: String, val password: String, val otpSeed: String)
data class FahipayCredentials(val idCard: String, val password: String)
data class MfaisaCredentials(val msisdn: String, val pin: String)
// ── MIB login credentials (multi-login, keyed by loginId = username) ─────
@@ -460,6 +461,110 @@ class CredentialStore(context: Context) {
editor.apply()
}
// ── M-Faisa login credentials (multi-login, keyed by loginId = msisdn) ───
fun getMfaisaLoginIds(): List<String> {
val json = prefs.getString("mfaisa_login_ids", null) ?: return emptyList()
return try {
val arr = org.json.JSONArray(json)
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) { emptyList() }
}
fun hasMfaisaCredentials(): Boolean = getMfaisaLoginIds().isNotEmpty()
private fun addMfaisaLoginId(loginId: String) {
val ids = getMfaisaLoginIds().toMutableList()
if (loginId !in ids) {
ids.add(loginId)
prefs.edit().putString("mfaisa_login_ids", org.json.JSONArray(ids).toString()).apply()
}
}
private fun removeMfaisaLoginId(loginId: String) {
val ids = getMfaisaLoginIds().toMutableList()
if (ids.remove(loginId))
prefs.edit().putString("mfaisa_login_ids", org.json.JSONArray(ids).toString()).apply()
}
fun saveMfaisaCredentials(loginId: String, msisdn: String, pin: String) {
addMfaisaLoginId(loginId)
val key = getOrCreateKey()
prefs.edit()
.putString("mfaisa_${loginId}_enc_msisdn", encrypt(msisdn, key))
.putString("mfaisa_${loginId}_enc_pin", encrypt(pin, key))
.apply()
}
fun loadMfaisaCredentials(loginId: String): MfaisaCredentials? {
val key = getOrCreateKey()
val encMsisdn = prefs.getString("mfaisa_${loginId}_enc_msisdn", null) ?: return null
val encPin = prefs.getString("mfaisa_${loginId}_enc_pin", null) ?: return null
return try {
MfaisaCredentials(decrypt(encMsisdn, key), decrypt(encPin, key))
} catch (_: Exception) { null }
}
fun clearMfaisaCredentials(loginId: String) {
removeMfaisaLoginId(loginId)
prefs.edit()
.remove("mfaisa_${loginId}_enc_msisdn")
.remove("mfaisa_${loginId}_enc_pin")
.remove("mfaisa_${loginId}_enc_profile")
.remove("mfaisa_${loginId}_hidden_pockets")
.apply()
}
/** Pocket-level hide flags (parallels [getHiddenBmlProfileIds]). Keyed by pocket ID. */
fun getHiddenMfaisaPocketIds(loginId: String): Set<String> =
prefs.getStringSet("mfaisa_${loginId}_hidden_pockets", emptySet()) ?: emptySet()
fun setHiddenMfaisaPocketIds(loginId: String, ids: Set<String>) =
prefs.edit().putStringSet("mfaisa_${loginId}_hidden_pockets", ids).apply()
// ── M-Faisa user profile (per loginId) ────────────────────────────────────
data class MfaisaUserProfile(
val name: String,
val email: String,
val mdnId: String,
val subscriberId: String,
val walletId: String,
val roleId: String,
val offerId: String
)
fun saveMfaisaUserProfile(loginId: String, p: MfaisaUserProfile) {
val json = org.json.JSONObject().apply {
put("name", p.name)
put("email", p.email)
put("mdnId", p.mdnId)
put("subscriberId", p.subscriberId)
put("walletId", p.walletId)
put("roleId", p.roleId)
put("offerId", p.offerId)
}.toString()
val key = getOrCreateKey()
prefs.edit().putString("mfaisa_${loginId}_enc_profile", encrypt(json, key)).apply()
}
fun loadMfaisaUserProfile(loginId: String): MfaisaUserProfile? {
val key = getOrCreateKey()
val enc = prefs.getString("mfaisa_${loginId}_enc_profile", null) ?: return null
return try {
val o = org.json.JSONObject(decrypt(enc, key))
MfaisaUserProfile(
name = o.optString("name"),
email = o.optString("email"),
mdnId = o.optString("mdnId"),
subscriberId = o.optString("subscriberId"),
walletId = o.optString("walletId"),
roleId = o.optString("roleId"),
offerId = o.optString("offerId")
)
} catch (_: Exception) { null }
}
// ── Security credential (PIN / pattern hash) ──────────────────────────────
/**
@@ -6,6 +6,7 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.bml.BmlHistoryClient
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
import sh.sar.basedbank.api.mfaisa.MfaisaHistoryClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibHistoryClient
import sh.sar.basedbank.api.models.BankTransaction
@@ -24,6 +25,8 @@ class HistoryFetcher(private val account: BankAccount) {
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
private val isBmlLoan get() = account.profileType == "BML_LOAN"
private val isFahipay get() = account.bank == "FAHIPAY"
private val isMfaisa get() = account.bank == "MFAISA"
private val isMfaisaPaypal get() = account.profileType == "MFAISA_PAYPAL"
// MIB pagination
private var mibNextStart = 1
@@ -35,17 +38,39 @@ class HistoryFetcher(private val account: BankAccount) {
// BML card pagination (month-based)
private var cardMonthOffset = 0
private var pendingCardOutstanding: List<BankTransaction>? = null
private var pendingCardUnbilled: List<BankTransaction>? = null
/**
* Returns and clears the card outstanding + unbilled lists captured on the first card
* fetch. Each list is only ever returned once.
*/
fun takeCardPendingSections(): Pair<List<BankTransaction>, List<BankTransaction>>? {
val o = pendingCardOutstanding
val u = pendingCardUnbilled
if (o == null && u == null) return null
pendingCardOutstanding = null
pendingCardUnbilled = null
return Pair(o ?: emptyList(), u ?: emptyList())
}
// Fahipay pagination
private var fahipayNextStart = 0
private var fahipayTotal = -1
// M-Faisa pagination — the server doesn't return a "total" field, so we infer "more pages exist"
// from whether the last page was full-sized.
private var mfaisaNextPage = 1
private var mfaisaHasMore = true
fun hasMore(): Boolean = when {
isBmlLoan -> false
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
isBmlCard -> cardMonthOffset < 3
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
isBmlLoan -> false
isMfaisaPaypal -> false // PayPal pockets have no known history endpoint
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
isBmlCard -> cardMonthOffset < 3
isMfaisa -> mfaisaHasMore
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
}
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<BankTransaction> = when {
@@ -53,9 +78,36 @@ class HistoryFetcher(private val account: BankAccount) {
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
isMfaisa -> withContext(Dispatchers.IO) { fetchMfaisa(app) }
else -> withContext(Dispatchers.IO) { fetchBmlCasa(app) }
}
private fun fetchMfaisa(app: BasedBankApp): List<BankTransaction> {
val loginId = account.loginTag.removePrefix("mfaisa_")
var session = app.mfaisaSessionFor(account) ?: return emptyList()
val page = try {
MfaisaHistoryClient().fetchHistory(
session = session,
accountNumber = account.accountNumber,
accountDisplayName = account.accountBriefName,
pageNo = mfaisaNextPage,
recordSize = 70
)
} catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) {
session = app.refreshMfaisaSession(loginId) ?: return emptyList()
MfaisaHistoryClient().fetchHistory(
session = session,
accountNumber = account.accountNumber,
accountDisplayName = account.accountBriefName,
pageNo = mfaisaNextPage,
recordSize = 70
)
}
mfaisaHasMore = page.hasMore
mfaisaNextPage++
return page.transactions
}
private fun fetchFahipay(app: BasedBankApp): List<BankTransaction> {
val session = app.fahipaySessionFor(account) ?: return emptyList()
val (list, total) = FahipayHistoryClient().fetchHistory(
@@ -90,16 +142,22 @@ class HistoryFetcher(private val account: BankAccount) {
private fun fetchBmlCard(app: BasedBankApp): List<BankTransaction> {
val session = app.bmlSessionFor(account) ?: return emptyList()
val cal = Calendar.getInstance()
val isFirstFetch = cardMonthOffset == 0
cal.add(Calendar.MONTH, -cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
cardMonthOffset++
return BmlHistoryClient().fetchCardHistory(
val result = BmlHistoryClient().fetchCardHistory(
session = session,
cardId = account.internalId,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
month = month
)
if (isFirstFetch) {
pendingCardOutstanding = result.outstanding
pendingCardUnbilled = result.unbilled
}
return result.statement
}
private fun fetchBmlCasa(app: BasedBankApp): List<BankTransaction> {
@@ -0,0 +1,53 @@
package sh.sar.basedbank.util
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.nfc.NfcAdapter
import android.nfc.cardemulation.CardEmulation
import android.provider.Settings
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.R
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
object NfcPaymentUtil {
fun checkAndProceed(context: Context, onReady: () -> Unit) {
val nfcAdapter = NfcAdapter.getDefaultAdapter(context) ?: run {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.nfc_unsupported_title)
.setMessage(R.string.nfc_unsupported_message)
.setPositiveButton(android.R.string.ok, null)
.show()
return
}
if (!nfcAdapter.isEnabled) {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.nfc_disabled_title)
.setMessage(R.string.nfc_disabled_message)
.setPositiveButton(R.string.nfc_open_settings) { _, _ ->
context.startActivity(Intent(Settings.ACTION_NFC_SETTINGS))
}
.setNegativeButton(R.string.cancel, null)
.show()
return
}
val cardEmulation = CardEmulation.getInstance(nfcAdapter)
val componentName = ComponentName(context, BmlHostCardEmulatorService::class.java)
if (!cardEmulation.isDefaultServiceForCategory(componentName, CardEmulation.CATEGORY_PAYMENT)) {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.nfc_not_default_title)
.setMessage(context.getString(R.string.nfc_not_default_message,
context.applicationInfo.loadLabel(context.packageManager)))
.setPositiveButton(R.string.nfc_payment_open_settings) { _, _ ->
context.startActivity(Intent(Settings.ACTION_NFC_PAYMENT_SETTINGS))
}
.setNegativeButton(R.string.cancel, null)
.show()
return
}
onReady()
}
}
@@ -0,0 +1,116 @@
package sh.sar.basedbank.util
import android.net.Uri
import android.util.Base64
data class OtpEntry(val name: String, val issuer: String, val secret: String)
object OtpauthParser {
fun parse(raw: String): List<OtpEntry> = when {
raw.startsWith("otpauth-migration://") -> parseMigration(raw)
raw.startsWith("otpauth://") -> parseStandard(raw)?.let { listOf(it) } ?: emptyList()
else -> emptyList()
}
private fun parseStandard(raw: String): OtpEntry? {
val uri = Uri.parse(raw)
val secret = uri.getQueryParameter("secret") ?: return null
val issuer = uri.getQueryParameter("issuer") ?: ""
val label = uri.path?.trimStart('/') ?: ""
val name = if (':' in label) label.substringAfter(':').trim() else label
return OtpEntry(name, issuer, secret.uppercase())
}
private fun parseMigration(raw: String): List<OtpEntry> {
val data = Uri.parse(raw).getQueryParameter("data") ?: return emptyList()
val bytes = try { Base64.decode(data, Base64.DEFAULT) } catch (_: Exception) { return emptyList() }
val reader = ProtobufReader(bytes)
val entries = mutableListOf<OtpEntry>()
while (reader.hasMore()) {
val tag = reader.readVarint().toInt()
val fieldNum = tag ushr 3
val wireType = tag and 0x7
if (fieldNum == 1 && wireType == 2) {
parseOtpParameters(reader.readBytes())?.let { entries.add(it) }
} else {
reader.skip(wireType)
}
}
return entries
}
private fun parseOtpParameters(bytes: ByteArray): OtpEntry? {
val reader = ProtobufReader(bytes)
var secret: ByteArray? = null
var name = ""
var issuer = ""
var type = 2 // default to TOTP
while (reader.hasMore()) {
val tag = reader.readVarint().toInt()
val fieldNum = tag ushr 3
val wireType = tag and 0x7
when (fieldNum) {
1 -> secret = reader.readBytes()
2 -> name = String(reader.readBytes(), Charsets.UTF_8)
3 -> issuer = String(reader.readBytes(), Charsets.UTF_8)
6 -> type = reader.readVarint().toInt()
else -> reader.skip(wireType)
}
}
if (type == 1) return null // skip HOTP
val secretBase32 = base32Encode(secret ?: return null)
return OtpEntry(name, issuer, secretBase32)
}
private fun base32Encode(bytes: ByteArray): String {
val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
val sb = StringBuilder()
var buffer = 0
var bitsLeft = 0
for (b in bytes) {
buffer = (buffer shl 8) or (b.toInt() and 0xFF)
bitsLeft += 8
while (bitsLeft >= 5) {
bitsLeft -= 5
sb.append(alphabet[(buffer ushr bitsLeft) and 0x1F])
}
}
if (bitsLeft > 0) sb.append(alphabet[(buffer shl (5 - bitsLeft)) and 0x1F])
return sb.toString()
}
private class ProtobufReader(private val bytes: ByteArray) {
private var pos = 0
fun hasMore() = pos < bytes.size
fun readVarint(): Long {
var result = 0L
var shift = 0
while (pos < bytes.size) {
val b = bytes[pos++].toInt() and 0xFF
result = result or ((b and 0x7F).toLong() shl shift)
if (b and 0x80 == 0) break
shift += 7
}
return result
}
fun readBytes(): ByteArray {
val len = readVarint().toInt()
val data = bytes.copyOfRange(pos, pos + len)
pos += len
return data
}
fun skip(wireType: Int) {
when (wireType) {
0 -> readVarint()
1 -> pos += 8
2 -> readBytes()
5 -> pos += 4
}
}
}
}
@@ -9,22 +9,58 @@ data class PaymvQrData(
object PaymvQrParser {
private const val BML_GATEWAY_PREFIX = "https://pay.bml.com.mv/app/"
private const val BML_EBANKING_PREFIX = "https://ebanking.bankofmaldives.com.mv/qrpay/"
private const val BML_POS_DOMAIN = "mv.com.bml.qtr"
/**
* Returns the BML gateway URL if [raw] is or contains one, otherwise null.
* Handles both plain URL QRs and combined EMV QRs (e.g. Fahipay+BML card QR).
* For combined EMV QRs the URL is parsed from TLV (root tag 35 sub-tag 20 sub-sub-tag 01)
* rather than via regex, to avoid greedily consuming subsequent EMV tag bytes.
* Returns the value to hand to the BML QR payment flow, or null when [raw] is not a BML QR.
*
* Three shapes exist:
* - plain URL QRs the QR text is the gateway short link or an ebanking `qrpay` link;
* - combined EMV QRs (e.g. Fahipay+BML card QRs), which carry the full gateway URL in TLV at
* `35` `20` `01`;
* - BML POS QRs (supplementary data domain `mv.com.bml.qtr`), where the same TLV path holds a
* bare reference such as `02:<32 hex>` rather than a URL. Those return the whole EMV payload,
* which [bmlPayRequestKey] then reduces back to the reference the lookup wants.
*
* The TLV is walked rather than regex-matched so a URL cannot greedily swallow the EMV tags
* that follow it.
*/
fun extractBmlGatewayUrl(raw: String): String? {
if (raw.startsWith("https://pay.bml.com.mv/app/")) return raw
return try {
val root = parseTlv(raw)
val bmlMerchantInfo = root["35"]?.let { parseTlv(it) } ?: return null
val inner = bmlMerchantInfo["20"]?.let { parseTlv(it) } ?: return null
inner["01"]?.takeIf { it.startsWith("https://pay.bml.com.mv/app/") }
} catch (_: Exception) {
null
}
fun bmlQrPayTarget(raw: String): String? {
if (raw.startsWith(BML_GATEWAY_PREFIX) || raw.startsWith(BML_EBANKING_PREFIX)) return raw
val ref = bmlQrReference(raw) ?: return null
if (ref.startsWith("https://")) return ref.takeIf { it.startsWith(BML_GATEWAY_PREFIX) }
// A bare reference only means a BML POS QR when the supplementary domain says so; anything
// else keeps falling through to the PayMV parse, as it did before POS QRs existed.
return raw.takeIf { supplementaryDomain(it)?.startsWith(BML_POS_DOMAIN) == true }
}
/** TLV `80` → `00` — the supplementary data domain (`mv.favara.mpqr`, `mv.com.bml.qtr`, …). */
private fun supplementaryDomain(raw: String): String? = try {
parseTlv(raw)["80"]?.let { parseTlv(it) }?.get("00")
} catch (_: Exception) {
null
}
/** TLV `35` → `20` → `01` — the BML payment reference carried inside an EMV QR. */
private fun bmlQrReference(raw: String): String? = try {
val merchantInfo = parseTlv(raw)["35"]?.let { parseTlv(it) }
val inner = merchantInfo?.get("20")?.let { parseTlv(it) }
inner?.get("01")?.takeIf { it.isNotBlank() }
} catch (_: Exception) {
null
}
/**
* The `payrequest` lookup key for [target] (a value returned by [bmlQrPayTarget]), to be
* Base64-encoded into the URL. URL QRs resolve under the URL itself; POS QRs resolve under the
* bare `35` `20` `01` reference (the whole EMV payload is rejected with code 112,
* "Unsupported payment link").
*/
fun bmlPayRequestKey(target: String): String {
if (target.startsWith("https://")) return target
return bmlQrReference(target) ?: target
}
fun parse(raw: String): PaymvQrData? {
@@ -43,7 +43,12 @@ object ReceiptStore {
bmlFromName = o.optString("bmlFromName"),
bmlReference = o.optString("bmlReference"),
bmlTimestamp = o.optString("bmlTimestamp"),
bmlMessage = o.optString("bmlMessage")
bmlMessage = o.optString("bmlMessage"),
mfaisaTransactionType = o.optString("mfaisaTransactionType"),
mfaisaFromName = o.optString("mfaisaFromName"),
mfaisaFromMsisdn = o.optString("mfaisaFromMsisdn"),
mfaisaToMsisdn = o.optString("mfaisaToMsisdn"),
mfaisaTimestamp = o.optLong("mfaisaTimestamp", 0L)
),
savedAt = o.optLong("savedAt", 0L)
)
@@ -75,6 +80,11 @@ object ReceiptStore {
put("bmlReference", d.bmlReference)
put("bmlTimestamp", d.bmlTimestamp)
put("bmlMessage", d.bmlMessage)
put("mfaisaTransactionType", d.mfaisaTransactionType)
put("mfaisaFromName", d.mfaisaFromName)
put("mfaisaFromMsisdn", d.mfaisaFromMsisdn)
put("mfaisaToMsisdn", d.mfaisaToMsisdn)
put("mfaisaTimestamp", d.mfaisaTimestamp)
put("savedAt", ts)
})
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(arr.toString()))
@@ -10,7 +10,10 @@ data class RecentPick(
val subtitle: String,
val colorHex: String,
val imageHash: String?,
val isProfileImage: Boolean
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
)
object RecentsCache {
@@ -34,6 +37,7 @@ object RecentsCache {
put("colorHex", r.colorHex)
if (r.imageHash != null) put("imageHash", r.imageHash)
put("isProfileImage", r.isProfileImage)
if (r.bank != null) put("bank", r.bank)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -51,6 +55,7 @@ object RecentsCache {
put("colorHex", r.colorHex)
if (r.imageHash != null) put("imageHash", r.imageHash)
put("isProfileImage", r.isProfileImage)
if (r.bank != null) put("bank", r.bank)
})
}
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -75,7 +80,8 @@ object RecentsCache {
subtitle = o.getString("subtitle"),
colorHex = o.getString("colorHex"),
imageHash = o.optString("imageHash").takeIf { it.isNotBlank() },
isProfileImage = o.optBoolean("isProfileImage", false)
isProfileImage = o.optBoolean("isProfileImage", false),
bank = o.optString("bank").takeIf { it.isNotBlank() }
)
}
} catch (_: Exception) {
@@ -36,9 +36,9 @@ object BmlCardParser {
"C8902", "C8907", "C8909", "C8912", "C8992", "C8996", "C8997", "C8982", "C8983" -> "cards/bml/master_islamic.png"
"C8101" -> "cards/bml/master_masveriyaa.png"
"C8102" -> "cards/bml/master_odiveriyaa.png"
"C8010", "C8011" -> "cards/bml/master_platinum.png"
"C8010", "C8011", "C8033" -> "cards/bml/master_platinum.png"
"C8040", "C8044" -> "cards/bml/master_world.png"
"C8030", "C8033" -> "cards/bml/master_business_debit.png"
"C8030" -> "cards/bml/master_business_debit.png"
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png"
"C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
"C8905", "C8995" -> "cards/bml/visa_credit.png"
@@ -0,0 +1,14 @@
package sh.sar.basedbank.util.mfaisaapi
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.util.AccountListDisplay
object MfaisaAccountParser {
fun displayData(account: BankAccount) = AccountListDisplay(
name = account.accountBriefName,
number = account.accountNumber,
typeLabel = account.accountTypeName,
balance = "${account.currencyName} ${account.availableBalance}"
)
}
@@ -0,0 +1,17 @@
package sh.sar.basedbank.util.mfaisaapi
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.util.AccountHistoryDisplay
object MfaisaHistoryParser {
fun displayData(account: BankAccount) = AccountHistoryDisplay(
name = account.accountBriefName,
number = account.accountNumber,
bankPill = if (account.profileType == "MFAISA_PAYPAL") "PP" else "MF",
typeLabel = account.accountTypeName,
availableBalance = "${account.currencyName} ${account.availableBalance}",
workingBalance = "${account.currencyName} ${account.currentBalance}",
blockedBalance = null
)
}
@@ -10,7 +10,7 @@ object MibHistoryParser {
return AccountHistoryDisplay(
name = account.accountBriefName,
number = account.accountNumber,
bankPill = null, // MIB has no bank pill
bankPill = "MIB",
typeLabel = MibAccountParser.productLabel(account.accountTypeName),
availableBalance = "${account.currencyName} ${account.availableBalance}",
workingBalance = "${account.currencyName} ${account.currentBalance}",
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Gray-to-white vertical gradient at the bottom of the receipt, below the zigzag tear. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="90"
android:startColor="#FFFFFF"
android:endColor="#E5E6E7" />
</shape>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- White-to-gray vertical gradient leading into the zigzag tear at the top of the receipt body. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="270"
android:startColor="#FFFFFF"
android:endColor="#E5E6E7" />
</shape>
+2 -2
View File
@@ -5,9 +5,9 @@
android:viewportWidth="24"
android:viewportHeight="24">
<!-- Bell body (white) -->
<!-- Bell body -->
<path
android:fillColor="@android:color/white"
android:fillColor="?attr/colorControlNormal"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07-1.63,-5.64-4.5,-6.32V4c0,-0.83-0.67,-1.5-1.5,-1.5s-1.5,0.67-1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"/>
<!-- Unread notification dot (red) -->
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07-1.63,-5.64-4.5,-6.32V4c0,-0.83-0.67,-1.5-1.5,-1.5s-1.5,0.67-1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"/>
</vector>
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z"/>
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Green check-in-circle used on the m-faisaa receipt next to the total amount. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="25dp"
android:height="25dp"
android:viewportWidth="25"
android:viewportHeight="25">
<path
android:fillColor="#00000000"
android:pathData="M22.917,11.541V12.5C22.916,14.746 22.188,16.932 20.843,18.731C19.498,20.53 17.608,21.846 15.454,22.483C13.3,23.12 10.997,23.043 8.89,22.265C6.783,21.486 4.984,20.048 3.762,18.163C2.539,16.279 1.958,14.05 2.106,11.808C2.254,9.567 3.122,7.433 4.582,5.726C6.041,4.018 8.013,2.828 10.205,2.333C12.396,1.838 14.688,2.065 16.74,2.979M22.917,4.166L12.5,14.594L9.375,11.469"
android:strokeColor="#B0E020"
android:strokeWidth="1.8"
android:strokeLineCap="round"
android:strokeLineJoin="round" />
</vector>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
M-Faisaa logo with text. Recolored from the decompiled original (white fill,
intended to be tinted by parent) to the brand red so it can be used directly.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="94dp"
android:height="123dp"
android:viewportWidth="94"
android:viewportHeight="123">
<group>
<clip-path android:pathData="M0.09,0h93.82v122.17h-93.82z" />
<path android:fillColor="#ED1C24" android:pathData="M62.06,49.1H61.96C61.75,49.09 61.54,49.04 61.34,48.94C61.15,48.85 60.97,48.72 60.83,48.56C60.69,48.39 60.58,48.21 60.51,48C60.43,47.8 60.4,47.58 60.42,47.37C60.98,40.13 62.75,33.04 65.64,26.38C65.89,25.8 65.9,25.15 65.67,24.57C65.44,23.98 64.99,23.51 64.41,23.25L55.36,19.21C54.84,18.97 54.24,18.93 53.68,19.1C53.13,19.27 52.66,19.63 52.35,20.13C50.47,23.4 49.18,26.99 48.55,30.72C48.52,30.93 48.45,31.14 48.34,31.32C48.24,31.51 48.09,31.67 47.92,31.8C47.75,31.93 47.55,32.03 47.34,32.08C47.13,32.14 46.92,32.15 46.7,32.12C46.49,32.09 46.28,32.02 46.1,31.91C45.91,31.8 45.75,31.66 45.62,31.49C45.49,31.32 45.39,31.12 45.34,30.91C45.28,30.7 45.27,30.49 45.3,30.27C45.97,26.1 47.4,22.09 49.53,18.44C50.25,17.26 51.37,16.38 52.7,15.97C54.02,15.56 55.44,15.64 56.7,16.21L65.75,20.24C67.11,20.86 68.17,21.98 68.72,23.37C69.26,24.76 69.24,26.3 68.67,27.68C65.91,33.98 64.23,40.7 63.7,47.55C63.67,47.97 63.49,48.37 63.18,48.65C62.88,48.94 62.47,49.1 62.05,49.1" />
<path android:fillColor="#ED1C24" android:pathData="M93.55,15.03C93.24,14.09 92.71,13.24 92.02,12.53C91.32,11.83 90.48,11.29 89.54,10.96L60.13,0.39C58.82,-0.09 57.39,-0.13 56.05,0.26C54.7,0.65 53.53,1.47 52.68,2.58C51.68,3.88 50.71,5.34 49.76,6.83C49.68,6.93 49.61,7.05 49.55,7.17C49.16,7.78 48.8,8.4 48.43,9.02H30.38C27.88,9.02 25.48,10.01 23.71,11.78C21.94,13.55 20.95,15.95 20.95,18.45V86.23C20.95,88.73 21.94,91.13 23.71,92.9C25.48,94.67 27.88,95.66 30.38,95.66H61.16C62.4,95.66 63.63,95.42 64.78,94.95C65.92,94.47 66.96,93.78 67.84,92.9C68.72,92.03 69.42,90.99 69.89,89.84C70.37,88.7 70.61,87.47 70.61,86.23V79.68C74.35,51.89 87.38,29.23 92.87,20.7C93.4,19.87 93.74,18.93 93.86,17.94C93.98,16.96 93.88,15.97 93.55,15.03L93.55,15.03ZM47.98,87.18H43.57C43.06,87.18 42.57,86.97 42.21,86.61C41.85,86.25 41.64,85.76 41.64,85.25C41.64,84.74 41.85,84.25 42.21,83.89C42.57,83.53 43.06,83.33 43.57,83.33H47.98C48.49,83.33 48.98,83.53 49.34,83.89C49.7,84.25 49.9,84.74 49.9,85.25C49.9,85.76 49.7,86.25 49.34,86.61C48.98,86.97 48.49,87.18 47.98,87.18ZM90.1,18.91C83.54,29.1 72.45,49.27 68.09,74.41C68.09,74.41 67.87,75.75 67.81,76.14C67.68,76.77 67.33,77.34 66.82,77.74C66.32,78.14 65.68,78.35 65.04,78.33H30.69C29.71,78.33 28.77,77.94 28.07,77.25C27.38,76.55 26.98,75.61 26.98,74.63V24.95C26.98,23.96 27.37,23.02 28.07,22.32C28.76,21.63 29.71,21.23 30.69,21.23H42.81C43.35,21.23 45.35,21.58 46.85,18.77C47.8,17.01 49.55,13.46 51.72,9.9L74.82,18.69C75.25,18.85 75.64,19.11 75.97,19.44C76.3,19.77 76.55,20.16 76.71,20.6C76.88,21.03 76.96,21.49 76.93,21.95C76.91,22.41 76.78,22.85 76.57,23.26C72.32,31.5 65.05,48.07 64.24,66.62C64.23,66.83 64.26,67.05 64.33,67.25C64.4,67.45 64.52,67.64 64.66,67.8C64.8,67.96 64.98,68.09 65.17,68.18C65.37,68.27 65.58,68.33 65.8,68.34H65.87C66.3,68.34 66.7,68.17 67.01,67.88C67.32,67.59 67.5,67.19 67.52,66.76C68.3,48.85 75.35,32.76 79.49,24.78C79.91,23.96 80.16,23.06 80.21,22.13C80.26,21.21 80.12,20.28 79.79,19.42C79.46,18.56 78.95,17.77 78.29,17.12C77.64,16.46 76.85,15.95 75.99,15.63L53.54,7.06C54.11,6.2 54.72,5.36 55.3,4.56C55.72,4.01 56.31,3.61 56.98,3.42C57.65,3.22 58.36,3.25 59.01,3.49L88.44,14.05C88.9,14.22 89.32,14.49 89.67,14.84C90.02,15.19 90.28,15.61 90.44,16.08C90.6,16.55 90.65,17.05 90.59,17.54C90.53,18.03 90.36,18.5 90.09,18.91" />
<path android:fillColor="#ED1C24" android:pathData="M16.82,116.24V121.41C16.83,121.49 16.82,121.56 16.8,121.64C16.77,121.71 16.73,121.77 16.68,121.83C16.62,121.88 16.56,121.92 16.49,121.95C16.42,121.97 16.34,121.98 16.26,121.97H14.26C14.18,121.98 14.11,121.97 14.03,121.95C13.96,121.92 13.9,121.88 13.84,121.83C13.79,121.77 13.75,121.71 13.72,121.64C13.7,121.56 13.69,121.49 13.7,121.41V116.02C13.7,115.53 13.51,115.07 13.16,114.72C12.82,114.38 12.35,114.18 11.86,114.18C11.37,114.18 10.91,114.38 10.56,114.72C10.22,115.07 10.02,115.53 10.02,116.02V121.41C10.03,121.49 10.02,121.56 10,121.64C9.97,121.71 9.93,121.77 9.88,121.83C9.82,121.88 9.76,121.92 9.69,121.95C9.62,121.97 9.54,121.98 9.46,121.97H7.45C7.38,121.98 7.3,121.97 7.23,121.95C7.16,121.92 7.09,121.88 7.04,121.83C6.98,121.77 6.94,121.71 6.92,121.64C6.89,121.57 6.89,121.49 6.89,121.41V116.02C6.89,115.78 6.85,115.54 6.75,115.32C6.66,115.09 6.53,114.89 6.36,114.72C6.18,114.55 5.98,114.41 5.76,114.32C5.54,114.23 5.3,114.18 5.05,114.18C4.81,114.18 4.57,114.23 4.35,114.32C4.13,114.41 3.92,114.55 3.75,114.72C3.58,114.89 3.45,115.09 3.35,115.32C3.26,115.54 3.21,115.78 3.21,116.02V121.41C3.22,121.49 3.22,121.56 3.19,121.64C3.17,121.71 3.13,121.77 3.07,121.83C3.02,121.88 2.95,121.92 2.88,121.95C2.81,121.97 2.73,121.98 2.66,121.97H0.65C0.57,121.98 0.5,121.97 0.42,121.95C0.35,121.92 0.29,121.88 0.23,121.83C0.18,121.77 0.14,121.71 0.11,121.64C0.09,121.56 0.08,121.49 0.09,121.41V116.24C0.05,115.58 0.14,114.91 0.38,114.29C0.61,113.66 0.98,113.1 1.45,112.62C1.92,112.15 2.49,111.79 3.11,111.55C3.73,111.32 4.4,111.22 5.06,111.27C5.7,111.23 6.34,111.33 6.93,111.58C7.52,111.82 8.04,112.2 8.46,112.68C8.88,112.2 9.41,111.82 10,111.58C10.59,111.33 11.23,111.23 11.86,111.27C12.53,111.22 13.19,111.32 13.82,111.55C14.44,111.79 15.01,112.15 15.48,112.63C15.95,113.1 16.31,113.66 16.54,114.29C16.77,114.91 16.87,115.58 16.82,116.24Z" />
<path android:fillColor="#ED1C24" android:pathData="M18.89,115.8C18.9,115.63 18.94,115.46 19.01,115.3C19.09,115.14 19.2,115 19.33,114.89C19.46,114.77 19.62,114.69 19.79,114.64C19.95,114.58 20.13,114.57 20.3,114.59H23.09C23.26,114.56 23.44,114.57 23.61,114.62C23.78,114.67 23.94,114.75 24.08,114.87C24.21,114.99 24.32,115.13 24.39,115.29C24.47,115.45 24.5,115.63 24.5,115.8C24.5,115.98 24.47,116.15 24.39,116.32C24.32,116.48 24.21,116.62 24.08,116.74C23.94,116.85 23.78,116.94 23.61,116.98C23.44,117.03 23.26,117.04 23.09,117.02H20.3C20.13,117.04 19.95,117.02 19.79,116.97C19.62,116.92 19.46,116.83 19.33,116.72C19.2,116.61 19.09,116.47 19.01,116.31C18.94,116.15 18.9,115.98 18.89,115.8Z" />
<path android:fillColor="#ED1C24" android:pathData="M30.57,111.23V113.92H34.85C35.05,113.89 35.27,113.92 35.47,113.98C35.66,114.05 35.85,114.15 36,114.29C36.16,114.43 36.29,114.61 36.37,114.8C36.46,114.99 36.5,115.2 36.5,115.41C36.5,115.62 36.46,115.82 36.37,116.02C36.29,116.21 36.16,116.38 36,116.52C35.85,116.66 35.66,116.77 35.47,116.83C35.27,116.9 35.05,116.92 34.85,116.9H30.57V121.42C30.58,121.49 30.57,121.57 30.54,121.64C30.52,121.71 30.48,121.78 30.43,121.83C30.37,121.88 30.31,121.92 30.24,121.95C30.16,121.97 30.09,121.98 30.01,121.97H27.92C27.85,121.98 27.77,121.97 27.7,121.95C27.63,121.92 27.56,121.88 27.51,121.83C27.45,121.78 27.41,121.71 27.39,121.64C27.36,121.57 27.36,121.49 27.37,121.42V109.8C27.35,109.59 27.38,109.38 27.46,109.18C27.53,108.99 27.65,108.81 27.8,108.66C27.95,108.52 28.13,108.4 28.32,108.33C28.52,108.26 28.73,108.23 28.94,108.24H35.74C35.95,108.22 36.16,108.25 36.36,108.31C36.56,108.38 36.74,108.48 36.9,108.62C37.05,108.76 37.18,108.93 37.26,109.13C37.35,109.32 37.39,109.53 37.39,109.74C37.39,109.95 37.35,110.15 37.26,110.35C37.18,110.54 37.05,110.71 36.9,110.85C36.74,110.99 36.56,111.1 36.36,111.16C36.16,111.23 35.95,111.25 35.74,111.23H30.57Z" />
<path android:fillColor="#ED1C24" android:pathData="M49.49,113.07L49.47,121.41C49.48,121.49 49.47,121.56 49.44,121.63C49.42,121.71 49.38,121.77 49.32,121.82C49.27,121.88 49.21,121.92 49.13,121.94C49.06,121.97 48.99,121.98 48.91,121.97H47C46.92,121.98 46.85,121.97 46.78,121.94C46.7,121.92 46.64,121.88 46.59,121.82C46.53,121.77 46.49,121.71 46.47,121.63C46.44,121.56 46.43,121.49 46.44,121.41V121.01C46.07,121.38 45.63,121.67 45.15,121.87C44.66,122.07 44.14,122.17 43.62,122.16C42.19,122.14 40.83,121.55 39.83,120.53C38.83,119.51 38.27,118.14 38.27,116.71C38.27,115.29 38.83,113.92 39.83,112.9C40.83,111.88 42.19,111.29 43.62,111.26C44.7,111.23 45.76,111.62 46.56,112.36C46.64,112.04 46.83,111.77 47.08,111.57C47.34,111.37 47.65,111.26 47.98,111.26C48.87,111.27 49.49,112 49.49,113.07ZM46.4,116.7C46.4,115.99 46.12,115.31 45.62,114.81C45.12,114.31 44.45,114.03 43.74,114.03C43.03,114.03 42.35,114.31 41.85,114.81C41.35,115.31 41.07,115.99 41.07,116.7C41.07,117.4 41.35,118.08 41.85,118.58C42.35,119.08 43.03,119.36 43.74,119.36C44.45,119.36 45.12,119.08 45.62,118.58C46.12,118.08 46.4,117.4 46.4,116.7Z" />
<path android:fillColor="#ED1C24" android:pathData="M51.96,108.54C51.96,108.2 52.06,107.87 52.25,107.59C52.44,107.31 52.71,107.09 53.02,106.96C53.34,106.83 53.68,106.8 54.01,106.86C54.35,106.93 54.65,107.1 54.89,107.33C55.13,107.57 55.3,107.88 55.36,108.21C55.43,108.55 55.4,108.89 55.27,109.21C55.14,109.52 54.92,109.79 54.64,109.98C54.36,110.17 54.03,110.27 53.69,110.27C53.46,110.27 53.23,110.23 53.02,110.14C52.81,110.06 52.62,109.93 52.46,109.77C52.3,109.61 52.17,109.42 52.09,109.21C52,109 51.96,108.77 51.96,108.54ZM55.26,121.41C55.26,121.49 55.26,121.57 55.23,121.64C55.21,121.71 55.17,121.77 55.11,121.83C55.06,121.88 54.99,121.92 54.92,121.95C54.85,121.97 54.77,121.98 54.7,121.97H52.69C52.61,121.98 52.54,121.97 52.47,121.95C52.39,121.92 52.33,121.88 52.28,121.83C52.22,121.77 52.18,121.71 52.16,121.64C52.13,121.57 52.12,121.49 52.13,121.41V112.66C52.13,112.46 52.18,112.26 52.26,112.08C52.35,111.9 52.47,111.74 52.62,111.61C52.77,111.48 52.94,111.38 53.13,111.32C53.33,111.26 53.53,111.24 53.72,111.27C53.92,111.25 54.12,111.27 54.3,111.33C54.49,111.39 54.66,111.49 54.8,111.62C54.95,111.75 55.06,111.91 55.14,112.09C55.22,112.27 55.26,112.46 55.26,112.66L55.26,121.41Z" />
<path android:fillColor="#ED1C24" android:pathData="M61.72,119.66C62.66,119.66 63.11,119.48 63.11,119.11C63.11,119 63.08,118.9 63.03,118.82C62.98,118.73 62.9,118.66 62.81,118.61C62.22,118.28 61.61,118 60.96,117.79L59.97,117.39C59.35,117.19 58.82,116.79 58.45,116.26C58.08,115.73 57.89,115.09 57.92,114.45C57.98,112.56 59.57,111.26 62.44,111.26C63.46,111.24 64.46,111.46 65.38,111.9C65.63,112.01 65.84,112.19 65.99,112.42C66.13,112.65 66.21,112.92 66.2,113.19C66.18,113.53 66.03,113.84 65.79,114.07C65.54,114.29 65.22,114.41 64.88,114.4C64.51,114.36 64.14,114.23 63.81,114.05C63.28,113.85 62.72,113.75 62.16,113.77C61.36,113.77 61.07,114.13 61.07,114.37C61.06,114.45 61.08,114.53 61.12,114.61C61.15,114.68 61.2,114.75 61.27,114.81C61.98,115.26 62.75,115.61 63.55,115.86L64.41,116.18C64.97,116.35 65.46,116.71 65.79,117.19C66.12,117.68 66.28,118.26 66.24,118.84C66.18,120.67 64.55,122.17 61.45,122.17C60.33,122.18 59.23,121.96 58.2,121.53C57.95,121.42 57.74,121.24 57.6,121.01C57.45,120.78 57.38,120.51 57.39,120.24C57.4,119.9 57.55,119.59 57.8,119.36C58.04,119.14 58.37,119.02 58.7,119.03C59.08,119.08 59.45,119.2 59.78,119.39C60.41,119.58 61.06,119.67 61.72,119.66Z" />
<path android:fillColor="#ED1C24" android:pathData="M78.81,113.07L78.79,121.41C78.8,121.49 78.79,121.56 78.77,121.63C78.74,121.71 78.7,121.77 78.65,121.82C78.59,121.88 78.53,121.92 78.46,121.94C78.39,121.97 78.31,121.98 78.23,121.97H76.32C76.25,121.98 76.17,121.97 76.1,121.94C76.03,121.92 75.96,121.88 75.91,121.82C75.86,121.77 75.82,121.71 75.79,121.63C75.77,121.56 75.76,121.49 75.77,121.41V121.01C75.4,121.38 74.96,121.67 74.47,121.87C73.99,122.07 73.47,122.17 72.94,122.16C71.51,122.14 70.16,121.55 69.16,120.53C68.16,119.51 67.6,118.14 67.6,116.71C67.6,115.29 68.16,113.92 69.16,112.9C70.16,111.88 71.51,111.29 72.94,111.26C74.03,111.23 75.08,111.62 75.89,112.36C75.97,112.04 76.15,111.77 76.4,111.57C76.66,111.37 76.98,111.26 77.3,111.26C78.2,111.27 78.81,112 78.81,113.07ZM75.73,116.7C75.71,116 75.43,115.33 74.93,114.84C74.43,114.36 73.76,114.08 73.06,114.08C72.36,114.08 71.69,114.36 71.2,114.84C70.7,115.33 70.41,116 70.4,116.7C70.39,117.05 70.45,117.4 70.59,117.73C70.72,118.06 70.91,118.36 71.16,118.61C71.41,118.87 71.7,119.07 72.03,119.21C72.36,119.34 72.71,119.41 73.06,119.41C73.42,119.41 73.77,119.34 74.09,119.21C74.42,119.07 74.72,118.87 74.97,118.61C75.21,118.36 75.41,118.06 75.54,117.73C75.67,117.4 75.74,117.05 75.73,116.7Z" />
<path android:fillColor="#ED1C24" android:pathData="M91.9,113.07L91.88,121.41C91.89,121.49 91.88,121.56 91.86,121.63C91.83,121.71 91.79,121.77 91.74,121.82C91.68,121.88 91.62,121.92 91.55,121.94C91.48,121.97 91.4,121.98 91.32,121.97H89.41C89.34,121.98 89.26,121.97 89.19,121.94C89.12,121.92 89.05,121.88 89,121.82C88.95,121.77 88.91,121.71 88.88,121.63C88.86,121.56 88.85,121.49 88.86,121.41V121.01C88.49,121.38 88.05,121.67 87.56,121.87C87.08,122.07 86.56,122.17 86.03,122.16C84.6,122.14 83.25,121.55 82.25,120.53C81.25,119.51 80.69,118.14 80.69,116.71C80.69,115.29 81.25,113.92 82.25,112.9C83.25,111.88 84.6,111.29 86.03,111.26C87.12,111.23 88.17,111.62 88.98,112.36C89.06,112.04 89.24,111.77 89.49,111.57C89.75,111.37 90.07,111.26 90.39,111.26C91.29,111.27 91.9,112 91.9,113.07ZM88.82,116.7C88.8,116 88.52,115.33 88.02,114.84C87.52,114.36 86.85,114.08 86.15,114.08C85.45,114.08 84.78,114.36 84.28,114.84C83.79,115.33 83.5,116 83.49,116.7C83.48,117.05 83.54,117.4 83.67,117.73C83.8,118.06 84,118.36 84.25,118.61C84.5,118.87 84.79,119.07 85.12,119.21C85.45,119.34 85.8,119.41 86.15,119.41C86.51,119.41 86.86,119.34 87.18,119.21C87.51,119.07 87.81,118.87 88.06,118.61C88.3,118.36 88.5,118.06 88.63,117.73C88.76,117.4 88.82,117.05 88.82,116.7Z" />
</group>
</vector>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="26dp"
android:viewportWidth="24"
android:viewportHeight="26">
<path
android:fillColor="#ED1C24"
android:pathData="M20.111 2.777c-1.766 0-3.174 1.437-3.174 3.195a3.18 3.18 0 0 0 3.174 3.194c1.766 0 3.174-1.437 3.174-3.194a3.168 3.168 0 0 0-3.174-3.195Z" />
<path
android:fillColor="#ED1C24"
android:fillType="evenOdd"
android:pathData="M18.286 16.175c0-4.912-3.968-8.885-8.829-8.885-4.88 0-8.849 3.993-8.849 8.885 0 4.912 3.968 8.885 8.849 8.885 4.88 0.02 8.829-3.973 8.829-8.885Zm-8.829 4.871c-2.678 0-4.841-2.196-4.841-4.871 0-2.696 2.182-4.872 4.841-4.872 2.678 0 4.84 2.196 4.84 4.872 0 2.695-2.162 4.871-4.84 4.871Z" />
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="124dp"
android:height="26dp"
android:viewportWidth="124"
android:viewportHeight="26">
<path
android:fillColor="#ED1C24"
android:pathData="M120.111 2.777c-1.766 0-3.174 1.437-3.174 3.195a3.18 3.18 0 0 0 3.174 3.194c1.766 0 3.174-1.437 3.174-3.194a3.168 3.168 0 0 0-3.174-3.195ZM109.457 21.046c-2.679 0-4.841-2.196-4.841-4.872 0-2.695 2.162-4.871 4.841-4.871 2.658 0 4.841 2.196 4.841 4.871 0 2.676-2.163 4.872-4.841 4.872Zm-17.678 0c-2.678 0-4.84-2.196-4.84-4.872 0-2.695 2.162-4.871 4.84-4.871 2.679 0 4.841 2.196 4.841 4.871 0 2.676-2.162 4.872-4.84 4.872Zm-17.657 0c-1.31 0-2.52-0.519-3.452-1.457a4.87 4.87 0 0 1-1.39-3.494c0.04-2.556 2.223-4.772 4.763-4.792h0.079c1.29 0 2.48 0.499 3.392 1.397a4.846 4.846 0 0 1 1.449 3.474c0 2.676-2.183 4.872-4.841 4.872Zm-21.785-6.768a4.803 4.803 0 0 1 4.464-2.975 4.775 4.775 0 0 1 4.445 2.994l-8.909-0.02ZM27.22 21.046c-2.678 0-4.84-2.196-4.84-4.872 0-2.695 2.181-4.871 4.84-4.871s4.841 2.196 4.841 4.871c0 2.676-2.163 4.872-4.84 4.872Zm-17.677 0c-2.679 0-4.841-2.196-4.841-4.872 0-2.695 2.182-4.871 4.84-4.871 2.679 0 4.842 2.196 4.842 4.871 0 2.676-2.163 4.872-4.841 4.872ZM109.457 7.27c-4.881 0-8.849 3.994-8.849 8.885 0-4.911-3.968-8.885-8.829-8.885-4.86 0-8.828 3.974-8.848 8.865l0.02-14.056c0-0.639-0.516-1.158-1.151-1.158h-1.726c-0.635 0-1.15 0.519-1.15 1.158l-0.02 6.629-0.219-0.14c-2.56-1.597-5.734-1.737-8.511-0.42-2.46 1.199-4.226 3.515-4.762 6.23a8.989 8.989 0 0 0-2.54-4.652 8.69 8.69 0 0 0-6.467-2.476c-2.183 0.08-4.187 0.999-5.694 2.436-1.746-1.797-4.167-2.675-6.726-2.456-1.409 0.12-2.798 0.72-4.047 1.698v-0.46a0.986 0.986 0 0 0-0.992-0.998H37.02a1.02 1.02 0 0 0-1.012 1.018v7.468c-0.099-4.832-4.008-8.726-8.829-8.726-4.88 0-8.848 3.994-8.848 8.886 0-4.912-3.968-8.886-8.83-8.886-4.88 0-8.848 3.994-8.848 8.886C0.654 21.026 4.622 25 9.503 25c4.86 0 8.829-3.994 8.829-8.886C18.332 21.026 22.3 25 27.18 25c4.822 0 8.73-3.894 8.83-8.726v7.687c0 0.56 0.436 0.999 0.991 0.999h1.925a1.02 1.02 0 0 0 1.011-1.019l0.08-8.046c0-2.516 1.746-4.473 4.246-4.732a4.882 4.882 0 0 1 4.186 1.657l0.06 0.08a8.324 8.324 0 0 0-0.616 3.135c-0.04 4.572 3.334 8.406 7.857 8.905l0.476 0.04c0.258 0.02 0.536 0.02 0.814 0.02h0.297c2.282-0.06 4.206-0.72 5.694-1.937l0.02-0.02 0.06-0.04 0.615-0.56a0.725 0.725 0 0 0 0.238-0.618 0.753 0.753 0 0 0-0.397-0.58l-2.262-1.257c-0.297-0.18-0.595-0.14-0.913 0.06l-0.416 0.28c-0.754 0.459-1.667 0.698-2.6 0.738h-0.217c-0.437 0-0.873-0.04-1.29-0.12-1.448-0.28-2.658-1.198-3.353-2.515l-0.218-0.52h13.154a8.871 8.871 0 0 0 2.142 4.233 8.826 8.826 0 0 0 6.548 2.916c4.86 0 8.829-3.994 8.829-8.886 0 4.892 3.968 8.886 8.848 8.886 4.88 0 8.829-3.994 8.829-8.886 0 4.912 3.968 8.886 8.849 8.886 4.88 0 8.828-3.994 8.828-8.886-0.02-4.911-3.988-8.905-8.868-8.905Z" />
<path
android:fillColor="#ED1C24"
android:pathData="M18.372 16.155c0-4.912-3.968-8.885-8.829-8.885-4.88 0-8.849 3.993-8.849 8.885 0 4.912 3.968 8.885 8.849 8.885 4.88 0.02 8.829-3.973 8.829-8.885Zm-8.829 4.871c-2.678 0-4.841-2.196-4.841-4.871 0-2.696 2.182-4.872 4.841-4.872 2.678 0 4.84 2.196 4.84 4.872 0 2.695-2.162 4.871-4.84 4.871Z" />
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Zigzag spike for the bottom edge of an m-faisa receipt body.
Teeth point downward into the gray footer gradient.
Drawn at a single color via tint in the layout.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="329dp"
android:height="34dp"
android:viewportWidth="329"
android:viewportHeight="34">
<path
android:fillColor="#FFFFFF"
android:pathData="M5.98,32.71L3.49,29.88C1.24,27.33 0,24.04 0,20.63L0,0.42L329,0.42L329,20.63C329,24.04 327.76,27.33 325.51,29.88L323.02,32.71C322.23,33.61 320.82,33.61 320.02,32.71L315.55,27.62C314.75,26.72 313.34,26.72 312.54,27.62L308.07,32.71C307.27,33.61 305.86,33.61 305.07,32.71L300.59,27.62C299.8,26.72 298.39,26.72 297.59,27.62L293.11,32.71C292.32,33.61 290.91,33.61 290.11,32.71L285.64,27.62C284.84,26.72 283.43,26.72 282.64,27.62L278.16,32.71C277.36,33.61 275.95,33.61 275.16,32.71L270.68,27.62C269.89,26.72 268.48,26.72 267.68,27.62L263.21,32.71C262.41,33.61 261,33.61 260.2,32.71L255.73,27.62C254.93,26.72 253.52,26.72 252.73,27.62L248.25,32.71C247.46,33.61 246.04,33.61 245.25,32.71L240.77,27.62C239.98,26.72 238.57,26.72 237.77,27.62L233.3,32.71C232.5,33.61 231.09,33.61 230.29,32.71L225.82,27.62C225.02,26.72 223.61,26.72 222.82,27.62L218.34,32.71C217.55,33.61 216.14,33.61 215.34,32.71L210.87,27.62C210.07,26.72 208.66,26.72 207.86,27.62L203.39,32.71C202.59,33.61 201.18,33.61 200.38,32.71L195.91,27.62C195.11,26.72 193.7,26.72 192.91,27.62L188.43,32.71C187.64,33.61 186.23,33.61 185.43,32.71L180.96,27.62C180.16,26.72 178.75,26.72 177.95,27.62L173.48,32.71C172.68,33.61 171.27,33.61 170.48,32.71L166,27.62C165.21,26.72 163.79,26.72 163,27.62L158.52,32.71C157.73,33.61 156.32,33.61 155.52,32.71L151.05,27.62C150.25,26.72 148.84,26.72 148.04,27.62L143.57,32.71C142.77,33.61 141.36,33.61 140.57,32.71L136.09,27.62C135.3,26.72 133.89,26.72 133.09,27.62L128.62,32.71C127.82,33.61 126.41,33.61 125.61,32.71L121.14,27.62C120.34,26.72 118.93,26.72 118.14,27.62L113.66,32.71C112.86,33.61 111.45,33.61 110.66,32.71L106.18,27.62C105.39,26.72 103.98,26.72 103.18,27.62L98.71,32.71C97.91,33.61 96.5,33.61 95.7,32.71L91.23,27.62C90.43,26.72 89.02,26.72 88.23,27.62L83.75,32.71C82.96,33.61 81.54,33.61 80.75,32.71L76.27,27.62C75.48,26.72 74.07,26.72 73.27,27.62L68.8,32.71C68,33.61 66.59,33.61 65.79,32.71L61.32,27.62C60.52,26.72 59.11,26.72 58.32,27.62L53.84,32.71C53.05,33.61 51.64,33.61 50.84,32.71L46.37,27.62C45.57,26.72 44.16,26.72 43.36,27.62L38.89,32.71C38.09,33.61 36.68,33.61 35.88,32.71L31.41,27.62C30.61,26.72 29.2,26.72 28.41,27.62L23.93,32.71C23.14,33.61 21.73,33.61 20.93,32.71L16.46,27.62C15.66,26.72 14.25,26.72 13.45,27.62L8.98,32.71C8.18,33.61 6.77,33.61 5.98,32.71Z" />
</vector>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Zigzag spike for the top edge of an m-faisa receipt body.
Teeth point upward into the gray header gradient.
Drawn at a single color via tint in the layout.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="329dp"
android:height="33dp"
android:viewportWidth="329"
android:viewportHeight="33">
<path
android:fillColor="#FFFFFF"
android:pathData="M323.02,0.71L325.51,3.53C327.76,6.09 329,9.38 329,12.78L329,33L0,33L0,12.78C0,9.38 1.24,6.09 3.49,3.53L5.98,0.71C6.77,-0.2 8.18,-0.2 8.98,0.71L13.45,5.79C14.25,6.7 15.66,6.7 16.46,5.79L20.93,0.71C21.73,-0.2 23.14,-0.2 23.93,0.71L28.41,5.79C29.2,6.7 30.61,6.7 31.41,5.79L35.88,0.71C36.68,-0.2 38.09,-0.2 38.89,0.71L43.36,5.79C44.16,6.7 45.57,6.7 46.37,5.79L50.84,0.71C51.64,-0.2 53.05,-0.2 53.84,0.71L58.32,5.79C59.11,6.7 60.52,6.7 61.32,5.79L65.79,0.71C66.59,-0.2 68,-0.2 68.8,0.71L73.27,5.79C74.07,6.7 75.48,6.7 76.27,5.79L80.75,0.71C81.54,-0.2 82.96,-0.2 83.75,0.71L88.23,5.79C89.02,6.7 90.43,6.7 91.23,5.79L95.7,0.71C96.5,-0.2 97.91,-0.2 98.71,0.71L103.18,5.79C103.98,6.7 105.39,6.7 106.18,5.79L110.66,0.71C111.45,-0.2 112.86,-0.2 113.66,0.71L118.14,5.79C118.93,6.7 120.34,6.7 121.14,5.79L125.61,0.71C126.41,-0.2 127.82,-0.2 128.62,0.71L133.09,5.79C133.89,6.7 135.3,6.7 136.09,5.79L140.57,0.71C141.36,-0.2 142.77,-0.2 143.57,0.71L148.04,5.79C148.84,6.7 150.25,6.7 151.05,5.79L155.52,0.71C156.32,-0.2 157.73,-0.2 158.52,0.71L163,5.79C163.79,6.7 165.21,6.7 166,5.79L170.48,0.71C171.27,-0.2 172.68,-0.2 173.48,0.71L177.95,5.79C178.75,6.7 180.16,6.7 180.96,5.79L185.43,0.71C186.23,-0.2 187.64,-0.2 188.43,0.71L192.91,5.79C193.7,6.7 195.11,6.7 195.91,5.79L200.38,0.71C201.18,-0.2 202.59,-0.2 203.39,0.71L207.86,5.79C208.66,6.7 210.07,6.7 210.87,5.79L215.34,0.71C216.14,-0.2 217.55,-0.2 218.34,0.71L222.82,5.79C223.61,6.7 225.02,6.7 225.82,5.79L230.29,0.71C231.09,-0.2 232.5,-0.2 233.3,0.71L237.77,5.79C238.57,6.7 239.98,6.7 240.77,5.79L245.25,0.71C246.04,-0.2 247.46,-0.2 248.25,0.71L252.73,5.79C253.52,6.7 254.93,6.7 255.73,5.79L260.2,0.71C261,-0.2 262.41,-0.2 263.21,0.71L267.68,5.79C268.48,6.7 269.89,6.7 270.68,5.79L275.16,0.71C275.95,-0.2 277.36,-0.2 278.16,0.71L282.64,5.79C283.43,6.7 284.84,6.7 285.64,5.79L290.11,0.71C290.91,-0.2 292.32,-0.2 293.11,0.71L297.59,5.79C298.39,6.7 299.8,6.7 300.59,5.79L305.07,0.71C305.86,-0.2 307.27,-0.2 308.07,0.71L312.54,5.79C313.34,6.7 314.75,6.7 315.55,5.79L320.02,0.71C320.82,-0.2 322.23,-0.2 323.02,0.71Z" />
</vector>
@@ -117,6 +117,50 @@
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Ooredoo M-Faisa Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardOoredoo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="16dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<ImageView
android:layout_width="124dp"
android:layout_height="26dp"
android:src="@drawable/ooredoo_logo_long"
android:contentDescription="@string/ooredoo_name"
android:scaleType="fitStart"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/ooredoo_name"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/ooredoo_desc"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="4dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Fahipay Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardFahipay"
@@ -58,6 +58,7 @@
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilPassword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/password"
@@ -73,22 +74,42 @@
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilOtpSeed"
<LinearLayout
android:id="@+id/rowOtpSeed"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/otp_seed"
android:layout_marginBottom="8dp"
app:endIconMode="password_toggle"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etOtpSeed"
android:layout_width="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="8dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilOtpSeed"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:imeOptions="actionDone"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
android:layout_weight="1"
android:hint="@string/otp_seed"
app:endIconMode="password_toggle"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etOtpSeed"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:imeOptions="actionDone"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnScanOtpSeed"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
app:icon="@drawable/ic_qr_scan"
android:contentDescription="@string/scan_otp_qr"
android:tooltipText="@string/scan_otp_qr" />
</LinearLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilTotpCode"
@@ -246,18 +246,10 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="4dp"
android:layout_marginStart="4dp"
android:text="Save"
app:icon="@drawable/ic_save" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDone"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="Done" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,385 @@
<?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"
android:background="?attr/colorSurface">
<ScrollView
android:id="@+id/receiptContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:overScrollMode="never"
android:scrollbars="none">
<!-- ════════════════════════════════════════════════════════════════════ -->
<!-- Renderable receipt card -->
<!-- ════════════════════════════════════════════════════════════════════ -->
<LinearLayout
android:id="@+id/receiptCard"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="#FFFFFF">
<!-- Top: m-faisaa logo on white -->
<ImageView
android:layout_width="78dp"
android:layout_height="102dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="42dp"
android:src="@drawable/mfaisaa_logo_with_text"
android:contentDescription="@null" />
<!-- White→gray fade leading into the top zigzag tear -->
<View
android:layout_width="match_parent"
android:layout_height="38dp"
android:layout_marginTop="22dp"
android:background="@drawable/bg_mfaisa_receipt_gradient_top" />
<!-- Top zigzag tear: white teeth poking up into the gray fade above -->
<ImageView
android:layout_width="match_parent"
android:layout_height="20dp"
android:scaleType="fitXY"
android:src="@drawable/receipt_mfaisa_top"
android:contentDescription="@null" />
<!-- ════════════════════════════════════════════════════════════════ -->
<!-- Receipt body -->
<!-- ════════════════════════════════════════════════════════════════ -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:background="#FFFFFF"
android:paddingHorizontal="24dp"
android:paddingTop="40dp"
android:paddingBottom="32dp">
<!-- Total amount row with green check -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
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="Total Amount"
android:textSize="13sp"
android:textStyle="bold"
android:textColor="#7A7A7A"
android:fontFamily="@font/nunito_sans" />
<TextView
android:id="@+id/tvAmount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="30sp"
android:textStyle="bold"
android:textColor="#A2D40A"
android:fontFamily="@font/nunito_sans" />
</LinearLayout>
<ImageView
android:layout_width="28dp"
android:layout_height="28dp"
android:src="@drawable/ic_mfaisa_receipt_check"
android:contentDescription="@null" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="20dp"
android:background="#EAEAEA" />
<!-- Status -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingVertical="14dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Status"
android:textSize="13sp"
android:textStyle="bold"
android:textColor="#7A7A7A"
android:fontFamily="@font/nunito_sans" />
<TextView
android:id="@+id/tvStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="Success"
android:textSize="15sp"
android:textStyle="bold"
android:textColor="#7A7A7A"
android:fontFamily="@font/nunito_sans" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#EAEAEA" />
<!-- Transaction type -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingVertical="14dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Transaction type"
android:textSize="13sp"
android:textStyle="bold"
android:textColor="#7A7A7A"
android:fontFamily="@font/nunito_sans" />
<TextView
android:id="@+id/tvTransactionType"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textSize="15sp"
android:textStyle="bold"
android:textColor="#7A7A7A"
android:fontFamily="@font/nunito_sans" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#EAEAEA" />
<!-- From -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingVertical="14dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="From"
android:textSize="13sp"
android:textStyle="bold"
android:textColor="#7A7A7A"
android:fontFamily="@font/nunito_sans" />
<TextView
android:id="@+id/tvFromName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textSize="15sp"
android:textStyle="bold"
android:textColor="#7A7A7A"
android:fontFamily="@font/nunito_sans" />
<TextView
android:id="@+id/tvFromMsisdn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textSize="15sp"
android:textStyle="bold"
android:textColor="#7A7A7A"
android:fontFamily="@font/nunito_sans" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#EAEAEA" />
<!-- To -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingVertical="14dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="To"
android:textSize="13sp"
android:textStyle="bold"
android:textColor="#7A7A7A"
android:fontFamily="@font/nunito_sans" />
<TextView
android:id="@+id/tvToName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textSize="15sp"
android:textStyle="bold"
android:textColor="#7A7A7A"
android:fontFamily="@font/nunito_sans" />
<TextView
android:id="@+id/tvToMsisdn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textSize="15sp"
android:textStyle="bold"
android:textColor="#7A7A7A"
android:fontFamily="@font/nunito_sans" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#EAEAEA" />
<!-- Date & Time -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingVertical="14dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Date &amp; Time"
android:textSize="13sp"
android:textStyle="bold"
android:textColor="#7A7A7A"
android:fontFamily="@font/nunito_sans" />
<TextView
android:id="@+id/tvDateTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textSize="15sp"
android:textStyle="bold"
android:textColor="#7A7A7A"
android:fontFamily="@font/nunito_sans" />
</LinearLayout>
<!-- Remarks (hidden when empty) -->
<View
android:id="@+id/remarksDivider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="#EAEAEA"
android:visibility="gone" />
<LinearLayout
android:id="@+id/remarksRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingVertical="14dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Remarks"
android:textSize="13sp"
android:textStyle="bold"
android:textColor="#7A7A7A"
android:fontFamily="@font/nunito_sans" />
<TextView
android:id="@+id/tvRemarks"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textSize="15sp"
android:textStyle="bold"
android:textColor="#7A7A7A"
android:fontFamily="@font/nunito_sans" />
</LinearLayout>
</LinearLayout>
<!-- Bottom zigzag tear: white teeth poking down into the gray fade below -->
<ImageView
android:layout_width="match_parent"
android:layout_height="20dp"
android:scaleType="fitXY"
android:src="@drawable/receipt_mfaisa_bottom"
android:contentDescription="@null" />
<!-- Gray→white fade trailing the bottom zigzag -->
<View
android:layout_width="match_parent"
android:layout_height="40dp"
android:background="@drawable/bg_mfaisa_receipt_gradient_bottom" />
</LinearLayout>
</ScrollView>
<!-- ════════════════════════════════════════════════════════════════════════ -->
<!-- Action buttons — outside renderable area -->
<!-- ════════════════════════════════════════════════════════════════════════ -->
<LinearLayout
android:id="@+id/btnRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="?attr/colorSurface"
android:paddingHorizontal="12dp"
android:paddingTop="8dp"
android:paddingBottom="12dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnShare"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="4dp"
android:text="Share"
app:icon="@drawable/ic_share" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSave"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="Save"
app:icon="@drawable/ic_save" />
</LinearLayout>
</LinearLayout>
@@ -273,18 +273,10 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginHorizontal="4dp"
android:layout_marginStart="4dp"
android:text="Save"
app:icon="@drawable/ic_save" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDone"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="4dp"
android:text="Done" />
</LinearLayout>
</LinearLayout>
@@ -0,0 +1,222 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<ImageView
android:id="@+id/ivLogo"
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="16dp"
android:src="@drawable/ic_logo"
android:contentDescription="@string/app_name" />
<TextView
android:id="@+id/tvAppName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:layout_marginBottom="4dp" />
<TextView
android:id="@+id/tvVersion"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:alpha="0.6"
android:layout_marginBottom="20dp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutlineVariant"
android:layout_marginBottom="24dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/about_legal"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:layout_marginBottom="24dp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutlineVariant"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/about_terms"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginBottom="4dp" />
<LinearLayout
android:id="@+id/rowMibTerms"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingVertical="12dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="16dp"
android:src="@drawable/mib_logo"
android:scaleType="fitCenter"
android:contentDescription="MIB" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Maldives Islamic Bank"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:src="@drawable/ic_arrow_right"
android:alpha="0.4"
android:contentDescription="@null" />
</LinearLayout>
<LinearLayout
android:id="@+id/rowBmlTerms"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingVertical="12dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="16dp"
android:src="@drawable/bml_icon"
android:scaleType="fitCenter"
android:contentDescription="BML" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Bank of Maldives"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:src="@drawable/ic_arrow_right"
android:alpha="0.4"
android:contentDescription="@null" />
</LinearLayout>
<LinearLayout
android:id="@+id/rowFahipayTerms"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingVertical="12dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="16dp"
android:src="@drawable/fahipay_logo"
android:scaleType="fitCenter"
android:contentDescription="Fahipay" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Fahipay"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:src="@drawable/ic_arrow_right"
android:alpha="0.4"
android:contentDescription="@null" />
</LinearLayout>
<LinearLayout
android:id="@+id/sectionDonate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutlineVariant"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/about_donate_title"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginBottom="6dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/about_donate_desc"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:alpha="0.7"
android:layout_marginBottom="16dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDonateMvr"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
android:text="@string/about_donate_mvr" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDonateUsd"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/about_donate_usd" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</ScrollView>
+13
View File
@@ -43,6 +43,19 @@
android:defaultValue="MIB" />
</action>
<action
android:id="@+id/action_bankSelection_to_credentials_ooredoo"
app:destination="@id/credentialsFragment"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right">
<argument
android:name="bankType"
app:argType="string"
android:defaultValue="MIB" />
</action>
</fragment>
<fragment
+48 -2
View File
@@ -28,6 +28,11 @@
<string name="fahipay_totp_code">Authenticator Code (6 digits)</string>
<string name="fahipay_totp_hint">Enter the code from your authenticator app</string>
<string name="fahipay_verify">Verify</string>
<string name="ooredoo_name">Ooredoo M-Faisa</string>
<string name="ooredoo_desc">Mobile Wallet</string>
<string name="ooredoo_sign_in_desc">Enter your mobile number and 4-digit mPIN.</string>
<string name="ooredoo_phone">Mobile Number</string>
<string name="ooredoo_pin">mPIN</string>
<string name="sign_in">Sign In</string>
<string name="sign_in_desc">Enter your Maldives Islamic Bank credentials.</string>
<string name="bml_sign_in_desc">Enter your Bank of Maldives credentials.</string>
@@ -35,6 +40,7 @@
<string name="password">Password</string>
<string name="otp_seed">OTP Seed (TOTP Secret)</string>
<string name="otp_seed_hint">The Base32 secret from your authenticator setup</string>
<string name="scan_otp_qr">Scan OTP QR</string>
<string name="login">Login</string>
<!-- Lock screen -->
@@ -138,7 +144,7 @@
<string name="settings_biometrics">Use biometrics</string>
<string name="settings_biometrics_unavailable">No biometrics enrolled on this device</string>
<string name="settings_biometrics_unlock">To unlock app</string>
<string name="settings_biometrics_transfer">Confirm transfer</string>
<string name="settings_biometrics_transfer">Confirm transaction</string>
<string name="biometric_transfer_title">Confirm Transfer</string>
<string name="settings_autolock">Auto-lock</string>
<string name="autolock_off">Off</string>
@@ -191,6 +197,27 @@
<string name="settings_desc_appearance">Theme, language, and display options</string>
<string name="settings_desc_privacy_security">App lock, PIN, and security preferences</string>
<string name="settings_desc_storage">Manage cached data and storage usage</string>
<string name="settings_notifications">Notifications</string>
<string name="settings_desc_notifications">Background alerts for new bank activity</string>
<string name="settings_notif_section">Background Polling</string>
<string name="settings_notif_enable">Enable background notifications</string>
<string name="settings_notif_enable_desc">Receive alerts for new transactions and activity</string>
<string name="settings_notif_description">Keeps the app running in the background and notifies you of new bank activity. A persistent status bar notification is shown while active — you can silence or hide it in notification channels.</string>
<string name="settings_notif_open_system">Notification channels</string>
<string name="settings_notif_channels_desc">Manage sounds, alerts, and silence the background service notification</string>
<string name="notif_service_title">Thijooree</string>
<string name="notif_service_desc">Checking for new bank notifications</string>
<string name="notif_channel_service">Background service</string>
<string name="settings_about">About</string>
<string name="settings_desc_about">App info, version, and legal</string>
<string name="about_version">Version %s</string>
<string name="about_short_desc">Thijooree is a native Android client for Maldivian banking services.</string>
<string name="about_terms">Terms of Service</string>
<string name="about_donate_title">Support Development</string>
<string name="about_donate_desc">If you find this app useful, a small donation goes a long way in keeping it alive and improving.</string>
<string name="about_donate_mvr">Donate in MVR</string>
<string name="about_donate_usd">Donate in USD</string>
<string name="about_legal">Thijooree is an independent, third-party app. It is not affiliated with, endorsed by, or officially supported by any bank or financial institution.\n\nThis app works by logging into your internet banking services directly using your credentials and communicating with bank APIs. It is built using techniques derived from reverse-engineered official bank apps. Behaviour may change or break without notice if banks update their systems.\n\nDhiraagu and Ooredoo APIs are used to look up details about phone numbers.\n\nThis app does not collect any analytics, telemetry, or personal data, and does not transmit any information about you or your usage to the developer. Everything stays entirely on your device.</string>
<string name="settings_logout">Log out</string>
<string name="settings_logout_confirm_title">Log out of %s?</string>
<string name="settings_logout_confirm_message">All cached data will be cleared and remaining accounts will be refreshed.</string>
@@ -258,7 +285,8 @@
<string name="transfer_confirm">Confirm</string>
<string name="transfer_success">Transfer Successful</string>
<string name="transfer_bml_contact_required_title">Contact Required</string>
<string name="transfer_bml_contact_required_msg">To send USD to a MIB account from BML, the recipient must be saved as a BML contact first. This is required by BML\'s API.\n\nPlease add this account as a BML contact, then try again.</string>
<string name="transfer_bml_contact_required_msg">We couldn\'t verify the recipient\'s currency for this transfer.\n\nPlease save them as a contact, manually select the correct currency, then try again.</string>
<string name="transfer_bml_contact_required_msg_bml_limit">BML\'s API requires the recipient to be saved as a contact when sending USD to a non-BML account, even though we verified the account.\n\nPlease save them as a contact, then try again.</string>
<string name="transfer_missing_internal_id">Account data is incomplete — please re-login to refresh.</string>
<string name="transfer_verify_payment">Verify Payment</string>
<string name="transfer_send_otp_via">Send verification code via</string>
@@ -334,12 +362,30 @@
<string name="card_pay_qr">Scan to Pay</string>
<string name="card_pay_nfc">Tap to Pay</string>
<string name="mib_qr_nfc_not_supported">Skill issue on MIB side, Not supported</string>
<string name="nfc_unsupported_title">Not Supported</string>
<string name="nfc_unsupported_message">Tap to Pay is not supported on this device.</string>
<string name="nfc_disabled_title">NFC is Off</string>
<string name="nfc_disabled_message">Turn on NFC to use Tap to Pay.</string>
<string name="nfc_open_settings">NFC Settings</string>
<string name="nfc_not_default_title">Set Default Payment App</string>
<string name="nfc_not_default_message">Set %1$s as the default contactless payment app to use Tap to Pay.</string>
<string name="nfc_payment_open_settings">Payment Settings</string>
<string name="card_manage">Manage Card</string>
<string name="card_set_as_default">Set as Default Card</string>
<string name="card_hide_from_dashboard">Hide from Dashboard</string>
<string name="card_action_change_pin">Change PIN</string>
<string name="card_action_freeze">Freeze</string>
<string name="card_action_unfreeze">Unfreeze</string>
<string name="card_action_block">Block</string>
<string name="card_freeze_confirm_title">Freeze card?</string>
<string name="card_freeze_confirm_message">This will temporarily stop the card from being used. You can unfreeze it anytime you want to use it again.</string>
<string name="card_unfreeze_confirm_title">Unfreeze card?</string>
<string name="card_unfreeze_confirm_message">This will re-enable the card for transactions.</string>
<string name="card_freeze_success">Card frozen</string>
<string name="card_unfreeze_success">Card unfrozen</string>
<string name="card_freeze_failed">Failed to update card status</string>
<string name="card_freeze_comments_hint">Reason (optional)</string>
<string name="card_status_temp_blocked">Temporary blocked by client</string>
<string name="cards_empty">No cards found</string>
<!-- Connectivity banner -->
+10
View File
@@ -211,6 +211,16 @@ GET https://www.bankofmaldives.com.mv/internetbanking/web/profile
`302` redirect (to `/web/redirect` or similar). The server has auto-activated the sole profile and set the `blaze_identity` cookie. Skip Step 6 and proceed directly to [OAuth Token Exchange](03-oauth-token.md).
In this fast-path the client has no real `profile_id` to track, so a placeholder `BmlProfile` is synthesized (`BmlLoginFlow.kt:127-135`):
| Field | Value |
|---|---|
| `profileId` | `username` (used as a stable temporary ID — replaced by the real customer number after `fetchUserInfo`) |
| `name` | `"Personal"` |
| `type` | `"Profile"` |
| `profileType` | `"default"` |
| `autoActivated` | `true` — sentinel; `activateProfile` skips the Step 6 GET and goes straight to OAuth |
---
## Step 6 — Activate Profile
+4 -2
View File
@@ -135,19 +135,21 @@ POST https://www.bankofmaldives.com.mv/internetbanking/oauth/token
**Content-Type:** `application/x-www-form-urlencoded`
**HTTP `User-Agent` header**: the browser/web UA (same as the initial token exchange), not the app UA. See `BmlLoginFlow.kt:341`.
| Field | Value |
|---|---|
| `grant_type` | `refresh_token` |
| `refresh_token` | Stored refresh token |
| `client_id` | `98C83590-513F-4716-B02B-EC68B7D9E7E7` |
| `Device-ID` | Same device ID from the original login |
| `User-Agent` | App user agent string |
| `User-Agent` | App user agent string (form field — distinct from the HTTP header above) |
| `x-app-version` | `2.1.44.348` |
```bash
curl --request POST \
--url 'https://www.bankofmaldives.com.mv/internetbanking/oauth/token' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'User-Agent: Mozilla/5.0 (Linux; Android {version}; {model}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/... Mobile Safari/537.36' \
--data 'grant_type=refresh_token' \
--data 'refresh_token=def50200aabbcc...' \
--data 'client_id=98C83590-513F-4716-B02B-EC68B7D9E7E7' \
+13 -8
View File
@@ -78,7 +78,7 @@ curl --request GET \
"currency": "MVR",
"account_status": "Active",
"prepaid_card": false,
"product_code": "VISA",
"product_code": "C1007",
"account_visible": false,
"cardBalance": {
"AvailableLimit": 0.0,
@@ -94,7 +94,7 @@ curl --request GET \
"currency": "MVR",
"account_status": "Active",
"prepaid_card": true,
"product_code": "VISA",
"product_code": "C1007",
"account_visible": true,
"cardBalance": {
"AvailableLimit": 200.00,
@@ -164,7 +164,7 @@ curl --request GET \
| Field | Type | Description |
|---|---|---|
| `prepaid_card` | `bool` | `true` for prepaid cards |
| `product_code` | `string` | Card scheme (e.g. `"VISA"`) |
| `product_code` | `string` | BML product code, always `Cxxxx` (e.g. `"C1007"`) — see mapping below |
| `account_visible` | `bool` | `true` for credit cards, `false` for debit cards |
| `cardBalance.AvailableLimit` | `number` | Available credit/prepaid balance |
| `cardBalance.CurrentBalance` | `number` | Current outstanding balance |
@@ -179,15 +179,20 @@ curl --request GET \
### Product Code → Card Name
The `product_code` field identifies the specific card product. Known mappings:
The `product_code` field identifies the specific card product. Resolution is two-tiered (`util/bmlapi/BmlCardParser.kt`):
1. **Network icon** (Visa / Mastercard / Amex chip in the corner) — by prefix: `C1*` → Visa, `C3*` → Amex, `C8*` → Mastercard, with `C8905` and `C8995` overridden to Visa.
2. **Card image asset** — by exact-match list below; unknown codes fall back to `defaultcard.png`.
Known asset mappings:
| `product_code` | Card name | Asset |
|---|---|---|
| `C8201`, `C8001`, `C8009` | Mastercard Prepaid | `master_prepaid` |
| `C8205`, `C8005`, `C8008` | Mastercard Prepaid Travel | `master_prepaid_travel` |
| `C8010`, `C8011` | Mastercard Platinum | `master_platinum` |
| `C8010`, `C8011`, `C8033` | Mastercard Platinum | `master_platinum` |
| `C8020`, `C8022` | Mastercard Gold | `master_gold` |
| `C8030`, `C8033` | Mastercard Business Debit | `master_business_debit` |
| `C8030` | Mastercard Business Debit | `master_business_debit` |
| `C8040`, `C8044` | Mastercard World | `master_world` |
| `C8101` | Mastercard Masveriyaa | `master_masveriyaa` |
| `C8102` | Mastercard Odiveriyaa | `master_odiveriyaa` |
@@ -202,12 +207,12 @@ The `product_code` field identifies the specific card product. Known mappings:
| `C3009`, `C3019`, `C3029`, `C3099`, `C3088`, `C3188` | Amex Credit Gold | `amex_credit_gold` |
| `C1001`, `C1011`, `C1082`, `C1081`, `C1101`, `C1111`, `C1181`, `C1182` | Visa Debit Generic | `visa_debit_generic` |
| `C1003`, `C1013`, `C1083`, `C1084`, `C1103`, `C1113`, `C1183`, `C1184` | Visa Gold | `visa_gold` |
| `C1005`, `C1006`, `C1089` | Visa Debit Islamic | `visa_debit_islamic` |
| `C1005`, `C1006`, `C1030`, `C1089` | Visa Debit Islamic | `visa_debit_islamic` |
| `C1007`, `C1027`, `C1097`, `C1107`, `C1197`, `C1077`, `C1177` | Visa Debit | `visa_debit` |
| `C1009`, `C1019`, `C1085`, `C1086`, `C1109`, `C1119`, `C1185`, `C1186` | Visa Platinum | `visa_platinum` |
| `C1017` | Visa Infinite | `visa_infinite` |
| `C1020`, `C1021` | Visa Debit Platinum | `visa_debit_platinum` |
| `C1030`, `C1090`, `C1130`, `C1033`, `C1133` | Visa Corporate | `visa_corporate` |
| `C1090`, `C1130`, `C1033`, `C1133` | Visa Corporate | `visa_corporate` |
| `C1040`, `C1041`, `C1047`, `C1048`, `C1050`, `C1051`, `C1087`, `C1088`, `C1140`, `C1141`, `C1147`, `C1148`, `C1150`, `C1151`, `C1187`, `C1188` | Visa Student Black | `visa_student_black` |
| `C1059`, `C1062`, `C1070`, `C1072`, `C1159`, `C1162` | Mastercard Prepaid Business | `master_prepaid_business` |
| `C1061`, `C1063`, `C1071`, `C1073`, `C1161`, `C1163` | Mastercard | `master` |
+70
View File
@@ -187,6 +187,76 @@ Fall back to `bookingDate` as-is.
---
## Pending History
Locked / pending amounts for a CASA account (e.g. unsettled card authorisations, holds). Returned as a flat list — no pagination.
### Endpoint
```
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/history/pending/{accountId}
```
| Path parameter | Description |
|---|---|
| `accountId` | Internal account ID (`id` field from [dashboard](04-dashboard.md)) |
### Headers
| Header | Value |
|---|---|
| `Authorization` | `Bearer <access_token>` |
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
| `x-app-version` | `2.1.44.348` |
```bash
curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/history/pending/abc123def456' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348'
```
### Response
```json
{
"success": true,
"payload": [
{
"LockedID": "L00012345",
"FromDate": "2026-05-16",
"LockedAmount": 75.00,
"Description": "Card authorisation — Merchant Name"
}
]
}
```
### Response Fields
| Field | Type | Description |
|---|---|---|
| `success` | `bool` | `true` on success |
| `payload` | `array` | List of pending entries (top-level array, **not** under `payload.history`) |
### Pending Entry
| Field | Type | Description |
|---|---|---|
| `LockedID` | `string` | Unique ID for the pending entry |
| `FromDate` | `string` | Date the hold was placed |
| `LockedAmount` | `number` | Held amount — always a positive number on the wire; the client treats it as a **debit** by negating (`BmlHistoryClient.kt:184`) |
| `Description` | `string` | Free-form description (counterparty/merchant) |
> **Amount sign:** the server returns `LockedAmount` as a positive number with no debit/credit indicator. All pending entries are debits (funds reserved out of the available balance), so the client negates the value before display.
> **Currency:** not returned by the server. The client assumes MVR.
Called from `AccountHistoryFragment.kt:263` to populate the pending tab of the account history view.
---
&nbsp;
---
+8 -1
View File
@@ -224,7 +224,14 @@ curl --request POST \
}
```
`success: false` — the `message` field contains the reason. Common causes: wrong OTP, insufficient balance, invalid account.
`success: false` — the error text may appear in either of two fields. The client prefers `payload` (when it is a non-blank string and not `"null"`) and falls back to `message` (`BmlTransferClient.kt:86-88`):
| Field | When used |
|---|---|
| `payload` (string) | Validation-style errors — e.g. insufficient balance, account-specific failures |
| `message` | Generic errors — e.g. invalid OTP, generic transfer failure |
Common causes: wrong OTP, insufficient balance, invalid account, currency mismatch.
---
+4
View File
@@ -148,6 +148,10 @@ curl --request GET \
| `name` | `string` | Account holder name |
| `agnt` | `string` | BIC of MIB — send as the `bank` field in the [transfer](08-transfer.md) request |
> **Client-synthesized fields**: when the app wraps this response for downstream code (`BmlValidateClient.kt:68`), it sets `trnType = "DOT"` and `validationType = "MIB"`. Neither is returned by the server.
>
> **No currency**: this endpoint does NOT return the MIB account's currency. The client sets `currency = ""` (`BmlValidateClient.kt:74-75`). Important for USD-vs-MVR transfer routing — currency must be sourced elsewhere (e.g. MIB's own lookup, see [MIB transfer docs](../mibapi/08-transfer.md)).
### Failure
```json
+37
View File
@@ -77,6 +77,16 @@ Expected response: `{ "code": 0, "payload": [...] }`
The OTP is a standard TOTP (RFC 6238, SHA-1, 30-second window, 6 digits) derived from the stored BML authenticator seed.
### Failure Handling
Each of the three POSTs validates the server's `code` field and throws on mismatch (`BmlTapToPayClient.kt:37, 42, 47`). The exception message is the server's `message` field:
| Step | Expected code | Throws if |
|---|---|---|
| 1a | `0` (rare) or `99` | code is neither — `message` propagated |
| 1b | `22` | code is not `22``message` propagated |
| 1c | `0` | code is not `0``message` propagated |
### Token Response
```json
@@ -239,6 +249,33 @@ All APDU responses use BER-TLV encoding. Tags are 1 or 2 bytes (hex string). Len
---
## Lifecycle
The HCE service (`BmlHostCardEmulatorService`) keeps a single active `BmlWalletToken` in a volatile companion-object field. Tokens are single-use — exactly one tap consumes one token.
### Companion API
```kotlin
BmlHostCardEmulatorService.setToken(token: BmlWalletToken)
BmlHostCardEmulatorService.clearToken()
BmlHostCardEmulatorService.onTransactionComplete: (success: Boolean) -> Unit
```
| Call | When |
|---|---|
| `setToken(token)` | After fetching a token, before prompting the user to tap |
| `clearToken()` | After the tap completes, when the prompt is dismissed, or on error |
| `onTransactionComplete(true)` | Fired immediately after the `READ RECORD` response (`BmlHostCardEmulatorService.kt:78`) |
| `onTransactionComplete(false)` | Fired from `onDeactivated` if GPO was never seen (`BmlHostCardEmulatorService.kt:35-38`) — i.e. the reader walked away before completing the EMV exchange |
### State Rules
- A token MUST be installed via `setToken` before the user taps. With no active token, `SELECT PPSE` launches `BmlTapToPayActivity` (a redirector to `MainActivity`) and returns `6F00`.
- The service tracks `gpoSent` to distinguish "user pulled the phone away" from a successful read. A successful `handleReadRecord` resets `gpoSent` to `false` via `onDeactivated` after the success callback has already fired.
- `BmlTapToPayActivity` provides the "Tap your phone…" prompt UI. The activity is responsible for calling `setToken` before showing the prompt and `clearToken` when dismissed.
---
## Prerequisites
- Valid `access_token` from [OAuth Token Exchange](03-oauth-token.md)
+35 -3
View File
@@ -33,6 +33,16 @@ TLV path: **root tag `35` → sub-tag `20` → sub-sub-tag `01`**
The value at tag `01` is the full `https://pay.bml.com.mv/app/...` URL.
### 3. POS QR (`mv.com.bml.qtr`)
BML POS terminals emit an EMVCo-style dynamic QR with no tag `26` and supplementary domain
`mv.com.bml.qtr`. The same TLV path (`35``20``01`) holds a bare reference such as
`02:215c7b9f15ce4ed28e15697ea976db99` instead of a URL. That bare reference — not the whole payload,
which is rejected with code `112` — is what gets Base64-encoded into the payrequest lookup below.
The reference does **not** resolve as a `pay.bml.com.mv/app/` short code in a browser; only the API
understands it. See
[PayMV QR Format → BML POS QR](../thijooree/18-paymv-qr-format.md#bml-pos-qr-mvcombmlqtr).
---
## PayMV QR Format (TLV)
@@ -71,19 +81,29 @@ PayMV QRs (static, PayMV-native) use a decimal TLV encoding (not BER-TLV):
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/{base64Url}
```
`{base64Url}` is the full QR URL (e.g. `https://pay.bml.com.mv/app/...`) base64-encoded with standard encoding (with padding).
`{base64Url}` is the lookup key, base64-encoded with standard encoding — the full QR URL
(e.g. `https://pay.bml.com.mv/app/...`), or for POS QRs the bare `35``20``01` reference.
BML's own app omits the `=` padding; the padded form resolves as well, so the client matches the app
rather than relying on either being required.
### Headers
| Header | Value |
|---|---|
| `accept` | `application/json`**required in practice**, see below |
| `Authorization` | `Bearer <access_token>` |
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
| `x-app-version` | `2.1.44.348` |
> **`accept: application/json` is not optional.** Handled errors (codes 103, 112, …) come back as
> JSON regardless, but when the route throws, the server renders the Internet Banking HTML login
> page — under HTTP **200** — instead of a JSON error body. A client without the header then sees a
> "successful" HTML response it cannot parse. All BML API requests set it in `bmlApiRequest()`.
```bash
curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/<base64Url>' \
--header 'accept: application/json' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348'
@@ -116,6 +136,16 @@ curl --request GET \
| `amount` | Payment amount (`"0.00"` for static QRS) |
| `currency` | Currency code (typically `"MVR"`) |
### Failure Responses
`success: false` comes back with a code and a user-facing message; the client shows BML's own
wording and keeps the user on the Transfer screen.
| Code | Message |
|---|---|
| `103` | The payment request has expired |
| `112` | Unsupported payment link |
---
## Step 2 — Pay (3-Step TOTP Flow)
@@ -150,7 +180,7 @@ POST https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments
Expected response: `{ "success": true, "code": 99 }` (OTP required)
> **Note:** This step may be skipped. The app proceeds directly to Step 2b if the gateway already indicates OTP is required.
> **When this step is used:** the client only calls `preInitiatePayment` for **gateway QRs** — QR URLs that begin with `https://pay.bml.com.mv/app/` (`TransferFragment.kt:349, 1419-1423`). For PayMV-native static QRs (`QRS`), Step 2a is skipped and the flow starts at Step 2b.
### Step 2b — Request OTP Channel
@@ -195,6 +225,8 @@ Expected response:
}
```
> **Currency fallback:** if the server's `payload.currency` is blank, the client falls back to the `currency` value sent in the request (`BmlQrPayClient.kt:148`). The same applies to `merchant` and `amount` at the UI layer.
On failure:
```json
@@ -240,4 +272,4 @@ The OTP is a standard TOTP (RFC 6238, SHA-1, 30-second window, 6 digits) derived
---
[← Tap-to-Pay](12-tap-to-pay.md)
[← Tap-to-Pay](12-tap-to-pay.md) &nbsp;&nbsp;&nbsp; **Next →** [Notifications](14-notifications.md)
+171
View File
@@ -0,0 +1,171 @@
# Notifications
In-app notifications (transaction alerts, security events, marketing) are served from a separate host. Notifications are fetched in pages and can be bulk-marked as read.
The polling service runs in the background and posts an Android system notification for each unseen item (`service/NotificationPollingService.kt:64`).
---
## Base URL
```
https://app.bankofmaldives.com.mv/api/v2
```
Distinct from the main `internetbanking/api/mobile` host, but uses the same Bearer token.
---
## Prerequisites
- Valid `access_token` from [OAuth Token Exchange](03-oauth-token.md)
---
## Fetch Notifications
```
GET https://app.bankofmaldives.com.mv/api/v2/notifications?group={group}&page={page}
```
### Query Parameters
| Parameter | Type | Description |
|---|---|---|
| `group` | `string` | Filter by group — `ALL` (default), or a specific group (e.g. `ALERTS`) |
| `page` | `int` | 1-based page number |
### Headers
| Header | Value |
|---|---|
| `Authorization` | `Bearer <access_token>` |
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
| `x-app-version` | `2.1.44.348` |
```bash
curl --request GET \
--url 'https://app.bankofmaldives.com.mv/api/v2/notifications?group=ALL&page=1' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348'
```
### Response
```json
{
"success": true,
"total": 137,
"payload": [
{
"id": "abc123",
"group": "ALERTS",
"type": "TRANSACTION",
"title": "Transaction Alert",
"message": "MVR 100.00 debited from 7730000000001",
"created_at": "2026-05-16T15:10:25",
"is_read": false,
"data": {
"account_number": "7730000000001",
"amount": "100.00",
"currency": "MVR",
"reference": "FT20260516123456"
}
}
]
}
```
### Top-level Fields
| Field | Type | Description |
|---|---|---|
| `success` | `bool` | `true` on success |
| `total` | `int` | Total notification count across all pages |
| `payload` | `array` | List of notifications for this page |
### Notification Object
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Unique notification ID |
| `group` | `string` | Logical grouping (e.g. `ALERTS`) — also the value passed back to the `group` filter |
| `type` | `string` | Sub-type within the group (e.g. `TRANSACTION`) |
| `title` | `string` | Short headline |
| `message` | `string` | Body text |
| `created_at` | `string` | Timestamp — `yyyy-MM-dd'T'HH:mm:ss` (no timezone) |
| `is_read` | `bool` | Read state |
| `data` | `object?` | Optional structured detail payload — fields vary by type |
### `data` Field Flattening
Where present, the `data` object is flattened into the notification's detail view as key-value rows. The client transforms each `data` key with underscore → space and title-case (`BmlNotificationsClient.kt:93-94`):
```
"account_number" → "Account Number"
"reference" → "Reference"
```
Three synthetic rows are prepended:
| Row | Value |
|---|---|
| `Bank` | `BML` |
| `Group` | from `group` field |
| `Type` | from `type` field |
---
## Mark All Read
```
PUT https://app.bankofmaldives.com.mv/api/v2/notifications/read
```
### Headers
| Header | Value |
|---|---|
| `Authorization` | `Bearer <access_token>` |
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
| `x-app-version` | `2.1.44.348` |
| `accept` | `application/json` |
| `Content-Type` | `application/json` |
### Request Body
```json
{
"all": true
}
```
```bash
curl --request PUT \
--url 'https://app.bankofmaldives.com.mv/api/v2/notifications/read' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348' \
--header 'accept: application/json' \
--header 'Content-Type: application/json' \
--data '{"all":true}'
```
### Response
The client treats any 2xx response as success — the response body is discarded.
---
## Polling
`service/NotificationPollingService.kt:64` polls page 1 of every active BML session at a fixed interval, diffs the result against a local cache, and posts an Android system notification for each new item.
---
&nbsp;
---
[← QR Payment](13-qr-payment.md) &nbsp;&nbsp;&nbsp; **Next →** [Card Freeze](15-card-freeze.md)
+121
View File
@@ -0,0 +1,121 @@
# Card Freeze / Unfreeze
Lock or unlock a BML card to block / allow new authorisations. The same endpoint handles both actions, distinguished by the `action` field.
---
## Endpoint
```
POST https://www.bankofmaldives.com.mv/internetbanking/api/mobile/services/card/freeze
```
---
## Prerequisites
- Valid `access_token` from [OAuth Token Exchange](03-oauth-token.md)
- `cardId` is the **internal card UUID** (`BankAccount.internalId`, sourced from the `id` field of a `Card` entry in the [dashboard](04-dashboard.md) response) — NOT the displayed 16-digit card number
---
## Request
### Body
**Content-Type:** `application/json`
```json
{
"card": "<internalId>",
"action": "freeze"
}
```
| Field | Type | Notes |
|---|---|---|
| `card` | `string` | Internal card UUID — the `id` from the dashboard Card object |
| `action` | `string` | `"freeze"` to lock the card; `"unfreeze"` to unlock |
### Headers
| Header | Value |
|---|---|
| `Authorization` | `Bearer <access_token>` |
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
| `x-app-version` | `2.1.44.348` |
| `accept` | `application/json` |
| `Content-Type` | `application/json` |
```bash
curl --request POST \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/services/card/freeze' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348' \
--header 'accept: application/json' \
--header 'Content-Type: application/json' \
--data '{"card":"abc-123-def-456","action":"freeze"}'
```
---
## Response
```json
{
"success": true,
"code": 0,
"payload": "Card frozen successfully",
"message": ""
}
```
### Fields
| Field | Type | Description |
|---|---|---|
| `success` | `bool` | `true` on success |
| `code` | `int` | `0` on success; non-zero indicates an error |
| `payload` | `string` | Human-readable confirmation text (may be blank) |
| `message` | `string` | Fallback error/info text |
Success is determined by **both** `success == true` AND `code == 0` (`BmlCardClient.kt:46`). Either condition alone is not enough.
### Display Message
The client prefers `payload` for the confirmation text and falls back to `message` when `payload` is blank (`BmlCardClient.kt:49`):
```
payload (if non-blank) → fallback message
```
---
## Failure
```json
{
"success": false,
"code": 1,
"payload": "",
"message": "Card cannot be frozen at this time"
}
```
Returned with HTTP `200` for application-level errors. The `message` (or `payload`) field contains the reason.
### Server / Auth Errors
| HTTP Code | Behaviour |
|---|---|
| `401` / `419` | Throws `AuthExpiredException` — refresh the token or re-login |
| `5xx` | Throws `BankServerException("BML")` — server-side failure, retry |
---
&nbsp;
---
[← Notifications](14-notifications.md)
+3
View File
@@ -25,6 +25,7 @@ The login process is stateful and must be executed in order:
| Web login / OAuth | `https://www.bankofmaldives.com.mv/internetbanking` |
| REST API (authenticated) | `https://www.bankofmaldives.com.mv/internetbanking/api/mobile` |
| Foreign limits API | `https://app.bankofmaldives.com.mv/api/v2` |
| Notifications API | `https://app.bankofmaldives.com.mv/api/v2` |
---
@@ -190,6 +191,8 @@ The access token expires after `expires_in` seconds (typically 3600). On a `401`
| 11 | [Foreign Limits](11-foreign-limits.md) | USD foreign transaction limits by card and channel |
| 12 | [Tap-to-Pay](12-tap-to-pay.md) | NFC HCE contactless payment — token fetch and EMV APDU exchange |
| 13 | [QR Payment](13-qr-payment.md) | PayMV QR payment — QR formats, payrequest lookup, 3-step pay flow |
| 14 | [Notifications](14-notifications.md) | Notifications list, mark-as-read, and polling |
| 15 | [Card Freeze](15-card-freeze.md) | Freeze / unfreeze a BML card |
---
+23 -92
View File
@@ -1,112 +1,43 @@
# Profile Picture
Fetch the authenticated user's profile picture. The endpoint redirects to the actual image URL.
Fahipay profile pictures are **stored locally** by the app. There is no Fahipay endpoint involved.
The official Fahipay app exposes `GET https://fahipay.mv/images/profiles/picture/?t={timestamp}` (a 302 redirect to the actual image), but this client never calls it — Fahipay accounts get a user-set picture saved on the device only.
---
## Endpoint
## Storage
```
GET https://fahipay.mv/images/profiles/picture/?t={timestamp}
```
`util/ProfileImageStore.kt` — keyed file storage under `filesDir/profile_images/`.
---
## Prerequisites
- Valid `authID` from [login](01-login.md) or [OTP](02-otp.md)
- Valid `__Secure-sess` session cookie
---
## Request
### Headers
| Header | Value |
| Bank | Key |
|---|---|
| `authid` | `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` |
| `User-Agent` | `okhttp/4.12.0` |
| `Accept-Encoding` | `gzip` |
| `Connection` | `Keep-Alive` |
| `Cookie` | `__Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` |
| Fahipay | `fahipay_{loginId}` |
| BML | `bml_{profileId}` |
| MIB | (n/a — fetched from server via [P41](../mibapi/02-login.md)) |
### Query Parameters
Helpers:
| Parameter | Description | Example |
|---|---|---|
| `t` | Cache-busting timestamp string | `Sat May 16 2026 14:57:52 GMT+0500` |
```kotlin
ProfileImageStore.fahipayKey(loginId) // "fahipay_abc123"
ProfileImageStore.save(context, key, bitmap)
ProfileImageStore.load(context, key) // Bitmap?
ProfileImageStore.delete(context, key)
```
The `t` parameter is a URL-encoded timestamp used to prevent browser caching. The value can be any string — the server ignores it for routing purposes.
Files are JPEGs at quality 90. The filename is the key with non-alphanumerics replaced by `_`.
---
## curl Example
## UI entry points
```bash
curl --request GET \
--url 'https://fahipay.mv/images/profiles/picture/?t=Sat%20Jan%2001%202026%2012:00:00%20GMT+0500' \
--compressed \
--header 'Accept-Encoding: gzip' \
--header 'Connection: Keep-Alive' \
--header 'Cookie: __Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
--header 'User-Agent: okhttp/4.12.0' \
--header 'authid: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
```
Set/replace/remove a Fahipay profile picture from **Settings → Logins** (`ui/home/SettingsLoginsFragment.kt`). The pencil icon next to a Fahipay login opens a chooser:
---
- Pick from gallery (`ACTION_OPEN_DOCUMENT` image/*)
- Take a photo (camera launcher, temp file at `cacheDir/profile_photo_tmp.jpg`)
- Remove current picture
## Response
### Success
The server responds with `HTTP 302` and a `Location` header pointing to the actual image URL.
```
HTTP/1.1 302 Found
Location: https://fahipay.mv/images/profiles/0000/avatar.jpg?v=0000000000
```
Follow the redirect to download the image. The final response is the raw image bytes (`image/jpeg` or `image/png`).
---
### No Picture Set
If the user has not uploaded a profile picture, the redirect points to a default placeholder image:
```
Location: https://fahipay.mv/images/profiles/default.png
```
---
### Error
If the session is invalid, the server returns `HTTP 401` or redirects to an error page.
---
## Implementation Notes
- HTTP clients that follow redirects automatically (e.g. `OkHttpClient` with `followRedirects(true)`) will return the image bytes directly.
- Use `followRedirects(false)` and read the `Location` header if you need the resolved image URL separately.
- The image URL contains the user's `profileID` in the path — this matches the `profileID` field from the [profile response](03-profile.md).
- The `v=` query parameter in the image URL is a version/cache key. It changes when the user updates their picture.
---
## Suggested Usage
```
timestamp = current time formatted as URL-safe string
GET /images/profiles/picture/?t={timestamp}
→ 302 Location: <image URL>
→ GET <image URL>
→ image bytes
```
Cache the downloaded image by `profileID` and re-fetch when the user explicitly refreshes, rather than on every app launch.
Saved images are surfaced anywhere the account is shown — accounts list, contact picker, receipts — via a `localProfileImageLoader` that resolves the key with `ProfileImageStore.load`.
---

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