Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
86e1dc0521
|
|||
|
a90d832dba
|
|||
|
51c2dff4b2
|
|||
|
43f3cca2aa
|
|||
|
0e226d17ae
|
|||
|
24021d7eeb
|
|||
|
e997969070
|
|||
|
3182e14873
|
|||
|
52d2eb235b
|
|||
|
ae18a8c6c8
|
|||
|
a8cd22cbe1
|
|||
|
281864347e
|
|||
|
16fd909c7f
|
|||
|
a95ca0e7a5
|
|||
|
286a6f845d
|
|||
|
5b5f776715
|
|||
|
98990544fc
|
|||
|
798e9da9ca
|
|||
|
014c002ebe
|
|||
|
6f8b7130fe
|
|||
|
05430f043a
|
|||
|
80bbacc130
|
|||
|
570e6b750b
|
|||
|
21fbd8b12c
|
|||
|
d0f46e2118
|
|||
|
71002ed70c
|
|||
|
fbc34d6435
|
|||
|
4b1c2419ec
|
@@ -16,5 +16,7 @@ local.properties
|
||||
docs/mibapi/tmp
|
||||
docs/bmlapi/tmp
|
||||
docs/fahipayapi/tmp
|
||||
docs/mfaisaapi/tmp
|
||||
tmp
|
||||
app/key.jks
|
||||
.kotlin/*
|
||||
|
||||
Generated
+2
-2
@@ -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>
|
||||
|
||||
@@ -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>— FFmpeg (@FFmpeg) <a href="https://x.com/FFmpeg/status/1762805900035686805?ref_src=twsrc%5Etfw">February 28, 2024</a></blockquote>
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ android {
|
||||
applicationId = "sh.sar.basedbank"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 17
|
||||
versionName = "1.0.18"
|
||||
versionCode = 22
|
||||
versionName = "1.0.21"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -116,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 }
|
||||
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -37,12 +37,19 @@ 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
|
||||
@@ -63,6 +70,8 @@ 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
|
||||
@@ -146,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()) {
|
||||
@@ -247,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 -> {
|
||||
@@ -270,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
|
||||
@@ -281,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) }
|
||||
@@ -333,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
|
||||
@@ -704,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 }
|
||||
@@ -809,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 {
|
||||
@@ -833,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() {
|
||||
@@ -887,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
|
||||
@@ -1022,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
|
||||
|
||||
@@ -27,6 +27,7 @@ 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() },
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import sh.sar.basedbank.api.mib.MibTransferResult
|
||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
||||
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
|
||||
import sh.sar.basedbank.util.AccountListParser
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.AccountInputParser
|
||||
@@ -86,6 +87,7 @@ class TransferFragment : Fragment() {
|
||||
private var resolvedAccountNumber = ""
|
||||
private var resolvedRecipientName = ""
|
||||
private var resolvedBankName = ""
|
||||
private var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown
|
||||
private var resolvedToOwnAccount: BankAccount? = null
|
||||
|
||||
// Selected Fahipay service when source is Fahipay and destination is a phone number
|
||||
@@ -104,6 +106,10 @@ class TransferFragment : Fragment() {
|
||||
private var bmlQrInfo: BmlQrPayInfo? = null
|
||||
private var bmlGatewayQr = false // true for pay.bml.com.mv QRs (requires pre-initiate step)
|
||||
private var bmlQrLookupAttempted = false // prevents re-lookup after user clears the merchant
|
||||
|
||||
// M-Faisa QR merchant payment mode (set when the scanned QR is an M-Faisa qrCodeId)
|
||||
private var mfaisaQrInfo: sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient.QrMerchant? = null
|
||||
|
||||
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
||||
|
||||
// BML business profile OTP flow state
|
||||
@@ -129,6 +135,39 @@ class TransferFragment : Fragment() {
|
||||
private var pendingBmlTransfer: PendingBmlTransfer? = null
|
||||
private var accountDropdownAdapter: AccountDropdownAdapter? = null
|
||||
|
||||
/** Lazy: created the first time the user selects an MFAISA source account. */
|
||||
private var mfaisaHandler: MfaisaTransferHandler? = null
|
||||
private fun mfaisaHandler(): MfaisaTransferHandler =
|
||||
mfaisaHandler ?: MfaisaTransferHandler(
|
||||
fragment = this,
|
||||
binding = binding,
|
||||
viewModel = viewModel,
|
||||
onRecipientChanged = {
|
||||
// The handler resolved or cleared a recipient — keep the resolvedAccountNumber
|
||||
// mirror in sync so the shared `updateTransferButton()` / clearForm() logic works.
|
||||
val r = mfaisaHandler?.recipient
|
||||
if (r != null) {
|
||||
resolvedAccountNumber = r.msisdn
|
||||
resolvedRecipientName = r.name
|
||||
resolvedDestCurrency = "MVR"
|
||||
resolvedBankName = "Ooredoo M-Faisa"
|
||||
} else {
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
resolvedDestCurrency = ""
|
||||
resolvedBankName = ""
|
||||
}
|
||||
updateTransferButton()
|
||||
},
|
||||
onTransferSuccess = { receipt, avatar ->
|
||||
ReceiptStore.save(requireContext(), receipt)
|
||||
clearForm()
|
||||
val activity = requireActivity() as HomeActivity
|
||||
activity.triggerRefresh()
|
||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar))
|
||||
}
|
||||
).also { mfaisaHandler = it }
|
||||
|
||||
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
|
||||
@@ -143,6 +182,17 @@ class TransferFragment : Fragment() {
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
// M-Faisa merchant QR — content is just the numeric qrCodeId. Only attempt the lookup
|
||||
// when the user actually has an M-Faisa wallet logged in; otherwise fall through to
|
||||
// PayMV parsing (which will toast "invalid" as before).
|
||||
val trimmedRaw = raw.trim()
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
if (trimmedRaw.length in 8..16 && trimmedRaw.all { it.isDigit() } &&
|
||||
app.mfaisaSessions.isNotEmpty()) {
|
||||
lookupMfaisaQrMerchant(trimmedRaw)
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
val qr = PaymvQrParser.parse(raw)
|
||||
if (qr == null || qr.accountNumber == null) {
|
||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||
@@ -258,6 +308,19 @@ class TransferFragment : Fragment() {
|
||||
lookupBmlQrMerchant(accountNumber.removePrefix("bmlqr:"))
|
||||
return@setFragmentResultListener
|
||||
}
|
||||
if (accountNumber.startsWith("mfaisaqr:")) {
|
||||
// M-Faisa QR merchant recent — re-run the lookup so the merchant stays current
|
||||
// (price / status can change) and so the source auto-switch path is taken.
|
||||
lookupMfaisaQrMerchant(accountNumber.removePrefix("mfaisaqr:"))
|
||||
return@setFragmentResultListener
|
||||
}
|
||||
// MFAISA source + a phone-number pick (e.g. a tagged M-Faisa recent) — re-run the
|
||||
// basicBeneDetails lookup so the recipient gets fully resolved before Send is enabled.
|
||||
if (selectedAccount?.bank == "MFAISA") {
|
||||
binding.etTo.setText(accountNumber)
|
||||
mfaisaHandler().searchRecipient(accountNumber)
|
||||
return@setFragmentResultListener
|
||||
}
|
||||
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
|
||||
val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber
|
||||
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
|
||||
@@ -422,6 +485,99 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private fun lookupMfaisaQrMerchant(qrCodeId: String) {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
// Prefer the currently-selected MFAISA account's session; otherwise fall back to any session.
|
||||
val source = (selectedAccount?.takeIf { it.bank == "MFAISA" }
|
||||
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" })
|
||||
?: run {
|
||||
Toast.makeText(requireContext(), "No M-Faisa account available", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val session = app.mfaisaSessionFor(source) ?: run {
|
||||
Toast.makeText(requireContext(), 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 (selectedAccount?.bank != "MFAISA") {
|
||||
selectedAccount = source
|
||||
updateAmountPrefix(source)
|
||||
showFromCard(source)
|
||||
}
|
||||
|
||||
// Lock the "To" input row while loading
|
||||
binding.tilTo.visibility = View.GONE
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
(activity as? HomeActivity)?.setRefreshing(true)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val merchant = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient().fetchQrDetails(session, qrCodeId)
|
||||
} catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||
?: return@withContext null
|
||||
try { sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient().fetchQrDetails(fresh, qrCodeId) }
|
||||
catch (_: Exception) { null }
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
if (merchant == null) {
|
||||
Toast.makeText(requireContext(), "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
|
||||
resetToFieldVisibility()
|
||||
return@launch
|
||||
}
|
||||
mfaisaQrInfo = 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(requireContext(), 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
|
||||
}
|
||||
|
||||
updateTransferButton()
|
||||
}
|
||||
}
|
||||
|
||||
/** Restores the To-input row to its default state when a QR lookup fails. */
|
||||
private fun resetToFieldVisibility() {
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
binding.tilTo.visibility = View.VISIBLE
|
||||
binding.btnPickContact.visibility = View.VISIBLE
|
||||
binding.btnScanQr.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun startLookupLoading() {
|
||||
val spinner = CircularProgressDrawable(requireContext()).apply {
|
||||
setStyle(CircularProgressDrawable.DEFAULT)
|
||||
@@ -469,6 +625,11 @@ class TransferFragment : Fragment() {
|
||||
return@setOnItemClickListener
|
||||
}
|
||||
}
|
||||
if (mfaisaQrInfo != null && picked.bank != "MFAISA") {
|
||||
Toast.makeText(requireContext(), "Unsupported for M-Faisa QR — select an M-Faisa account", Toast.LENGTH_SHORT).show()
|
||||
binding.actvFrom.setText("", false)
|
||||
return@setOnItemClickListener
|
||||
}
|
||||
selectedAccount = picked
|
||||
updateAmountPrefix(picked)
|
||||
showFromCard(picked)
|
||||
@@ -517,16 +678,61 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle the "To" row's affordances + keyboard hints depending on whether the source is MFAISA. */
|
||||
private fun applyMfaisaToFieldMode(mfaisa: Boolean) {
|
||||
if (mfaisa) {
|
||||
binding.tilTo.hint = getString(R.string.ooredoo_phone)
|
||||
binding.etTo.inputType = android.text.InputType.TYPE_CLASS_PHONE
|
||||
// Any previously-resolved non-MFAISA recipient (or stale state) is no longer valid
|
||||
if (resolvedAccountNumber.isNotBlank() && mfaisaHandler?.recipient == null) {
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
resolvedDestCurrency = ""
|
||||
resolvedToOwnAccount = null
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
binding.tilTo.visibility = View.VISIBLE
|
||||
binding.etTo.setText("")
|
||||
}
|
||||
} else {
|
||||
binding.tilTo.hint = getString(R.string.transfer_to)
|
||||
binding.etTo.inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||
// Drop any M-Faisa-resolved recipient when switching banks
|
||||
if (mfaisaHandler?.recipient != null) {
|
||||
mfaisaHandler?.clearState()
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
resolvedDestCurrency = ""
|
||||
resolvedToOwnAccount = null
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
binding.tilTo.visibility = View.VISIBLE
|
||||
binding.etTo.setText("")
|
||||
}
|
||||
}
|
||||
// The picker and QR-scan icons live alongside the tilTo input. Keep them in sync with
|
||||
// tilTo: when a To-card is rendered (tilTo GONE), they must be GONE too — otherwise
|
||||
// they end up floating above the rendered merchant/recipient card.
|
||||
val showToAffordances = binding.tilTo.visibility == View.VISIBLE
|
||||
binding.btnPickContact.visibility = if (showToAffordances) View.VISIBLE else View.GONE
|
||||
binding.btnScanQr.visibility = if (showToAffordances) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun showFromCard(account: BankAccount) {
|
||||
// Apply per-source "To"-row configuration before painting the card. M-Faisa transfers
|
||||
// only target phone numbers (looked up via basicBeneDetails), so the QR / contact-picker
|
||||
// affordances are hidden and the keyboard is set to phone-numeric.
|
||||
applyMfaisaToFieldMode(account.bank == "MFAISA")
|
||||
|
||||
val colorHex = when (account.bank) {
|
||||
"BML" -> "#0066A1"
|
||||
"FAHIPAY" -> "#15BEA7"
|
||||
"MFAISA" -> "#ED1C24"
|
||||
else -> "#FE860E"
|
||||
}
|
||||
val bankLabel = when (account.bank) {
|
||||
"BML" -> "BML"
|
||||
"FAHIPAY" -> "FP"
|
||||
"MIB" -> "MIB"
|
||||
"MFAISA" -> "M-Faisa"
|
||||
else -> null
|
||||
}
|
||||
val typeLabel = AccountListParser.from(account)?.typeLabel
|
||||
@@ -560,11 +766,18 @@ class TransferFragment : Fragment() {
|
||||
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivFromPhoto.setImageResource(R.drawable.fahipay_logo)
|
||||
}
|
||||
else -> {
|
||||
account.bank == "MFAISA" -> {
|
||||
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivFromPhoto.setImageResource(R.drawable.ooredoo_logo)
|
||||
}
|
||||
account.bank == "MIB" -> {
|
||||
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivFromPhoto.setImageResource(R.drawable.mib_logo)
|
||||
if (account.profileImageHash != null) loadFromPhoto(account.profileImageHash)
|
||||
}
|
||||
else -> {
|
||||
binding.ivFromPhoto.setImageDrawable(null)
|
||||
}
|
||||
}
|
||||
binding.tilFrom.visibility = View.GONE
|
||||
binding.cardFromInfo.visibility = View.VISIBLE
|
||||
@@ -617,11 +830,18 @@ class TransferFragment : Fragment() {
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivToPhoto.setImageResource(R.drawable.fahipay_logo)
|
||||
}
|
||||
else -> {
|
||||
account.bank == "MFAISA" -> {
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
|
||||
}
|
||||
account.bank == "MIB" -> {
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivToPhoto.setImageResource(R.drawable.mib_logo)
|
||||
if (account.profileImageHash != null) loadToPhoto(account.profileImageHash, isProfile = true)
|
||||
}
|
||||
else -> {
|
||||
binding.ivToPhoto.setImageDrawable(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -648,7 +868,14 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun setupAccountLookup() {
|
||||
binding.tilTo.setEndIconOnClickListener { lookupAccount() }
|
||||
binding.tilTo.setEndIconOnClickListener {
|
||||
// M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails)
|
||||
if (selectedAccount?.bank == "MFAISA") {
|
||||
mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty())
|
||||
} else {
|
||||
lookupAccount()
|
||||
}
|
||||
}
|
||||
|
||||
binding.btnClearToInfo.setOnClickListener {
|
||||
if (bmlQrInfo != null) {
|
||||
@@ -659,16 +886,27 @@ class TransferFragment : Fragment() {
|
||||
binding.tilRemarks.alpha = 1f
|
||||
binding.etAmount.setText("")
|
||||
}
|
||||
if (mfaisaQrInfo != null) {
|
||||
mfaisaQrInfo = null
|
||||
binding.tilAmount.isEnabled = true
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
binding.etAmount.setText("")
|
||||
}
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
resolvedDestCurrency = ""
|
||||
resolvedToOwnAccount = null
|
||||
selectedFahipayService = null
|
||||
mfaisaHandler?.clearState()
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
binding.layoutServiceSelector.visibility = View.INVISIBLE
|
||||
binding.tilTo.visibility = View.VISIBLE
|
||||
binding.btnPickContact.visibility = View.VISIBLE
|
||||
binding.btnScanQr.visibility = View.VISIBLE
|
||||
binding.tilTo.error = null
|
||||
// Re-apply MFAISA-mode if needed (hides QR/contact picker + sets phone keyboard)
|
||||
applyMfaisaToFieldMode(selectedAccount?.bank == "MFAISA")
|
||||
updateTransferButton()
|
||||
}
|
||||
|
||||
@@ -677,11 +915,14 @@ class TransferFragment : Fragment() {
|
||||
if (binding.cardToInfo.visibility == View.VISIBLE) {
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
resolvedDestCurrency = ""
|
||||
resolvedToOwnAccount = null
|
||||
mfaisaHandler?.clearState()
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
binding.tilTo.visibility = View.VISIBLE
|
||||
binding.btnPickContact.visibility = View.VISIBLE
|
||||
binding.btnScanQr.visibility = View.VISIBLE
|
||||
applyMfaisaToFieldMode(selectedAccount?.bank == "MFAISA")
|
||||
updateTransferButton()
|
||||
}
|
||||
}
|
||||
@@ -760,7 +1001,16 @@ class TransferFragment : Fragment() {
|
||||
"IAT" -> "MALBMVMV"
|
||||
else -> bmlResult.agnt ?: bmlResult.account
|
||||
}
|
||||
MibIpsAccountInfo(accountName = bmlResult.name, accountNumber = bmlResult.account, bankId = bankId)
|
||||
// BML's MIB verify endpoint doesn't return the account's currency.
|
||||
// Enrich via MIB lookup when a MIB session is available.
|
||||
val currency = if (
|
||||
inputType == AccountInputParser.InputType.MIB_ACCOUNT &&
|
||||
bmlResult.currency.isBlank() && mibSess != null
|
||||
) {
|
||||
try { MibTransferClient().lookup(mibSess, bmlResult.account).currency }
|
||||
catch (_: Exception) { "" }
|
||||
} else bmlResult.currency
|
||||
MibIpsAccountInfo(accountName = bmlResult.name, accountNumber = bmlResult.account, bankId = bankId, currency = currency)
|
||||
} else if (mibSess != null) {
|
||||
try { MibTransferClient().lookup(mibSess, accountNumber) }
|
||||
catch (e: MibLookupException) { errorMsg = e.message; null }
|
||||
@@ -769,21 +1019,29 @@ class TransferFragment : Fragment() {
|
||||
errorMsg = getString(R.string.transfer_account_not_found); null
|
||||
}
|
||||
} else {
|
||||
if (mibSess != null) {
|
||||
val mibInfo = if (mibSess != null) {
|
||||
try { MibTransferClient().lookup(mibSess, accountNumber) }
|
||||
catch (e: MibLookupException) { errorMsg = e.message; null }
|
||||
catch (_: Exception) { errorMsg = getString(R.string.transfer_account_not_found); null }
|
||||
} else {
|
||||
val bmlResult = try { BmlValidateClient().validateAccount(bmlSess!!, accountNumber) } catch (_: Exception) { null }
|
||||
} else null
|
||||
if (mibInfo != null) {
|
||||
mibInfo
|
||||
} else if (bmlSess != null) {
|
||||
val bmlResult = try { BmlValidateClient().validateAccount(bmlSess, accountNumber) } catch (_: Exception) { null }
|
||||
if (bmlResult != null) {
|
||||
errorMsg = null
|
||||
val bankId = when (bmlResult.trnType) {
|
||||
"IAT" -> "MALBMVMV"
|
||||
else -> bmlResult.agnt ?: bmlResult.account
|
||||
}
|
||||
MibIpsAccountInfo(accountName = bmlResult.name, accountNumber = bmlResult.account, bankId = bankId)
|
||||
MibIpsAccountInfo(accountName = bmlResult.name, accountNumber = bmlResult.account, bankId = bankId, currency = bmlResult.currency)
|
||||
} else {
|
||||
errorMsg = getString(R.string.transfer_account_not_found); null
|
||||
if (errorMsg == null) errorMsg = getString(R.string.transfer_account_not_found)
|
||||
null
|
||||
}
|
||||
} else {
|
||||
if (errorMsg == null) errorMsg = getString(R.string.transfer_account_not_found)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -799,6 +1057,7 @@ class TransferFragment : Fragment() {
|
||||
resolvedAccountNumber = info.accountNumber
|
||||
resolvedRecipientName = info.accountName
|
||||
resolvedBankName = info.bankId
|
||||
resolvedDestCurrency = info.currency
|
||||
savedToSubtitle = "${info.accountNumber} · ${info.bankId}"
|
||||
savedToColorHex = colorHex
|
||||
savedToImageHash = when {
|
||||
@@ -963,22 +1222,37 @@ class TransferFragment : Fragment() {
|
||||
updateTransferButton()
|
||||
|
||||
val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
|
||||
if (contact != null) {
|
||||
RecentsCache.save(requireContext(), RecentPick(
|
||||
accountNumber = contact.benefAccount,
|
||||
displayName = contact.benefNickName,
|
||||
subtitle = subtitle,
|
||||
colorHex = colorHex,
|
||||
imageHash = imageHash,
|
||||
isProfileImage = false
|
||||
))
|
||||
if (imageHash != null) loadToPhoto(imageHash, isProfile = false)
|
||||
val recentImageHash: String?
|
||||
val recentIsProfileImage: Boolean
|
||||
when {
|
||||
ownAccount != null -> {
|
||||
recentImageHash = ownAccount.profileImageHash
|
||||
recentIsProfileImage = true
|
||||
}
|
||||
contact != null -> {
|
||||
recentImageHash = contact.customerImgHash ?: imageHash
|
||||
recentIsProfileImage = false
|
||||
}
|
||||
else -> {
|
||||
recentImageHash = imageHash
|
||||
recentIsProfileImage = false
|
||||
}
|
||||
}
|
||||
RecentsCache.save(requireContext(), RecentPick(
|
||||
accountNumber = accountNumber,
|
||||
displayName = displayName,
|
||||
subtitle = subtitle,
|
||||
colorHex = colorHex,
|
||||
imageHash = recentImageHash,
|
||||
isProfileImage = recentIsProfileImage
|
||||
))
|
||||
if (contact != null && imageHash != null) loadToPhoto(imageHash, isProfile = false)
|
||||
}
|
||||
|
||||
private fun prefillToFromContact(accountNumber: String, label: String) {
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
resolvedDestCurrency = ""
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
binding.tilTo.visibility = View.VISIBLE
|
||||
binding.btnPickContact.visibility = View.VISIBLE
|
||||
@@ -1047,6 +1321,51 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun initiateTransfer() {
|
||||
// M-Faisa merchant QR — same confirm popup as other transfers. The /initiateNewBuy +
|
||||
// /confirmNewBuy pair does NOT require OTP for wallet QR pay (2FARequired=NONE).
|
||||
mfaisaQrInfo?.let { merchant ->
|
||||
val src = selectedAccount ?: run {
|
||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
if (src.bank != "MFAISA") {
|
||||
Toast.makeText(requireContext(), "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 confirmView = buildTransferConfirmView(
|
||||
amountCurrency = merchant.currencyCode,
|
||||
amountValue = "%.2f".format(amount),
|
||||
fromName = src.accountBriefName,
|
||||
fromNumber = src.accountNumber,
|
||||
fromDetail = "M-Faisa",
|
||||
toName = merchant.merchantName,
|
||||
toNumber = merchant.merchantMsisdn,
|
||||
toDetail = "Ooredoo M-Faisa merchant"
|
||||
)
|
||||
showConfirmWithBiometric(
|
||||
title = getString(R.string.transfer),
|
||||
customView = confirmView,
|
||||
biometricSubtitle = "${merchant.currencyCode} ${"%.2f".format(amount)} → ${merchant.merchantName}",
|
||||
onConfirmed = { dialog, frame ->
|
||||
showProcessingInDialog(dialog, frame)
|
||||
executeMfaisaQrPayment(src, merchant, amount, "%.2f".format(amount), remarks, dialog, frame)
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// M-Faisa source: the entire flow (initiate + OTP + confirm) lives in the handler.
|
||||
if (selectedAccount?.bank == "MFAISA") {
|
||||
mfaisaHandler().submit()
|
||||
return
|
||||
}
|
||||
|
||||
// BML QR merchant payment — uses shared confirm dialog, no receipt
|
||||
bmlQrInfo?.let { info ->
|
||||
val src = selectedAccount ?: run {
|
||||
@@ -1115,10 +1434,27 @@ class TransferFragment : Fragment() {
|
||||
if (isSrcBml && isDestMib && currency == "USD") {
|
||||
val hasBmlContact = allContacts.any { it.benefCategoryId == "BML" && it.benefAccount == resolvedAccountNumber }
|
||||
if (!hasBmlContact) {
|
||||
// If we verified the dest currency via MIB fallback, the block is purely a BML API limitation.
|
||||
// Otherwise (no MIB session, currency truly unknown) the generic message applies.
|
||||
val msgRes = if (resolvedDestCurrency.isNotBlank())
|
||||
R.string.transfer_bml_contact_required_msg_bml_limit
|
||||
else
|
||||
R.string.transfer_bml_contact_required_msg
|
||||
val bmlProfileId = src.profileId.takeIf { it.isNotBlank() }
|
||||
?: src.loginTag.removePrefix("bml_").takeIf { it.isNotBlank() }
|
||||
val verified = resolvedDestCurrency.isNotBlank()
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.transfer_bml_contact_required_title)
|
||||
.setMessage(R.string.transfer_bml_contact_required_msg)
|
||||
.setPositiveButton(R.string.close, null)
|
||||
.setMessage(msgRes)
|
||||
.setPositiveButton(R.string.contact_save) { _, _ ->
|
||||
AddContactSheetFragment.newInstance(
|
||||
bmlProfileId = bmlProfileId,
|
||||
accountNumber = resolvedAccountNumber,
|
||||
recipientName = if (verified) resolvedRecipientName else null,
|
||||
currency = if (verified) resolvedDestCurrency else null
|
||||
).show(parentFragmentManager, "add_contact")
|
||||
}
|
||||
.setNegativeButton(R.string.close, null)
|
||||
.show()
|
||||
return
|
||||
}
|
||||
@@ -1128,11 +1464,13 @@ class TransferFragment : Fragment() {
|
||||
val bankNameCapture = resolvedBankName
|
||||
val capturedToAvatar = (binding.ivToPhoto.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
|
||||
|
||||
val destCurrency = allAccounts.firstOrNull { it.accountNumber == resolvedAccountNumber }
|
||||
?.currencyName?.ifBlank { "MVR" }
|
||||
?: allContacts.firstOrNull { it.benefAccount == resolvedAccountNumber }
|
||||
?.transferCyDesc?.ifBlank { "MVR" }
|
||||
?: if (isDestMib) "MVR" else "MVR"
|
||||
val destCurrency = resolvedDestCurrency.ifBlank {
|
||||
allAccounts.firstOrNull { it.accountNumber == resolvedAccountNumber }
|
||||
?.currencyName?.ifBlank { "MVR" }
|
||||
?: allContacts.firstOrNull { it.benefAccount == resolvedAccountNumber }
|
||||
?.transferCyDesc?.ifBlank { "MVR" }
|
||||
?: "MVR"
|
||||
}
|
||||
val isUsdToMvr = currency.equals("USD", ignoreCase = true) && destCurrency.equals("MVR", ignoreCase = true)
|
||||
val isSrcCredit = src.profileType == "BML_CREDIT"
|
||||
|
||||
@@ -1236,7 +1574,7 @@ class TransferFragment : Fragment() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildTransferConfirmView(
|
||||
internal fun buildTransferConfirmView(
|
||||
amountCurrency: String,
|
||||
amountValue: String,
|
||||
fromName: String,
|
||||
@@ -1418,6 +1756,93 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeMfaisaQrPayment(
|
||||
src: BankAccount,
|
||||
merchant: sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient.QrMerchant,
|
||||
amount: Double,
|
||||
amountStr: String,
|
||||
remarks: String,
|
||||
dialog: AlertDialog,
|
||||
frame: android.widget.FrameLayout
|
||||
) {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val loginId = src.loginTag.removePrefix("mfaisa_")
|
||||
val initialSession = app.mfaisaSessionFor(src) ?: run {
|
||||
dialog.dismiss()
|
||||
Toast.makeText(requireContext(), 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
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val outcome = withContext(Dispatchers.IO) {
|
||||
val client = sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient()
|
||||
try {
|
||||
val refId = try {
|
||||
client.initiatePurchase(initialSession, src.accountNumber, sourceMdn, merchant, amountStr, remarks)
|
||||
} catch (_: sh.sar.basedbank.api.mfaisa.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 (_: sh.sar.basedbank.api.mfaisa.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 (_binding == 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()
|
||||
)
|
||||
ReceiptStore.save(requireContext(), receipt)
|
||||
dialog.dismiss()
|
||||
clearForm()
|
||||
val activity = requireActivity() as HomeActivity
|
||||
activity.triggerRefresh()
|
||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, null))
|
||||
},
|
||||
onFailure = { e ->
|
||||
dialog.dismiss()
|
||||
binding.btnTransfer.isEnabled = true
|
||||
val msg = when {
|
||||
e is java.io.IOException -> getString(R.string.connectivity_no_internet)
|
||||
!e.message.isNullOrBlank() -> e.message!!
|
||||
else -> "Payment failed"
|
||||
}
|
||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showProcessingInDialog(dialog: AlertDialog, frame: android.widget.FrameLayout) {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE
|
||||
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
|
||||
@@ -2009,7 +2434,7 @@ class TransferFragment : Fragment() {
|
||||
private fun updateTransferButton() {
|
||||
if (bmlOtpState != BmlOtpState.NONE) return
|
||||
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
||||
val recipientReady = if (bmlQrInfo != null) bmlQrInfo != null else resolvedAccountNumber.isNotBlank()
|
||||
val recipientReady = bmlQrInfo != null || mfaisaQrInfo != null || resolvedAccountNumber.isNotBlank()
|
||||
val hasAll = selectedAccount != null && recipientReady && amount > 0
|
||||
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
|
||||
val errors = viewModel.connectivityErrors.value ?: emptySet()
|
||||
@@ -2020,16 +2445,22 @@ class TransferFragment : Fragment() {
|
||||
|
||||
private fun clearForm() {
|
||||
resetBmlOtpState()
|
||||
mfaisaHandler?.clearState()
|
||||
mfaisaQrInfo = null
|
||||
selectedAccount = null
|
||||
binding.actvFrom.setText("", false)
|
||||
binding.cardFromInfo.visibility = View.GONE
|
||||
binding.tilFrom.visibility = View.VISIBLE
|
||||
binding.tilAmount.prefixText = null
|
||||
binding.tilAmount.isEnabled = true
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
binding.etAmount.setText("")
|
||||
binding.etRemarks.setText("")
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
resolvedBankName = ""
|
||||
resolvedDestCurrency = ""
|
||||
resolvedToOwnAccount = null
|
||||
selectedFahipayService = null
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
@@ -2139,6 +2570,9 @@ class TransferFragment : Fragment() {
|
||||
bmlOtpState = BmlOtpState.NONE
|
||||
pendingBmlTransfer = null
|
||||
bmlOtpChannel = null
|
||||
// The M-Faisa handler holds binding refs; drop it so the next view gets a fresh one.
|
||||
mfaisaHandler?.clearState()
|
||||
mfaisaHandler = null
|
||||
_binding = null
|
||||
}
|
||||
|
||||
@@ -2298,6 +2732,10 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
imageView.visibility = View.VISIBLE
|
||||
}
|
||||
acc.bank == "MFAISA" -> {
|
||||
b.ivDropdownCardLogo.setImageResource(R.drawable.ooredoo_logo)
|
||||
b.ivDropdownCardLogo.visibility = View.VISIBLE
|
||||
}
|
||||
else -> b.ivDropdownCardLogo.visibility = View.GONE
|
||||
}
|
||||
b.root
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -38,6 +38,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 +69,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 +97,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,6 +270,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() {
|
||||
@@ -366,14 +429,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() }
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
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.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.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. Lives alongside [sh.sar.basedbank.ui.home.TransferFragment]
|
||||
* which dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source.
|
||||
*
|
||||
* 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: Fragment,
|
||||
private val binding: FragmentTransferBinding,
|
||||
private val viewModel: HomeViewModel,
|
||||
/** 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()
|
||||
|
||||
/** Set to the resolved recipient after a successful search; null otherwise. */
|
||||
var recipient: MfaisaTransferClient.Recipient? = 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 = currentSource() ?: 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 = currentSource() ?: 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)
|
||||
}
|
||||
}
|
||||
|
||||
/** Called when the source account changes away from M-Faisa (or the view tears down). */
|
||||
fun clearState() {
|
||||
recipient = null
|
||||
lookupInFlight = false
|
||||
}
|
||||
|
||||
// ─── Internal ────────────────────────────────────────────────────────────
|
||||
|
||||
private fun currentSource(): BankAccount? =
|
||||
viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" && it.accountNumber == sourceAccountNumberFromCard() }
|
||||
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" } // fallback if from-card field isn't easily readable
|
||||
|
||||
private fun sourceAccountNumberFromCard(): String =
|
||||
binding.tvFromAccountNumber.text?.toString().orEmpty()
|
||||
|
||||
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 tf = fragment as? TransferFragment ?: return
|
||||
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 = tf.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: Exception) {
|
||||
val msg = when {
|
||||
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
|
||||
!e.message.isNullOrBlank() -> e.message!!
|
||||
else -> "Transfer failed"
|
||||
}
|
||||
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -30,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
|
||||
@@ -102,6 +108,19 @@ class CredentialsFragment : Fragment() {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
binding.btnLogin.isEnabled = false
|
||||
@@ -133,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()
|
||||
@@ -147,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() {
|
||||
@@ -170,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
|
||||
}
|
||||
}
|
||||
@@ -204,6 +224,7 @@ class CredentialsFragment : Fragment() {
|
||||
when (bankType) {
|
||||
"BML" -> { attemptBmlLogin(); return }
|
||||
"FAHIPAY" -> { attemptFahipayLogin(); return }
|
||||
"OOREDOO" -> { attemptMfaisaLogin(); return }
|
||||
}
|
||||
|
||||
val username = binding.etUsername.text.toString().trim()
|
||||
@@ -411,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> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
<?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 & 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_marginHorizontal="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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -139,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>
|
||||
@@ -192,6 +197,17 @@
|
||||
<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>
|
||||
@@ -269,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>
|
||||
@@ -358,7 +375,17 @@
|
||||
<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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' \
|
||||
|
||||
@@ -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,7 +179,12 @@ 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 |
|
||||
|---|---|---|
|
||||
@@ -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` |
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -150,7 +150,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 +195,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 +242,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) **Next →** [Notifications](14-notifications.md)
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← QR Payment](13-qr-payment.md) **Next →** [Card Freeze](15-card-freeze.md)
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← Notifications](14-notifications.md)
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -42,14 +42,14 @@ GET https://fahipay.mv/api/app/favs/?page={serviceName}&lang=en
|
||||
|
||||
## Service Groups
|
||||
|
||||
Call this endpoint once per service group:
|
||||
Call this endpoint once per service group. Labels shown are the ones the app surfaces in the UI (`FahipayContactsClient.kt:22-25`):
|
||||
|
||||
| `page` value | Service | Description |
|
||||
|---|---|---|
|
||||
| `ooredooraastas` | Ooredoo Raastas | Ooredoo mobile top-up |
|
||||
| `dhiraagureload` | Dhiraagu Reload | Dhiraagu mobile top-up |
|
||||
| `ooredoobillpay` | Ooredoo Bill | Ooredoo bill payment |
|
||||
| `dhiraagubillpay` | Dhiraagu Bill | Dhiraagu bill payment |
|
||||
| `page` value | Group label | `benefCategoryId` | Description |
|
||||
|---|---|---|---|
|
||||
| `ooredooraastas` | `Raastas` | `FAHIPAY_RAASTAS` | Ooredoo mobile top-up |
|
||||
| `dhiraagureload` | `Reload` | `FAHIPAY_RELOAD` | Dhiraagu mobile top-up |
|
||||
| `ooredoobillpay` | `Ooredoo Bill` | `FAHIPAY_OOREDOO_BILL` | Ooredoo bill payment |
|
||||
| `dhiraagubillpay` | `Dhiraagu Bill` | `FAHIPAY_DHIRAAGU_BILL` | Dhiraagu bill payment |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ Client Server
|
||||
| 3 | [Profile](03-profile.md) | Fetch user profile and linked bank accounts |
|
||||
| 4 | [Balance](04-balance.md) | Fetch wallet balance |
|
||||
| 5 | [Transaction History](05-history.md) | Paginated activity/transaction history |
|
||||
| 6 | [Profile Picture](06-profile-picture.md) | Fetch user profile picture |
|
||||
| 6 | [Profile Picture](06-profile-picture.md) | Local-only profile picture storage (no Fahipay endpoint) |
|
||||
| 7 | [Saved Favourites](07-contacts.md) | Fetch saved contacts per payment service |
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
# Encryption & Anti-Replay
|
||||
|
||||
Every M-Faisa endpoint at `superapp.ooredoo.mv` mixes three things on the wire:
|
||||
|
||||
1. **Field-level RSA encryption** for the mobile number (`mdnId` / `mobileNumber` / `userName` / `initiatingMDN` / `identifier`) and the mPIN.
|
||||
2. An **anti-replay envelope** (`rndValue` + `csValue`) on every session-scoped form-encoded POST.
|
||||
3. A **Gson `htmlSafe` JSON quirk** that turns every literal `=` inside the JSON payload into `=`.
|
||||
|
||||
This document is the single source of truth for all three. The endpoint docs ([login](02-login.md), [history](03-history.md), [transfer](04-transfer.md)) just reference back here.
|
||||
|
||||
---
|
||||
|
||||
## RSA keys
|
||||
|
||||
Two distinct RSA public keys are used. Both live obfuscated inside `libnative-lib.so` (file offsets given for app version `10.3.1`, `versionCode = 101349`).
|
||||
|
||||
| Purpose | Bit length | JNI source | Cipher | Output format |
|
||||
|---|---|---|---|---|
|
||||
| Mobile / mdnId / mobileNumber / userName / initiatingMDN / identifier | 1024 | `getBCPublicKeyImpl()` (obfuscated, see below) | `RSA/ECB/OAEPWithSHA-256AndMGF1Padding` | base64 (`NO_WRAP`) — 172 chars + `=` padding |
|
||||
| mPin / rndValue / OTP code | 2048 | `getRSAModulusImpl()` (modulus hex) + `getRSAExponentImpl()` (`"10001"`) | `RSA/ECB/OAEPWithSHA-1AndMGF1Padding` | lowercase hex — 512 chars |
|
||||
|
||||
The native-function names are deliberately misleading — `getRSAModulusImpl` returns the modulus *as a hex string*, `getRSAExponentImpl` returns the exponent (also hex, always `"10001"`), and `getBCPublicKeyImpl` returns the mobile key in a custom obfuscated form (not a hex modulus).
|
||||
|
||||
### Mobile key (1024-bit)
|
||||
|
||||
```
|
||||
N = 12504370852445171564296397369840670875526950229356546060611893054268
|
||||
22759715800327041313624881501743511944071724521752756122840313665124
|
||||
84449720820820404229217064541745811143629538982383390723079478499614
|
||||
16062061691167925660329675284421662011306487434253185147285131906525
|
||||
8962732556596958868200227678294957694889
|
||||
E = 65537
|
||||
```
|
||||
|
||||
The plaintext is **always `"960" + msisdn`** (i.e. the local number prefixed with the Maldives country code, e.g. `9601234567`). The trailing `=` from base64 encoding survives the wire — the server's strict JSON parser actually relies on it.
|
||||
|
||||
### mPin key (2048-bit)
|
||||
|
||||
```
|
||||
N (hex, 512 chars) = f46921c7091b315f8b9b20ef548deac32ff5b519a2e9ace2f971cc82a341a90eca39…
|
||||
…d419274db7b
|
||||
E (hex) = 10001 (= 65537)
|
||||
```
|
||||
|
||||
The full hex modulus is the string returned by `SecurityConfig.m()` in the official app; see `MfaisaCrypto.kt` for the value as a decimal `BigInteger`.
|
||||
|
||||
The plaintext is `pin + <6-character random salt>`. The salt is drawn fresh on every encrypt from `[A-Za-z0-9]` (62-character alphabet) — e.g. `1234aB3xQz`. It exists only to keep the OAEP plaintext above a minimum length; the server discards it after decryption.
|
||||
|
||||
The same routine is reused for any short string that needs anti-replay (OTP codes, the `rndValue` nonce) — see below.
|
||||
|
||||
---
|
||||
|
||||
## Reference implementation (Kotlin)
|
||||
|
||||
```kotlin
|
||||
object MfaisaCrypto {
|
||||
private val MOBILE_N = BigInteger("125043708524451715642963973…7694889")
|
||||
private val MOBILE_E = BigInteger("65537")
|
||||
|
||||
private val PIN_N = BigInteger("30853988905151679601945771998…504123")
|
||||
private val PIN_E = BigInteger("65537")
|
||||
|
||||
private val mobileKey by lazy { rsaPublicKey(MOBILE_N, MOBILE_E) }
|
||||
private val pinKey by lazy { rsaPublicKey(PIN_N, PIN_E) }
|
||||
|
||||
private val random = SecureRandom()
|
||||
private const val SALT_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
/** RSA-OAEP-SHA256 of "960" + msisdn → base64 NO_WRAP. */
|
||||
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)
|
||||
}
|
||||
|
||||
/** RSA-OAEP-SHA1 of `<value> + <6-char random salt>` → lowercase hex. */
|
||||
fun encryptPin(value: String): String {
|
||||
val salt = (1..6).map { SALT_ALPHABET[random.nextInt(SALT_ALPHABET.length)] }.joinToString("")
|
||||
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((value + salt).toByteArray(Charsets.UTF_8))
|
||||
return ct.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun rsaPublicKey(n: BigInteger, e: BigInteger): PublicKey =
|
||||
KeyFactory.getInstance("RSA").generatePublic(RSAPublicKeySpec(n, e))
|
||||
}
|
||||
```
|
||||
|
||||
The OAEP parameter spec **must be passed explicitly**. Android's `Cipher.init(mode, key)` without an `OAEPParameterSpec` defaults the MGF1 digest to SHA-1, even for `RSA/ECB/OAEPWithSHA-256AndMGF1Padding` — the server rejects this mismatch with HTTP 400.
|
||||
|
||||
---
|
||||
|
||||
## Reference implementation (Python)
|
||||
|
||||
```python
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa, padding
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
import base64, secrets, string
|
||||
|
||||
MOBILE_N = 12504370852445171564…7694889
|
||||
PIN_N = 30853988905151679601…504123
|
||||
E = 65537
|
||||
|
||||
def encrypt_mobile(msisdn: str) -> str:
|
||||
key = rsa.RSAPublicNumbers(E, MOBILE_N).public_key()
|
||||
ct = key.encrypt(
|
||||
("960" + msisdn).encode("utf-8"),
|
||||
padding.OAEP(mgf=padding.MGF1(hashes.SHA256()), algorithm=hashes.SHA256(), label=None),
|
||||
)
|
||||
return base64.b64encode(ct).decode("ascii")
|
||||
|
||||
def encrypt_pin(value: str) -> str:
|
||||
salt = "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(6))
|
||||
key = rsa.RSAPublicNumbers(E, PIN_N).public_key()
|
||||
ct = key.encrypt(
|
||||
(value + salt).encode("utf-8"),
|
||||
padding.OAEP(mgf=padding.MGF1(hashes.SHA1()), algorithm=hashes.SHA1(), label=None),
|
||||
)
|
||||
return ct.hex()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anti-replay envelope: `rndValue` + `csValue`
|
||||
|
||||
Every session-scoped form-encoded POST (history, transfer, recipient-lookup, …) carries two extra fields the server uses to detect replays:
|
||||
|
||||
```kotlin
|
||||
val offset = (Random.nextInt(5) + 10) xor 0xE // small noise: 0, 2, 3, 4, or 5
|
||||
val nonceStr = (System.currentTimeMillis() + offset).toString()
|
||||
val rndValue = MfaisaCrypto.encryptPin(nonceStr) // RSA-OAEP-SHA1 of nonceStr+salt, hex
|
||||
val csValue = Adler32().apply {
|
||||
update((formDataJson + nonceStr).toByteArray(Charsets.UTF_8))
|
||||
}.value.toString() // decimal string of the 32-bit Adler32 sum
|
||||
```
|
||||
|
||||
- **`rndValue`** is an `encryptPin(...)` of the timestamp string — the SAME 2048-bit RSA key + OAEP-SHA1 routine documented above. The 6-char salt added by `encryptPin` makes every encrypt non-deterministic.
|
||||
- **`csValue`** is `Adler32(formDataJson || nonceStr)` rendered in decimal. The server recomputes this; tampering with `formData` after generating `csValue` will cause rejection.
|
||||
- The offset (`(rand0-4 + 10) xor 14` → {0, 2, 3, 4, 5}) is a tiny bit of fixed noise on the timestamp. It exists in the official app's bytecode; the server tolerates any timestamp within a few seconds of `now` anyway.
|
||||
|
||||
`csValue` is computed from the **pre-html-escape** `formData` JSON (see next section) — i.e. the same string the server reads off the wire.
|
||||
|
||||
---
|
||||
|
||||
## HTML-safe Gson `=` escape
|
||||
|
||||
The official app serialises every JSON payload with Gson in `htmlSafe = true` mode. The relevant side effect for the wire format: any literal `=` character becomes the six-byte sequence `=`.
|
||||
|
||||
This matters because:
|
||||
- The base64 ciphertexts in `mdnId` / `mobileNumber` / `userName` / `initiatingMDN` / `identifier` always end with `=` (1024-bit ⇒ 128-byte output ⇒ 172 chars + 1 padding `=`).
|
||||
- The M-Faisa server's JSON parser is strict — sending the literal `=` instead of `=` returns HTTP 400 *even though both are valid JSON*.
|
||||
|
||||
Match the on-wire form with a simple string replace before sending:
|
||||
|
||||
```kotlin
|
||||
private fun String.matchGsonHtmlSafe(): String =
|
||||
replace("\\/", "/").replace("=", "\\u003d")
|
||||
```
|
||||
|
||||
The `\/` → `/` swap covers the corresponding `org.json` quirk that escapes forward slashes by default — also rejected by the server.
|
||||
|
||||
The same `csValue` / `rndValue` pair must be computed against the **escaped** string (i.e. exactly what's sent on the wire).
|
||||
|
||||
---
|
||||
|
||||
## The `getBCPublicKeyImpl` riddle
|
||||
|
||||
`SecurityConfig.d()` in the JVM bytecode returns a 231-character obfuscated string that, after a `replace("MeWtSVjV3Mj","").trim() + "="` cleanup and base64 decode, *should* yield an ASN.1 `SEQUENCE { INTEGER N, INTEGER E }`. **It does not** — the bytes start with `0x10` instead of the `0x30` ASN.1 SEQUENCE tag, and BouncyCastle's `ASN1Sequence.getInstance(bytes)` throws `unknown tag 16 encountered`.
|
||||
|
||||
The cleaned form the runtime actually feeds into `j()` starts `MIGJAoGB…AAE=` (i.e. a valid 1024-bit `RSAPublicKey` SEQUENCE). The transformation from the obfuscated `EAABMgAp…` to `MIGJAoGB…` is NOT what the visible Kotlin bytecode does — strongly suggesting Pairip-style VM protection (`com.pairip.licensecheck` is present in the APK) intercepts the call at runtime.
|
||||
|
||||
**Practical consequence:** the only reliable way to recover the BC public key is to hook the running app and dump `j()`'s input. A Frida script for that is checked in at `tmp/ooredoo_hook.js`. Once dumped, the key matches the `MOBILE_N` value above — so unless Ooredoo rotates keys, the values in `MfaisaCrypto.kt` are stable and the Frida step is one-off.
|
||||
|
||||
If the keys ever rotate, the **mPin key** (`SecurityConfig.m()` + `SecurityConfig.l()`) can be re-extracted purely statically — both `m()` and `l()` return plain hex strings of the modulus and exponent respectively. Only the mobile key needs Frida.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
> **Next →** [Login](02-login.md) | **← Back to** [README](README.md)
|
||||
@@ -0,0 +1,220 @@
|
||||
# Login
|
||||
|
||||
Authenticate a user with their Ooredoo mobile number and 4-digit M-Faisa mPIN.
|
||||
|
||||
The flow is two requests:
|
||||
|
||||
1. `fetchSubscriberByMDN` — confirms the number has a registered, fully-KYC'd M-Faisa wallet before asking for the mPIN.
|
||||
2. `doMobileLogin` — submits the mPIN and (on success) returns the session + pocket details.
|
||||
|
||||
All RSA encryption used below is specified in detail in [01-encryption.md](01-encryption.md) — the mobile-key cipher is `RSA/ECB/OAEPWithSHA-256AndMGF1Padding` with the plaintext `"960" + msisdn`; the mPin cipher is `RSA/ECB/OAEPWithSHA-1AndMGF1Padding` with the plaintext `pin + <6-char salt>`.
|
||||
|
||||
---
|
||||
|
||||
## Step 1: `fetchSubscriberByMDN`
|
||||
|
||||
Confirms the number has a usable M-Faisa wallet before prompting for the mPIN.
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/fetchSubscriberByMDN
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
**Content-Type:** `application/json; charset=UTF-8`
|
||||
|
||||
```json
|
||||
{ "mdnId": "<encryptMobile(msisdn), base64>" }
|
||||
```
|
||||
|
||||
### curl Example
|
||||
|
||||
```bash
|
||||
MDN_ENC=$(python tmp/mfaisa_encrypt.py mobile <msisdn>)
|
||||
curl --request POST \
|
||||
--url https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/fetchSubscriberByMDN \
|
||||
--compressed \
|
||||
--header 'Content-Type: application/json; charset=UTF-8' \
|
||||
--header 'Host: superapp.ooredoo.mv' \
|
||||
--header 'Connection: Keep-Alive' \
|
||||
--header 'Accept-Encoding: gzip' \
|
||||
--data "{\"mdnId\":\"${MDN_ENC//=/\\u003d}\"}"
|
||||
```
|
||||
|
||||
> Note the `${MDN_ENC//=/=}` substitution — the server requires the [Gson html-safe `=` escape](01-encryption.md#html-safe-gson--escape).
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Operation completed successfully.",
|
||||
"kycStatus": "Full KYC",
|
||||
"name": "<First Name>",
|
||||
"firstName": "<First Name>",
|
||||
"lastName": "<Last Name>",
|
||||
"language": "English",
|
||||
"activationPending": false,
|
||||
"passwordCreated": true,
|
||||
"subscriberRegistered": true,
|
||||
"userIdCreated": false
|
||||
}
|
||||
```
|
||||
|
||||
### Decision matrix
|
||||
|
||||
| Condition | Thijooree behaviour |
|
||||
|---|---|
|
||||
| `subscriberRegistered = false` | Show: *"User not registered. Please use the Ooredoo SuperApp to register your M-Faisa wallet and complete KYC, then come back to Thijooree."* |
|
||||
| `kycStatus != "Full KYC"` | Show: *"Your M-Faisa wallet needs Full KYC. Please complete KYC in the Ooredoo SuperApp, then come back to Thijooree."* |
|
||||
| `passwordCreated = false` | Show: *"Set your M-Faisa mPIN in the Ooredoo SuperApp first, then try again."* |
|
||||
| `activationPending = true` | Show: *"Your M-Faisa wallet activation is still pending. Complete it in the Ooredoo SuperApp first."* |
|
||||
| Otherwise | Proceed to `doMobileLogin` |
|
||||
|
||||
---
|
||||
|
||||
## Step 2: `doMobileLogin`
|
||||
|
||||
Submits the encrypted mPIN; the response contains the user's wallet pockets (E-Money MVR, optionally IMT MVR and PayPal USD).
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/doMobileLogin
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
**Content-Type:** `application/x-www-form-urlencoded`
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `channel` | `C03` (constant) |
|
||||
| `formData` | JSON object (see below), html-safe-escaped (`=` → `=`) |
|
||||
| `formDataCs` | `null` (literal string) |
|
||||
|
||||
`formData` JSON object:
|
||||
|
||||
```json
|
||||
{
|
||||
"deviceGeoInfo": {
|
||||
"appType": "CustomerAndroid",
|
||||
"appversion": "1.0",
|
||||
"deviceId": "<Settings.Secure.ANDROID_ID>",
|
||||
"deviceManufacturer": "<Build.MANUFACTURER>",
|
||||
"imieNumber": "<Settings.Secure.ANDROID_ID>",
|
||||
"ipaddress": "11.22.33.55",
|
||||
"latitude": "0.0",
|
||||
"longitude": "0.0",
|
||||
"simId": "<Settings.Secure.ANDROID_ID>"
|
||||
},
|
||||
"mPin": "<encryptPin(mpin), hex>",
|
||||
"mobileNumber": "<encryptMobile(msisdn), base64>",
|
||||
"role": "RETAIL_SUBSCRIBER",
|
||||
"tenantCode": "ooredoo",
|
||||
"userName": "<encryptMobile(msisdn), base64>"
|
||||
}
|
||||
```
|
||||
|
||||
Both `mobileNumber` and `userName` encrypt the same plaintext, but encrypt independently (so their ciphertexts differ — OAEP padding randomises the output).
|
||||
|
||||
`ipaddress` is the constant `"11.22.33.55"` in the official app — not the device's real IP.
|
||||
|
||||
### Response — success
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"loginExchangeKey": "<opaque hex token>",
|
||||
"mobileLoginSessionTimeout": "240",
|
||||
"kycStatus": "Full KYC",
|
||||
"suscriberId": "<12-digit subscriber id>",
|
||||
"pocketDetails": [
|
||||
{
|
||||
"name": "<Subscriber Name>",
|
||||
"eMailId": "<user@example.com>",
|
||||
"mdnId": "<msisdn>",
|
||||
"roleId": "<12-digit role id>",
|
||||
"walletId": "<11-digit wallet id>",
|
||||
"offerId": "<offer id>",
|
||||
"pocketSummaryDetailsArrayDTO": [
|
||||
{
|
||||
"pocketId": "<pocket id>",
|
||||
"pocketType": "INTERNAL",
|
||||
"pocketValueType": "EMONEY",
|
||||
"nickName": "E-Money",
|
||||
"balanceAmount": { "amount": 0.0, "currencyCode": "MVR" },
|
||||
"isDefaultPocket": true,
|
||||
"isSecondaryPocket": false,
|
||||
"statusType": "ACTIVE",
|
||||
"displayName": "E-Money"
|
||||
},
|
||||
{ "pocketValueType": "PAYPAL_USD", "...": "..." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> The typo `suscriberId` (missing `b`) is the **server's** spelling, not ours. The same value also appears as `pocketDetails[0].roleId`.
|
||||
|
||||
### Response — wrong PIN
|
||||
|
||||
The server returns a **JSON array** (not object) on failure:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"success": false,
|
||||
"message": "validation errors",
|
||||
"error": [
|
||||
{
|
||||
"objectName": "Credentials Criteria",
|
||||
"attributeName": "mPin",
|
||||
"attributeValue": "MPIN_NOT_VALID",
|
||||
"errorMessage": "Invalid mobile number/ Password. Please check and retry. If you have forgotten your PIN please go to FORGOT PIN to reset PIN."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
On the **second-to-last** attempt, the `errorMessage` changes to:
|
||||
|
||||
```
|
||||
Provided login details are not valid, One more wrong attempt will lock your account.
|
||||
```
|
||||
|
||||
Thijooree detects the warning by substring (`"one more"` / `"will lock"`, case-insensitive) and surfaces it as a stronger inline error.
|
||||
|
||||
### Distinguishing success from failure
|
||||
|
||||
The official app — and Thijooree — distinguish the two purely by the JSON shape:
|
||||
|
||||
```kotlin
|
||||
val trimmed = raw.trimStart()
|
||||
if (trimmed.startsWith("[")) {
|
||||
// wrong PIN path
|
||||
} else {
|
||||
// success path
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation notes
|
||||
|
||||
- **Plaintext is `"960" + msisdn`.** The country code is prepended *inside* `MfaisaCrypto.encryptMobile` rather than at the call site.
|
||||
- **`Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")` is not enough on its own.** You also need the explicit `OAEPParameterSpec` — see [01-encryption.md](01-encryption.md#reference-implementation-kotlin).
|
||||
- **The mPIN salt must be exactly 6 alphanumeric chars.** Other lengths/charsets work for OAEP locally but were not seen in the official app and aren't worth deviating from.
|
||||
- **No User-Agent header**, as noted in the [README](README.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
> **Next →** [Transaction History](03-history.md) | **← Back to** [README](README.md)
|
||||
@@ -0,0 +1,149 @@
|
||||
# Transaction History
|
||||
|
||||
Fetch a paginated list of transactions for the active subscriber session.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/transactionInquiry/fetchSummary
|
||||
```
|
||||
|
||||
Requires an active session (i.e. a valid [`loginExchangeKey`](02-login.md#step-2-domobilelogin)) obtained from `doMobileLogin`. Sessions expire after `mobileLoginSessionTimeout` seconds (240s) — see [Session expiry](#session-expiry) below.
|
||||
|
||||
---
|
||||
|
||||
## Request
|
||||
|
||||
**Content-Type:** `application/x-www-form-urlencoded`
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `role` | `RETAIL_SUBSCRIBER` (constant) |
|
||||
| `channel` | `SubscriberApp` — **not** `C03` as in login |
|
||||
| `loginExchangeKey` | From the login response |
|
||||
| `mdnId` | `<encryptMobile(msisdn), base64>` — same routine as `mdnId` in login |
|
||||
| `formData` | JSON object (see below), [html-safe-escaped](01-encryption.md#html-safe-gson--escape) |
|
||||
| `rndValue` | [Anti-replay nonce](01-encryption.md#anti-replay-envelope-rndvalue--csvalue) |
|
||||
| `csValue` | [Adler32 integrity check](01-encryption.md#anti-replay-envelope-rndvalue--csvalue) |
|
||||
|
||||
### `formData` JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"actorRole": "RETAIL_SUBSCRIBER",
|
||||
"actorRoleId": "<suscriberId from login response>",
|
||||
"fromDate": "",
|
||||
"mdnId": "<encryptMobile(msisdn), base64>",
|
||||
"pageNo": "1",
|
||||
"recordSize": "70",
|
||||
"toDate": "",
|
||||
"transactionType": ""
|
||||
}
|
||||
```
|
||||
|
||||
- `actorRoleId` comes from `suscriberId` at the top level of `doMobileLogin`'s success body — see [02-login.md](02-login.md#step-2-domobilelogin). It also appears as `pocketDetails[0].roleId` in the same response.
|
||||
- The inner `mdnId` is independently encrypted from the outer `mdnId` — same plaintext, different ciphertext (OAEP random padding).
|
||||
- `fromDate` / `toDate` are empty strings in the official app — the server returns all available history.
|
||||
|
||||
---
|
||||
|
||||
## curl Example
|
||||
|
||||
```bash
|
||||
# Requires a valid loginExchangeKey + suscriberId from a fresh login call
|
||||
python tmp/mfaisa_history.py <msisdn> <mpin> 1
|
||||
```
|
||||
|
||||
See `tmp/mfaisa_history.py` for the full Python reference (it does the login, captures the session, then calls fetchSummary).
|
||||
|
||||
---
|
||||
|
||||
## Response
|
||||
|
||||
```json
|
||||
{
|
||||
"transactionInquiryDTOList": [
|
||||
{
|
||||
"requestId": "<request id>",
|
||||
"referenceId": "<reference id>",
|
||||
"sourceMDN": "<Subscriber Name>-DT Pocket-<msisdn>",
|
||||
"sourcePocketId":"<pocket id>",
|
||||
"actorRoleType": "RETAIL_SUBSCRIBER",
|
||||
"actorRoleId": "<subscriber id>",
|
||||
"commodityType": "WALLET",
|
||||
"channel": "SubscriberApp",
|
||||
"transactionAmount": { "amount": 1, "currencyCode": "MVR" },
|
||||
"userStatus": "CONFIRMED",
|
||||
"trnStage": "AUTO_REVERSED",
|
||||
"trnType": "CASH_IN",
|
||||
"status": "FAILED",
|
||||
"trnDate": "2026-06-13 13:04:19",
|
||||
"narrationString": "Load Money",
|
||||
"typeSummaryString": "[{\"Transaction Type\":\"Load Money\",\"Deposit Pocket\":\"DT Pocket\",\"Reference\":\"<ref>\"}]",
|
||||
"errorCode": "INT_002",
|
||||
"errorDesc": "QR_CODE_GENERATED",
|
||||
"resolutionDetails": "Please reconcile with the payment gateway..."
|
||||
},
|
||||
"..."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Key fields used by Thijooree:
|
||||
|
||||
| Field | Maps to `BankTransaction` |
|
||||
|---|---|
|
||||
| `trnDate` | `date` (already in `YYYY-MM-DD HH:mm:ss` form) |
|
||||
| `narrationString` | `description` (suffixed with `· Failed` when `status == "FAILED"`) |
|
||||
| `transactionAmount.amount` | `amount` (signed — see direction rule below) |
|
||||
| `transactionAmount.currencyCode` | `currency` |
|
||||
| `referenceId` (fallback `requestId`) | `id` + `reference` |
|
||||
| `typeSummaryString` → `Merchant Name` / `Receiver Name` / `Sender Name` | `counterpartyName` |
|
||||
| `sourceMDN` (e.g. `"<Name>-DT Pocket-<msisdn>"`) | `counterpartyName` fallback (first segment before `-`) |
|
||||
|
||||
### Debit / credit direction
|
||||
|
||||
The response does not include a signed amount or direction flag. Direction is inferred from `trnType`:
|
||||
|
||||
| `trnType` | Direction |
|
||||
|---|---|
|
||||
| `CASH_IN`, `RECEIVE_MONEY`, `*_IN` | credit (positive amount) |
|
||||
| everything else (`PURCHASE`, `TRANSFER`, …) | debit (negative amount) |
|
||||
|
||||
### Pagination
|
||||
|
||||
The server does not return a `total` field. Thijooree treats "received a full `recordSize` (= 70) records" as the only signal that further pages may exist; the next call uses `pageNo = pageNo + 1`. Once a page comes back with fewer than 70 records, no more pages are fetched.
|
||||
|
||||
### Session expiry
|
||||
|
||||
When the 240-second session lapses, the server still returns HTTP 200 but the body is its standard error envelope:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"success": false,
|
||||
"message": "validation errors",
|
||||
"error": [
|
||||
{
|
||||
"objectName": "LoginLog",
|
||||
"attributeName": "LoginLog",
|
||||
"attributeValue": "SESSION_EXPIRED",
|
||||
"errorCode": "SESSION_EXPIRED",
|
||||
"errorMessage": "SESSION_EXPIRED"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`MfaisaHistoryClient` parses this into `MfaisaSessionExpiredException`. Callers (`HistoryFetcher`, `TransferHistoryFragment`) catch it, call `BasedBankApp.refreshMfaisaSession(loginId)` to re-login transparently, and retry the same page once.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
> **Next →** [Transfer Money](04-transfer.md) | **← Back to** [Login](02-login.md) | [README](README.md)
|
||||
@@ -0,0 +1,282 @@
|
||||
# Transfer Money (Wallet-to-Wallet)
|
||||
|
||||
Send MVR from the user's M-Faisa pocket to another M-Faisa subscriber, identified by phone number.
|
||||
|
||||
There is no "account number" concept on M-Faisa — recipients are addressed by mobile number, and the server resolves the destination pocket itself. The flow is three calls, ending with an OTP delivered by SMS to the sender.
|
||||
|
||||
> **Currency / pocket constraints:** Thijooree only sends from the user's MVR (EMONEY) pocket to the recipient's MVR pocket. PayPal-USD pockets are out of scope (the HAR captures don't cover them and we have no Frida-extracted recipe).
|
||||
|
||||
---
|
||||
|
||||
## Flow
|
||||
|
||||
```
|
||||
Client Server
|
||||
| |
|
||||
| POST /Pocket/basicBeneDetails | ← user typed a phone and tapped 🔍
|
||||
| formData = { beneficaryDetails, initiator } |
|
||||
|--------------------------------------------->|
|
||||
| [{ success, response:[[pocket, pocket,…]]}] |
|
||||
|<---------------------------------------------|
|
||||
| |
|
||||
| (show recipient name, accept amount + remarks)
|
||||
| |
|
||||
| POST /initiateFTRequest |
|
||||
| formData = { sourceDetails, recipient, |
|
||||
| transactionAmount, … } |
|
||||
|--------------------------------------------->|
|
||||
| { 2FARequired:"OTP", response:[{ |
|
||||
| responseObject:{ referenceId, |
|
||||
| chargeDetails, … } }] } |
|
||||
|<---------------------------------------------|
|
||||
| |
|
||||
| (server sends SMS OTP to sender's phone)
|
||||
| (user types OTP) |
|
||||
| |
|
||||
| POST /confirmFTRequest |
|
||||
| formData = { referenceId } |
|
||||
| transactionAuthDetails = { OTP encrypted } |
|
||||
|--------------------------------------------->|
|
||||
| { success:true, |
|
||||
| message:"Transfer Completed Successfully" }
|
||||
|<---------------------------------------------|
|
||||
```
|
||||
|
||||
All three endpoints carry the same anti-replay pair (`rndValue` + `csValue`) derived from the request's `formData` JSON — see [01-encryption.md → rndValue / csValue](01-encryption.md#anti-replay-envelope-rndvalue--csvalue).
|
||||
|
||||
---
|
||||
|
||||
## Step 1: `Pocket/basicBeneDetails` — recipient lookup
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/Pocket/basicBeneDetails
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
**Content-Type:** `application/x-www-form-urlencoded`
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `role` | `RETAIL_SUBSCRIBER` |
|
||||
| `channel` | `SubscriberApp` |
|
||||
| `loginExchangeKey` | From login |
|
||||
| `rndValue` / `csValue` | [Standard anti-replay](01-encryption.md#anti-replay-envelope-rndvalue--csvalue) |
|
||||
| `formData` | JSON below ([html-safe `=` escaping](01-encryption.md#html-safe-gson--escape)) |
|
||||
|
||||
```json
|
||||
{
|
||||
"beneficaryDetails": {
|
||||
"MDNId": "<encryptMobile(recipientMsisdn), base64>",
|
||||
"actorRoleType": "RETAIL_SUBSCRIBER"
|
||||
},
|
||||
"initiatorDetailsDTO": {
|
||||
"initiatingMDN": "<encryptMobile(myMsisdn), base64>",
|
||||
"initiatingRoleId": "<my suscriberId>",
|
||||
"initiatorRole": "RETAIL_SUBSCRIBER"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `suscriberId` (note the server's typo) comes from the top level of the `doMobileLogin` response — see [02-login.md](02-login.md#step-2-domobilelogin).
|
||||
|
||||
### Response — happy path
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"success": true,
|
||||
"message": "Operation Completed Successfully",
|
||||
"response": [
|
||||
[
|
||||
{ "pocketId": "<paypal pocket id>", "pocketCurrency": "USD",
|
||||
"pocketValueType": "PAYPAL_USD", "name": "<Recipient Name>", "MDNId": "<recipient msisdn>",
|
||||
"walletId": "<recipient wallet id>", "actorId": "<recipient actor id>", "...": "..." },
|
||||
{ "pocketId": "<mvr pocket id>", "pocketCurrency": "MVR",
|
||||
"pocketValueType": "EMONEY", "name": "<Recipient Name>", "MDNId": "<recipient msisdn>",
|
||||
"walletId": "<recipient wallet id>", "actorId": "<recipient actor id>", "...": "..." }
|
||||
]
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Note the nesting — `response` is an array (one element only seen in practice) of arrays of pocket objects (one per pocket the recipient owns).
|
||||
|
||||
### Response — recipient not found
|
||||
|
||||
```json
|
||||
[{ "success": false, "message": "Pocket details not found." }]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2: `initiateFTRequest` — initiate, server SMSes OTP
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/initiateFTRequest
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
**Content-Type:** `application/x-www-form-urlencoded`
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `identifier` | `<encryptMobile(recipientMsisdn), base64>` — independent encryption from `formData.MDNId` |
|
||||
| `role` | `RETAIL_SUBSCRIBER` |
|
||||
| `transferMode` | `MOBILE` |
|
||||
| `channel` | **`C03`** (the top-level value differs from the inner `formData.channel`, which is `SubscriberApp`) |
|
||||
| `tPin` | empty string `""` (a relic — the OTP step authenticates) |
|
||||
| `loginExchangeKey` | From login |
|
||||
| `rndValue` / `csValue` | Standard anti-replay (derived from the `formData` below) |
|
||||
| `formData` | JSON below |
|
||||
|
||||
```json
|
||||
{
|
||||
"MDNId": "960<recipientMsisdn>", /* PLAINTEXT — '960' + recipient phone */
|
||||
"beneDetails": {
|
||||
"miscDetails": "<remarks>",
|
||||
"transferMode":"MOBILE"
|
||||
},
|
||||
"channel": "SubscriberApp",
|
||||
"commodityType": "WALLET",
|
||||
"description": "<remarks>",
|
||||
"inputDetailsDTO": { "deviceId": "…", "simId": "…" },
|
||||
"mfs-transactionType": "send-money-to-mobile",
|
||||
"pocketId": "",
|
||||
"sourceDetails": {
|
||||
"MDNId": "960<myMsisdn>", /* PLAINTEXT — '960' + my phone */
|
||||
"actorRoleType":"RETAIL_SUBSCRIBER",
|
||||
"pocketId": "<my source pocket id>" /* from login.pocketDetails[0].pocketSummaryDetailsArrayDTO */
|
||||
},
|
||||
"transactionAmount": "<amount>", /* string, MVR */
|
||||
"transactionCurrency":"MVR",
|
||||
"transferMode": "MOBILE"
|
||||
}
|
||||
```
|
||||
|
||||
`deviceId` and `simId` are both `Settings.Secure.ANDROID_ID` in Thijooree's implementation — matching the device-info pattern from login.
|
||||
|
||||
### Response — happy path
|
||||
|
||||
```json
|
||||
{
|
||||
"2FARequired": "OTP",
|
||||
"authenticationType": "OTP",
|
||||
"success": true,
|
||||
"message": "Operation Completed Successfully",
|
||||
"response": [
|
||||
{
|
||||
"requestObject": { "...": "..." },
|
||||
"responseObject": {
|
||||
"referenceId": "<reference id>",
|
||||
"transactionAmount": { "amount": 1.0, "currencyCode": "MVR" },
|
||||
"netAmount": { "amount": 1.0, "currencyCode": "MVR" },
|
||||
"chargeDetailsDTO": { "totalFeesInTenantCurrency": { "amount": 0.0, "...": "..." }, "...": "..." },
|
||||
"isCompleted": false,
|
||||
"...": "..."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The server SMSes a 6-digit OTP to the sender's phone immediately. Cache `referenceId` for step 3.
|
||||
|
||||
---
|
||||
|
||||
## Step 3: `confirmFTRequest` — submit OTP
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/confirmFTRequest
|
||||
```
|
||||
|
||||
### Request
|
||||
|
||||
**Content-Type:** `application/x-www-form-urlencoded`
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `role` | `RETAIL_SUBSCRIBER` |
|
||||
| `channel` | `C03` |
|
||||
| `loginExchangeKey` | From login |
|
||||
| `rndValue` / `csValue` | Anti-replay derived from `formData` below |
|
||||
| `formData` | `{"referenceId": "<from step 2>"}` |
|
||||
| `transactionAuthDetails` | JSON below |
|
||||
|
||||
```json
|
||||
{
|
||||
"authenticationType": "OTP",
|
||||
"authenticationValue": "<encryptPin(otpCode), hex>",
|
||||
"otpTransactionType": "TRANSACTION",
|
||||
"referenceId": "<from step 2>"
|
||||
}
|
||||
```
|
||||
|
||||
The OTP code is encrypted with the same `encryptPin` routine used for the mPIN — i.e. RSA-OAEP-SHA1 against the 2048-bit mPin key, with a fresh 6-character salt. See [01-encryption.md](01-encryption.md#mpin-key-2048-bit).
|
||||
|
||||
### Response — happy path
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Transfer Completed Successfully.",
|
||||
"response": [
|
||||
{
|
||||
"responseObject": {
|
||||
"isCompleted": true,
|
||||
"balanceInquiryDTO": {
|
||||
"currencyCode": "MVR",
|
||||
"pocketAmount": 0.45,
|
||||
"pocketId": "<source pocket id>",
|
||||
"pocketBalanceMap": { "...": "..." }
|
||||
},
|
||||
"status": { "replyCode": 0.0, "replyText": "Success" },
|
||||
"...": "..."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Response — wrong OTP
|
||||
|
||||
The server returns its standard error envelope as a JSON array:
|
||||
|
||||
```json
|
||||
[{
|
||||
"success": false, "message": "validation errors",
|
||||
"error": [{ "attributeName":"OTP", "errorMessage":"<details>" }]
|
||||
}]
|
||||
```
|
||||
|
||||
`MfaisaTransferClient` parses this into [`MfaisaInvalidOtpException`](../../app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaModels.kt) so the caller can re-prompt without losing the `referenceId`.
|
||||
|
||||
### Session expiry
|
||||
|
||||
Same envelope as elsewhere — `attributeValue: "SESSION_EXPIRED"` with HTTP 200; the client throws `MfaisaSessionExpiredException`. See [03-history.md → Session expiry](03-history.md#session-expiry).
|
||||
|
||||
---
|
||||
|
||||
## curl Reference
|
||||
|
||||
```bash
|
||||
# Step 1 (search a recipient)
|
||||
python tmp/mfaisa_transfer.py <myMsisdn> <myMpin> <recipientMsisdn>
|
||||
# Steps 2 + 3 require a live phone OTP and are documented in tmp/mfaisa_transfer.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
> **← Back to** [Transaction History](03-history.md) | [README](README.md)
|
||||
@@ -0,0 +1,98 @@
|
||||
# Ooredoo M-Faisa API Documentation
|
||||
|
||||
Reverse-engineered from traffic captures and live Frida hooks of the official Ooredoo SuperApp (`com.mventus.ooredoomaldives`).
|
||||
|
||||
[Play Store](https://play.google.com/store/apps/details?id=com.mventus.ooredoomaldives)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
M-Faisa is Ooredoo Maldives' mobile wallet, exposed via a JSON/form-encoded REST API on `superapp.ooredoo.mv`. The wire format is unusual in three ways:
|
||||
|
||||
1. **Field-level RSA encryption.** The MSISDN (`mdnId`, `mobileNumber`, `userName`, `initiatingMDN`, `identifier`) and the mPIN (`mPin`) are each encrypted with a different RSA public key before being placed in the request body. See [01-encryption.md](01-encryption.md).
|
||||
2. **Anti-replay envelope.** Every session-scoped form-encoded POST carries an `rndValue` (RSA-encrypted timestamp) and a `csValue` (Adler32 of `formDataJson + nonceStr`). See [01-encryption.md → `rndValue` / `csValue`](01-encryption.md#anti-replay-envelope-rndvalue--csvalue).
|
||||
3. **Cloudflare-fingerprinted header order.** A `User-Agent` header sent explicitly (instead of letting OkHttp add it last) returns HTTP 400.
|
||||
|
||||
---
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
https://superapp.ooredoo.mv
|
||||
```
|
||||
|
||||
All M-Faisa endpoints are mounted at `/api/mfaisaa-bff/mfino/v1.1/web/...`.
|
||||
|
||||
---
|
||||
|
||||
## Authentication Model
|
||||
|
||||
| Value | How obtained | How used |
|
||||
|---|---|---|
|
||||
| `loginExchangeKey` | Returned by `doMobileLogin` on success | Held in memory only; identifies the session |
|
||||
| Session timeout | `mobileLoginSessionTimeout` field, default **240 seconds** | After expiry the user must re-login (no refresh-token flow) |
|
||||
|
||||
Because there is no refresh, Thijooree re-runs `fetchSubscriberByMDN` + `doMobileLogin` on every cold-start refresh, using the saved msisdn + mPIN from `CredentialStore`. The `SESSION_EXPIRED` error envelope is also caught at runtime and the session is silently re-established before retrying the failed request.
|
||||
|
||||
---
|
||||
|
||||
## Common Request Headers
|
||||
|
||||
```
|
||||
Content-Type: application/json; charset=UTF-8 (fetchSubscriberByMDN)
|
||||
Content-Type: application/x-www-form-urlencoded (every other endpoint)
|
||||
Host: superapp.ooredoo.mv
|
||||
Connection: Keep-Alive
|
||||
Accept-Encoding: gzip
|
||||
User-Agent: okhttp/4.12.0
|
||||
```
|
||||
|
||||
> **Do NOT set User-Agent in code.** Cloudflare fingerprints the header order; an explicit `User-Agent` header is pushed to the front of the request and the request is rejected with HTTP 400. Let OkHttp's `BridgeInterceptor` add the default `okhttp/4.12.0` at the end.
|
||||
|
||||
---
|
||||
|
||||
## Login Flow
|
||||
|
||||
```
|
||||
Client Server
|
||||
| |
|
||||
| POST /fetchSubscriberByMDN |
|
||||
| { mdnId: encryptMobile(msisdn) } |
|
||||
|---------------------------------------------------->|
|
||||
| { success, subscriberRegistered, kycStatus, ... } |
|
||||
|<----------------------------------------------------|
|
||||
| |
|
||||
| (abort if subscriberRegistered=false |
|
||||
| or kycStatus != "Full KYC") |
|
||||
| |
|
||||
| POST /doMobileLogin |
|
||||
| channel=C03 |
|
||||
| formData={ deviceGeoInfo, mPin: encryptPin(mpin), |
|
||||
| mobileNumber: ..., userName: ..., |
|
||||
| role:"RETAIL_SUBSCRIBER", |
|
||||
| tenantCode:"ooredoo" } |
|
||||
| formDataCs=null |
|
||||
|---------------------------------------------------->|
|
||||
| { success, loginExchangeKey, pocketDetails: [...]} |
|
||||
|<----------------------------------------------------|
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Documents
|
||||
|
||||
| # | File | Description |
|
||||
|---|---|---|
|
||||
| 1 | [Encryption & Anti-Replay](01-encryption.md) | Mobile / mPin RSA, the `rndValue` + `csValue` envelope, the Gson `=` quirk, key-extraction story |
|
||||
| 2 | [Login](02-login.md) | Subscriber lookup + mPIN login |
|
||||
| 3 | [Transaction History](03-history.md) | Paginated history per session |
|
||||
| 4 | [Transfer Money](04-transfer.md) | Three-step wallet-to-wallet send: recipient lookup → initiate (server SMSes OTP) → confirm |
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
> **Next →** [Encryption & Anti-Replay](01-encryption.md)
|
||||
@@ -8,7 +8,7 @@ All traffic to the encrypted API (`faisanet.mib.com.mv`) uses Blowfish encryptio
|
||||
|
||||
- **Algorithm**: Blowfish, ECB mode, PKCS5 padding
|
||||
- **Input**: raw UTF-8 bytes of the JSON payload string
|
||||
- **Key**: raw UTF-8 bytes of the key string
|
||||
- **Key**: raw ISO-8859-1 bytes of the key string (not UTF-8 — only matters if a key ever contains a non-ASCII character; in Python this is `key.encode('latin-1')`, in Kotlin `Charsets.ISO_8859_1`)
|
||||
- **Output**: base64-encoded ciphertext (URL-encoded when sent as form data)
|
||||
|
||||
```python
|
||||
@@ -204,8 +204,10 @@ def generate_nonce(nonce_generator: str) -> str:
|
||||
|
||||
### Notes
|
||||
|
||||
- `nonce` and `sodium` are separate fields. `sodium` is an independent random integer (~23–24 bit range).
|
||||
- `nonce` and `sodium` are separate fields. `sodium` is an independent random integer in `[1_000_000, 16_000_000)` (~24-bit range, 7–8 decimal digits) — see `MibNonce.kt:76`.
|
||||
- `xxid` (when randomly generated client-side, e.g. inside the `sfunc=r`/`sfunc=i` inner payload) is a random 40-bit integer: `Random.nextLong(0L, 1L shl 40)` — see `MibNonce.kt:78`. The server replaces this with a real session `xxid` in its response.
|
||||
- The `nonceGenerator` is returned once by the key exchange response and reused for the entire session.
|
||||
- All `S` and `C` arithmetic in Phase 2 uses Kotlin `Long` (see `MibNonce.kt:54-60`) — `carry * carry * carry + …` can exceed 32-bit range for `carry ≈ 99`. Reproducing the nonce in another language requires 64-bit (or arbitrary-precision) arithmetic on the intermediate value before taking the last two digits.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+25
-6
@@ -14,17 +14,17 @@ MIB uses a two-phase authentication model:
|
||||
The password is never sent in plaintext. Required by both `C41` (registration) and `A41` (login).
|
||||
|
||||
```
|
||||
pgf03 = SHA256( clientSalt + SHA256( userSalt + SHA256( password ) ) )
|
||||
pgf03 = SHA256( clientSalt + SHA256( SHA256( password ) + userSalt ) )
|
||||
```
|
||||
|
||||
All SHA-256 values are uppercase hex strings. `clientSalt` is a fresh random 32-character alphanumeric string each time.
|
||||
All SHA-256 values are uppercase hex strings. `clientSalt` is a fresh random 32-character alphanumeric string each time. Note the inner concat order is `passwordHash + userSalt` — getting this backwards produces a valid-looking but wrong hash.
|
||||
|
||||
```python
|
||||
import hashlib
|
||||
|
||||
def compute_pgf03(password: str, user_salt: str, client_salt: str) -> str:
|
||||
h1 = hashlib.sha256(password.encode()).hexdigest().upper()
|
||||
h2 = hashlib.sha256((user_salt + h1).encode()).hexdigest().upper()
|
||||
h2 = hashlib.sha256((h1 + user_salt).encode()).hexdigest().upper()
|
||||
return hashlib.sha256((client_salt + h2).encode()).hexdigest().upper()
|
||||
```
|
||||
|
||||
@@ -69,7 +69,7 @@ def compute_pgf03(password: str, user_salt: str, client_salt: str) -> str:
|
||||
"cmod": "<G^A mod P as decimal string>",
|
||||
"appId": "IOS17.2-<15 random alphanumeric chars>",
|
||||
"routePath": "S40",
|
||||
"sodium": "<random 20-bit int as string>",
|
||||
"sodium": "<random int in [1_000_000, 16_000_000)>",
|
||||
"xxid": "<random 40-bit int as string>"
|
||||
}
|
||||
}
|
||||
@@ -218,7 +218,7 @@ Form body: `key2=<key2>&sfunc=i&data=<encrypted payload>`
|
||||
"cmod": "<G^A mod P>",
|
||||
"appId": "<appId>",
|
||||
"routePath": "S40",
|
||||
"sodium": "<random 20-bit int>",
|
||||
"sodium": "<random int in [1_000_000, 16_000_000)>",
|
||||
"xxid": "<random 40-bit int>"
|
||||
}
|
||||
}
|
||||
@@ -272,10 +272,29 @@ After: derive new session key, replace `xxid` and `nonceGenerator`.
|
||||
"otpTypes": [2, 3],
|
||||
"email": "<masked email>",
|
||||
"uuid": "<uuid1>",
|
||||
"uuid2": "<uuid2>"
|
||||
"uuid2": "<uuid2>",
|
||||
"operatingProfiles": [
|
||||
{
|
||||
"customerProfileId": "...",
|
||||
"annexId": "...",
|
||||
"customerId": "...",
|
||||
"name": "...",
|
||||
"cifType": "...",
|
||||
"profileType": "...",
|
||||
"color": "...",
|
||||
"customerImage": "<image hash, may be missing/blank>"
|
||||
}
|
||||
],
|
||||
"profileSelected": false,
|
||||
"selectedProfileId": "",
|
||||
"accountBalance": []
|
||||
}
|
||||
```
|
||||
|
||||
**Single-profile fast-path** — if the account has exactly one operating profile, the server returns `profileSelected: true`, populates `selectedProfileId`, and includes a non-empty `accountBalance` array in the A41 response itself. In that case the [P47](03-accounts.md) call is **skipped** and balances are read from this response (see `MibLoginFlow.kt:150-184`).
|
||||
|
||||
> **Field naming**: the image hash field on each `operatingProfiles[]` entry is `customerImage`. The same conceptual value is called `customerImgHash` in the contacts API response — the two endpoints disagree on the field name.
|
||||
|
||||
---
|
||||
|
||||
### [3b] Get Profile Image — `sfunc=n`, `routePath: P41`
|
||||
|
||||
+20
-17
@@ -1,6 +1,8 @@
|
||||
# Accounts & Balances
|
||||
|
||||
Account numbers and balances are returned by the **Select Profile** call (`routePath: P47`). The login init call (`A41`) returns an empty `accountBalance` array — balances are only available after `P47`.
|
||||
Account numbers and balances are returned by the **Select Profile** call (`routePath: P47`). For multi-profile users the `A41` login init call returns an empty `accountBalance` array and `P47` must be called for each profile to enumerate accounts.
|
||||
|
||||
> **Single-profile fast-path**: when the account has exactly one operating profile, the server returns `profileSelected: true`, `selectedProfileId`, and a populated `accountBalance` array directly in the `A41` response. In that case the `P47` call is **skipped** — see [02-login.md](02-login.md) and `MibLoginFlow.kt:150-184`.
|
||||
|
||||
---
|
||||
|
||||
@@ -95,22 +97,23 @@ Each element represents one account:
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `accountNumber` | Full account number |
|
||||
| `accountBriefName` | Human-readable account label |
|
||||
| `currencyCode` | ISO 4217 numeric (e.g. `"462"` = MVR, `"840"` = USD) |
|
||||
| `currencyName` | ISO 4217 alpha (e.g. `"MVR"`, `"USD"`) |
|
||||
| `accountTypeName` | Account type (e.g. `"Saving Account"`) |
|
||||
| `availableBalance` | Spendable balance (decimal string) |
|
||||
| `currentBalance` | Ledger balance (decimal string) |
|
||||
| `blockedAmount` | Held/blocked funds — negative means funds are held |
|
||||
| `settlementBalance` | Balance including pending settlements |
|
||||
| `mvrBalance` | All balances converted to MVR for unified display |
|
||||
| `transfer` | `"Y"` if usable as transfer source |
|
||||
| `statusDesc` | Account status (e.g. `"Active"`) |
|
||||
| `cif` | Customer Information File number |
|
||||
| `template` | UI template ID |
|
||||
| Field | Consumed | Description |
|
||||
|---|---|---|
|
||||
| `accountNumber` | yes | Full account number |
|
||||
| `accountBriefName` | yes | Human-readable account label |
|
||||
| `currencyName` | yes | ISO 4217 alpha (e.g. `"MVR"`, `"USD"`) |
|
||||
| `accountTypeName` | yes | Account type (e.g. `"Saving Account"`) |
|
||||
| `availableBalance` | yes | Spendable balance (decimal string) |
|
||||
| `currentBalance` | yes | Ledger balance (decimal string) |
|
||||
| `blockedAmount` | yes | Held/blocked funds. Server value is **signed** (negative = held). The app normalizes to a positive magnitude via `absBlockedAmount()` (`MibLoginFlow.kt:172, 194-197`). |
|
||||
| `mvrBalance` | yes | All balances converted to MVR for unified display |
|
||||
| `statusDesc` | yes | Account status (e.g. `"Active"`) |
|
||||
| `currencyCode` | server-only | ISO 4217 numeric (e.g. `"462"` = MVR, `"840"` = USD) — present in payload but not read by the app |
|
||||
| `transfer` | server-only | `"Y"` if usable as transfer source |
|
||||
| `cif` | server-only | Customer Information File number |
|
||||
| `template` | server-only | UI template ID |
|
||||
| `branchName` | server-only | Branch name |
|
||||
| `settlementBalance` | server-only | Balance including pending settlements |
|
||||
|
||||
> All balance fields are **decimal strings**, not numbers — parse with `Decimal` for precision.
|
||||
|
||||
|
||||
@@ -71,6 +71,8 @@ Referer: https://faisamobilex-wv.mib.com.mv//debitCards?dashurl=1
|
||||
| `phoneNumber` | `string` | Registered phone number |
|
||||
| `cardHolderName` | `string` | Name on card |
|
||||
|
||||
> The app's `MibCard` model adds two **app-internal** fields not present on the server payload: `loginTag` (e.g. `"mib_<username>"`) and `profileId` (the active profile that fetched the card). They are populated by the client (`MibCardsClient.kt:66-67`) and used by the UI to map a card back to its owning login.
|
||||
|
||||
### Failure
|
||||
|
||||
```json
|
||||
@@ -79,6 +81,44 @@ Referer: https://faisamobilex-wv.mib.com.mv//debitCards?dashurl=1
|
||||
|
||||
---
|
||||
|
||||
## Freeze / Unfreeze Card
|
||||
|
||||
```
|
||||
POST https://faisamobilex-wv.mib.com.mv/ajaxDebitCard/freeze
|
||||
POST https://faisamobilex-wv.mib.com.mv/ajaxDebitCard/unfreeze
|
||||
```
|
||||
|
||||
Two endpoints — same shape. Pick by intent. Source: `MibCardsClient.kt:74-103`.
|
||||
|
||||
```
|
||||
Referer: https://faisamobilex-wv.mib.com.mv//debitCards/manage?cardId=<cardId>&dashurl=1
|
||||
```
|
||||
|
||||
### Request Body (form-urlencoded)
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `cardId` | Card identifier from `fetchCardInfos` |
|
||||
| `comments` | User-supplied reason (free text; may be empty) |
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonText": "Card frozen successfully",
|
||||
"currentStatusCode": "F"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `success` | `true` on success |
|
||||
| `reasonText` | Human-readable message (also returned on failure — show to user) |
|
||||
| `currentStatusCode` | New card status code after the action (e.g. `"F"` = frozen, `"A"` = active) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
+123
-14
@@ -1,18 +1,23 @@
|
||||
# Personal Profile
|
||||
|
||||
Fetch the user's personal profile details. This endpoint returns an HTML page; data is extracted via HTML scraping.
|
||||
This document covers two unrelated profile-adjacent surfaces:
|
||||
|
||||
1. **Personal profile** — an HTML page on the WebView host, scraped for display.
|
||||
2. **Profile image management** — three encrypted-API endpoints (`P40`/`P41`/`P42`) that fetch, upload, and delete the avatar.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint
|
||||
## 1. Personal Profile (HTML scrape)
|
||||
|
||||
Fetch the user's personal profile details. This endpoint returns an HTML page; data is extracted via HTML scraping.
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
GET https://faisamobilex-wv.mib.com.mv/personalProfile
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
### Authentication
|
||||
|
||||
Session cookies only — no additional AJAX headers required.
|
||||
|
||||
@@ -20,9 +25,7 @@ Session cookies only — no additional AJAX headers required.
|
||||
Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response
|
||||
### Response
|
||||
|
||||
**Content-Type:** `text/html; charset=UTF-8`
|
||||
|
||||
@@ -53,9 +56,7 @@ Regex(
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Extracted Fields
|
||||
### Extracted Fields
|
||||
|
||||
| Label in HTML | Field | Description |
|
||||
|---|---|---|
|
||||
@@ -76,15 +77,123 @@ data class MibPersonalProfile(
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
### Notes
|
||||
|
||||
- Returns `null` if the response cannot be parsed (network error or unexpected HTML structure).
|
||||
- This endpoint does not have a JSON equivalent — scraping is the only method.
|
||||
|
||||
---
|
||||
|
||||
## 2. Profile Image Management
|
||||
|
||||
The avatar lives on the **encrypted API** (`faisanet.mib.com.mv`), not the WebView host. Three `sfunc=n` route paths cover fetch, upload, and delete. All three follow the standard encrypted-request format (see [01-encryption.md](01-encryption.md) and [02-login.md](02-login.md)) and include the standard `baseData` fields (`nonce`, `appId`, `sodium`, `routePath`, `xxid`).
|
||||
|
||||
| `routePath` | Operation | Source |
|
||||
|---|---|---|
|
||||
| `P41` | Fetch image by hash | `MibLoginFlow.kt:368-375` |
|
||||
| `P40` | Upload new image | `MibLoginFlow.kt:382-391` |
|
||||
| `P42` | Delete current image | `MibLoginFlow.kt:397-403` |
|
||||
|
||||
### Image-hash field naming
|
||||
|
||||
The same conceptual value — "the hash that identifies a customer's avatar" — is exposed under **two different field names** depending on which endpoint returned it:
|
||||
|
||||
| Source endpoint | Field name |
|
||||
|---|---|
|
||||
| `A41` login init (`operatingProfiles[]`) | `customerImage` |
|
||||
| `C41` registration init (root) | `customerImgHash` |
|
||||
| `ajaxBeneficiary/main` (contacts list) | `customerImgHash` |
|
||||
|
||||
Both are non-empty hash strings that can be passed straight into `P41` as `imageHash`. Treat them as the same value with two names.
|
||||
|
||||
### Fetch — `routePath: P41`
|
||||
|
||||
**Request** (encrypted payload):
|
||||
```json
|
||||
{
|
||||
"sfunc": "n",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"imageHash": "<customerImage or customerImgHash>",
|
||||
"nonce": "<computed nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random>",
|
||||
"routePath": "P41",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"reasonCode": "201",
|
||||
"profileImage": "<base64-encoded JPEG>"
|
||||
}
|
||||
```
|
||||
|
||||
`profileImage` is raw base64 with no data URI prefix.
|
||||
|
||||
### Upload — `routePath: P40`
|
||||
|
||||
**Request** (encrypted payload):
|
||||
```json
|
||||
{
|
||||
"sfunc": "n",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"profileId": "<active profile ID>",
|
||||
"profileImage": "<base64-encoded JPEG>",
|
||||
"nonce": "<computed nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random>",
|
||||
"routePath": "P40",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"imageHash": "<new hash>",
|
||||
"customerImage": "<new hash, alternate field name>"
|
||||
}
|
||||
```
|
||||
|
||||
The server may populate either `imageHash` or `customerImage` for the new hash — the client reads both (`MibLoginFlow.kt:389-390`) and prefers `imageHash` when present.
|
||||
|
||||
### Delete — `routePath: P42`
|
||||
|
||||
**Request** (encrypted payload):
|
||||
```json
|
||||
{
|
||||
"sfunc": "n",
|
||||
"xxid": "<session xxid>",
|
||||
"data": {
|
||||
"profileId": "<active profile ID>",
|
||||
"nonce": "<computed nonce>",
|
||||
"appId": "<appId>",
|
||||
"sodium": "<random>",
|
||||
"routePath": "P42",
|
||||
"xxid": "<session xxid>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
After a successful delete, the `customerImage` field on subsequent `A41` responses is blank.
|
||||
|
||||
> Note: BML and Fahipay profile images are stored locally on-device only (`util/ProfileImageStore.kt`). Only MIB persists avatars server-side via these endpoints.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -48,6 +48,8 @@ Body: `benefAccount=7700000000000`
|
||||
|
||||
Use `bankNo=3` and `transferLocal` for the transfer.
|
||||
|
||||
> **USD cross-bank accounts**: `getIPSAccount` only succeeds for **MVR** cross-bank accounts. A 13-digit `7…` account that is denominated in USD returns `success: false` here and cannot be resolved via IPS at all. The client hardcodes `currency = "MVR"` for IPS results (`MibTransferClient.kt:135-137`). For USD BML→MIB transfers the user must first save a BML contact (see Notes below).
|
||||
|
||||
---
|
||||
|
||||
### 1b. MIB Internal Account (17 digits, starts with `9`)
|
||||
@@ -62,11 +64,13 @@ Body: `accountNo=90100000000000000`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"accountName": "ACCOUNT HOLDER NAME"
|
||||
"accountName": "ACCOUNT HOLDER NAME",
|
||||
"currencyCode": "462"
|
||||
}
|
||||
```
|
||||
|
||||
- `accountName` may be at root level or inside a `data` object — check both
|
||||
- `accountName` may be at root level or inside a `data` object — check both (`MibTransferClient.kt:160-161`)
|
||||
- `currencyCode` may also be at root level or inside `data` (`MibTransferClient.kt:162-163`). `"462"` = MVR, `"840"` = USD. The client maps this into `MibIpsAccountInfo.currency` ∈ `{"MVR", "USD", ""}` — this is the **MIB→MIB USD detection** fix from commit `16fd909`.
|
||||
- Bank is always MIB (`MADVMVMV`)
|
||||
|
||||
Use `bankNo=2` and `transferInternal` for the transfer.
|
||||
@@ -201,6 +205,26 @@ POST https://faisamobilex-wv.mib.com.mv/ajaxTransfer/transferLocal
|
||||
|
||||
---
|
||||
|
||||
## `MibIpsAccountInfo` (client model)
|
||||
|
||||
All three lookups return this unified structure (`MibModels.kt:42-47`):
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `accountName` | Account holder name (trimmed) |
|
||||
| `accountNumber` | Resolved account number |
|
||||
| `bankId` | Bank BIC (`MADVMVMV` = MIB, `MALBMVMV` = BML, etc.) |
|
||||
| `currency` | `"MVR"`, `"USD"`, or `""` (unknown). Populated from `currencyCode` for MIB internal lookups; hardcoded `"MVR"` for IPS lookups; default `""` for alias lookups. |
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- **BML → MIB USD transfers** require a saved BML contact first. Because `getIPSAccount` rejects USD accounts (`success: false`), the app cannot validate the BML USD account number directly. The workaround in `TransferFragment.kt` is to call `MibContactsClient.createContact` (see [09-contacts.md](09-contacts.md)) to auto-add the BML account as a beneficiary, then transfer to that beneficiary. Introduced in commit `16fd909`.
|
||||
- **Session expiry**: HTTP `419` on either lookup or transfer means the session expired. See [README](README.md) for the unified expiry detection rules.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -94,15 +94,23 @@ POST https://faisamobilex-wv.mib.com.mv/ajaxBeneficiary/main
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `benefNo` | Unique beneficiary ID — use for delete |
|
||||
| `benefNickName` | User-assigned nickname (prefer over `benefName` for display) |
|
||||
| `benefType` | `L`, `I`, or `S` |
|
||||
| `bankColor` | Hex color for placeholder avatar background |
|
||||
| `customerImgHash` | Hash for fetching profile photo (`null` if no photo) |
|
||||
| `benefCategoryID` | Category ID — `"0"` means uncategorized |
|
||||
| `transferCyDesc` | Currency (e.g. `"MVR"`, `"USD"`) |
|
||||
| Field | Consumed | Description |
|
||||
|---|---|---|
|
||||
| `benefNo` | yes | Unique beneficiary ID — use for delete |
|
||||
| `benefName` | yes | Legal/full name; used as fallback when nickname is blank |
|
||||
| `benefNickName` | yes | User-assigned nickname (prefer over `benefName` for display) |
|
||||
| `benefAccount` | yes | Account number |
|
||||
| `benefType` | yes | `L`, `I`, or `S` |
|
||||
| `bankColor` | yes | Hex color for placeholder avatar background |
|
||||
| `benefBankName` | yes | Bank display name. Shown as transfer subtitle and contact detail (`MibContactParser.kt:23, 26`). |
|
||||
| `bankCode` | yes | Short bank code (e.g. `"BML"`, `"MIB"`) |
|
||||
| `benefStatus` | yes | Beneficiary status (`"A"` = active) |
|
||||
| `transferCyDesc` | yes | Currency (e.g. `"MVR"`, `"USD"`) |
|
||||
| `customerImgHash` | yes | Hash for fetching profile photo. May be missing, blank, or the literal **string** `"null"` — the client filters all three (`MibContactsClient.kt:120`) so downstream code only sees a real hash or `null`. |
|
||||
| `benefCategoryID` | yes | Category ID — `"0"` means uncategorized |
|
||||
| `benefSwiftCode` | server-only | SWIFT BIC of the beneficiary's bank — present in payload, not read by the app |
|
||||
| `benefBankId` | server-only | Numeric bank ID — not consumed |
|
||||
| `transferCy` | server-only | Currency numeric code (e.g. `"462"`) — only `transferCyDesc` is consumed |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# Activity History
|
||||
|
||||
Fetch the audit/activity log for the authenticated session. This is a separate feed from transaction history ([04-history.md](04-history.md)) — it records login events, profile switches, transfers initiated, beneficiary edits, etc.
|
||||
|
||||
Source: `MibActivityHistoryClient.kt`.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST https://faisamobilex-wv.mib.com.mv/aProfile/getPagedActivityHistory
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
WebView session cookies (see [README](README.md)) plus `X-Requested-With: XMLHttpRequest`.
|
||||
|
||||
Unlike most WebView AJAX calls, this endpoint sends **no `Referer`** and **no `Origin`** header.
|
||||
|
||||
```
|
||||
Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<nonceGenerator>; time-tracker=597
|
||||
X-Requested-With: XMLHttpRequest
|
||||
User-Agent: Mozilla/5.0 (Linux; Android <ver>; wv) AppleWebKit/537.36 ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Request Body (form-urlencoded)
|
||||
|
||||
| Field | Value | Description |
|
||||
|---|---|---|
|
||||
| `start` | `1` | Start record index (1-based, inclusive) |
|
||||
| `end` | `100` | End record index (inclusive) |
|
||||
| `includeCount` | `1` | Return `total_count` in the response |
|
||||
|
||||
The app uses a default page size of **100** (`MibActivityHistoryClient.kt:120`).
|
||||
|
||||
---
|
||||
|
||||
## Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"total_count": "248",
|
||||
"data": [
|
||||
{
|
||||
"aid": "A0001",
|
||||
"activityType": "Local Transfer",
|
||||
"pa": "You",
|
||||
"activity": "transferred MVR 100.00 to",
|
||||
"pb": "Ahmed Ali",
|
||||
"date": "16 May 2026 15:10"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `success` | `true` on success |
|
||||
| `total_count` | Total entries on the server side (as a string — parse to int) |
|
||||
| `data` | Array of activity records |
|
||||
|
||||
### Record fields
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `aid` | Activity ID — used as the notification ID for read-state tracking |
|
||||
| `activityType` | Category label (e.g. `"Local Transfer"`, `"Beneficiary Added"`, `"Switch Profile"`, `"Log in"`) |
|
||||
| `pa` | Subject — the actor, typically `"You"` |
|
||||
| `activity` | Verb phrase describing the action |
|
||||
| `pb` | Object — counterparty / target of the action |
|
||||
| `date` | Timestamp formatted `"dd MMM yyyy HH:mm"` in **US locale** (parsed with `SimpleDateFormat("dd MMM yyyy HH:mm", Locale.US)`) |
|
||||
|
||||
### Display message
|
||||
|
||||
The app concatenates the three text fields with single spaces, skipping blanks:
|
||||
|
||||
```
|
||||
message = "$pa $activity $pb"
|
||||
```
|
||||
|
||||
E.g. `"You transferred MVR 100.00 to Ahmed Ali"`.
|
||||
|
||||
---
|
||||
|
||||
## Skipped Activity Types
|
||||
|
||||
The client hard-filters two `activityType` values out of the UI feed (`MibActivityHistoryClient.kt:13`):
|
||||
|
||||
```kotlin
|
||||
private val SKIP_TYPES = setOf("Switch Profile", "Log in")
|
||||
```
|
||||
|
||||
These records are still counted in `total_count` and still consume their slot in the requested `[start, end]` page. Pagination therefore has to fetch past them.
|
||||
|
||||
---
|
||||
|
||||
## Pagination — `fetchUntilEnough`
|
||||
|
||||
Because hidden types reduce the effective yield of each page, a thin helper repeats `fetchActivity` until enough visible records are collected or all pages are exhausted (`MibActivityHistoryClient.kt:116-134`):
|
||||
|
||||
```kotlin
|
||||
fun fetchUntilEnough(
|
||||
session: MibSession,
|
||||
loginId: String,
|
||||
minCount: Int = 5,
|
||||
pageSize: Int = 100
|
||||
): FetchResult
|
||||
```
|
||||
|
||||
Loop logic:
|
||||
|
||||
1. Start at `start = 1`.
|
||||
2. Call `fetchActivity(session, loginId, start, start + pageSize - 1)`.
|
||||
3. Append filtered items to the accumulator.
|
||||
4. Stop when **either** the accumulator has at least `minCount` items, **or** the raw page came back empty, **or** `start + pageSize - 1 >= totalCount`.
|
||||
5. Otherwise advance `start += pageSize` and repeat.
|
||||
|
||||
The returned `FetchResult` carries:
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `items` | Filtered, ready-to-display notifications |
|
||||
| `rawCount` | Total raw items consumed from the server (pre-filter) |
|
||||
| `totalCount` | Server-reported total |
|
||||
| `nextStart` | Next `start` to use for further pagination |
|
||||
|
||||
---
|
||||
|
||||
## Failure
|
||||
|
||||
Any non-2xx response, JSON parse failure, or `success: false` is mapped to an empty `FetchResult(emptyList(), 0, 0, end + 1)` — failures are silent. The caller distinguishes "no data" from "transient failure" by inspecting `totalCount`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← Contacts](09-contacts.md)
|
||||
+20
-1
@@ -48,6 +48,11 @@ Cookie: mbmodel=IOS-1.0; xxid=<session_xxid>; IBSID=<session_xxid>; mbnonce=<non
|
||||
|
||||
These values come from the login flow — `xxid` and `nonceGenerator` from the DH key exchange response.
|
||||
|
||||
**Cookie notes:**
|
||||
|
||||
- `time-tracker=597` is a **hardcoded constant** in the client. Every WebView client (transfers, contacts, cards, activity, etc.) sends the literal value `597` — it is not computed or rotated.
|
||||
- `mbnonce` is the **unmodified `nonceGenerator` string** from the key-exchange response. It does **not** carry a freshly-computed per-request nonce. The actual per-request nonce (derived via the algorithm in [01-encryption.md](01-encryption.md)) only appears inside the encrypted payloads of `sfunc=n` calls on the encrypted API — WebView endpoints have no nonce field.
|
||||
|
||||
### WebView AJAX Headers
|
||||
|
||||
All AJAX `POST` calls also require:
|
||||
@@ -59,7 +64,20 @@ Origin: https://faisamobilex-wv.mib.com.mv
|
||||
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
|
||||
```
|
||||
|
||||
The `Referer` value varies per endpoint (documented per endpoint).
|
||||
The `Referer` value varies per endpoint (documented per endpoint). A few endpoints (notably activity history, [10-activity-history.md](10-activity-history.md)) omit both `Referer` and `Origin`.
|
||||
|
||||
---
|
||||
|
||||
## Session Expiry Detection
|
||||
|
||||
A MIB session is considered expired when **either** condition is met (`MibLoginFlow.kt:248-274`):
|
||||
|
||||
| Signal | Source |
|
||||
|---|---|
|
||||
| HTTP **`419`** status | Encrypted API or any WebView endpoint |
|
||||
| JSON `reasonCode == "505"` in a decrypted response body | Encrypted API |
|
||||
|
||||
On detection the client auto-recovers by re-running the login flow using stored credentials, refreshes `xxid` + `nonceGenerator` in the in-flight payload, and retries the original request once. Callers receive the retried response transparently. If the recovery itself hits expiry again it surfaces a `SessionExpiredException`.
|
||||
|
||||
### WebView User-Agent
|
||||
|
||||
@@ -82,6 +100,7 @@ Mozilla/5.0 (Linux; Android {version}; wv) AppleWebKit/537.36 (KHTML, like Gecko
|
||||
| 7 | [07-profile.md](07-profile.md) | Personal profile (HTML scrape) |
|
||||
| 8 | [08-transfer.md](08-transfer.md) | Account lookup and fund transfer |
|
||||
| 9 | [09-contacts.md](09-contacts.md) | Beneficiary management |
|
||||
| 10 | [10-activity-history.md](10-activity-history.md) | Activity / audit log |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,16 +17,22 @@ Architecture overview of the app's entry point, main container, navigation syste
|
||||
|
||||
### Intent Actions
|
||||
|
||||
External intents (from NFC, shortcuts, or notifications) are passed through to `HomeActivity` via the same forwarding intent:
|
||||
External intents (from NFC, shortcuts, or notifications) are forwarded to `HomeActivity` via the same intent (`MainActivity.kt:57-65`):
|
||||
|
||||
| Action | Effect |
|
||||
|---|---|
|
||||
| `OPEN_TRANSFER` | Opens transfer screen |
|
||||
| `OPEN_SCAN_QR` | Opens QR scanner |
|
||||
| `OPEN_PAY_WITH_CARD` | Opens BML card QR payment |
|
||||
| `TAP_TO_PAY` | Opens BML tap-to-pay NFC flow |
|
||||
| Action | Destination | Notes |
|
||||
|---|---|---|
|
||||
| `sh.sar.basedbank.OPEN_TRANSFER` | `R.id.nav_transfer` | Plain transfer screen |
|
||||
| `sh.sar.basedbank.OPEN_SCAN_QR` | `R.id.nav_transfer` + `auto_scan=true` | Opens [QR scanner](25-qr-scanner.md) immediately |
|
||||
| `sh.sar.basedbank.OPEN_PAY_WITH_CARD` | `R.id.nav_pay_with_card` | Opens [Cards](22-cards.md) (`CardsFragment`) |
|
||||
| `sh.sar.basedbank.TAP_TO_PAY` | `R.id.nav_pay_with_card` + `auto_tap_mode=true` | Enters [Tap to Pay](23-tap-to-pay.md) on the default card |
|
||||
|
||||
`BmlTapToPayActivity` is a dedicated NFC entry point registered in the manifest. It immediately re-fires a `TAP_TO_PAY` intent to `MainActivity` and finishes.
|
||||
### Share-to-Scan (`ACTION_SEND`)
|
||||
|
||||
When another app shares an image to the **Scan to Pay** activity-alias declared in `AndroidManifest.xml`, `MainActivity.kt:47-55` decodes the bitmap on the spot using `ZxingCpp` (while it still holds the share URI permission) and forwards the decoded QR text as `share_qr_text` to `HomeActivity`.
|
||||
|
||||
### NFC Entry Point
|
||||
|
||||
`BmlTapToPayActivity` (manifest-registered, NFC payment service redirect) immediately re-fires a `TAP_TO_PAY` intent to `MainActivity` and finishes — see [Tap to Pay](23-tap-to-pay.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -48,6 +54,9 @@ External intents (from NFC, shortcuts, or notifications) are passed through to `
|
||||
|---|---|
|
||||
| Lock icon | Immediately locks the app → `LockActivity` (animated with scale + alpha) |
|
||||
| Eye icon | Toggles `hideAmounts` in `HomeViewModel`; all balance displays redact to `••••` |
|
||||
| Bell icon | Opens `NotificationsSheetFragment` ([Notifications](24-notifications.md)). Shows `ic_bell` when there are unread notifications, `ic_bell_read` otherwise (`HomeActivity.kt:640-684`) |
|
||||
|
||||
In Circular nav mode the toolbar collapses to just the app title — the lock, eye, and bell items are all hidden (`HomeActivity.kt:646-650`).
|
||||
|
||||
### Auto-refresh
|
||||
|
||||
@@ -61,36 +70,46 @@ A persistent banner appears at the top of `HomeActivity` when network connectivi
|
||||
|
||||
## Navigation Modes
|
||||
|
||||
The user can choose between two navigation modes in Settings → Appearance:
|
||||
Three modes are selectable in Settings → Appearance (`NavCustomization.kt:10-12`):
|
||||
|
||||
### Drawer (default)
|
||||
### Drawer (default) — `NAV_MODE_DRAWER`
|
||||
|
||||
A slide-out navigation drawer containing up to 10 configurable nav items. The hamburger icon in the toolbar opens it.
|
||||
A slide-out navigation drawer containing all configurable nav items. The hamburger icon in the toolbar opens it.
|
||||
|
||||
### Bottom Navigation
|
||||
### Bottom Navigation — `NAV_MODE_BOTTOM`
|
||||
|
||||
A bottom bar with 3 configurable slots plus a fixed **Dashboard** tab (always leftmost) and a **More** tab (always rightmost). Tapping **More** opens `NavMoreSheetFragment` — a bottom sheet listing all items not assigned to the 3 visible slots.
|
||||
A bottom bar with **3** configurable slots plus a fixed **Dashboard** tab (always leftmost) and a **More** tab (always rightmost). Tapping **More** opens `NavMoreSheetFragment` — a bottom sheet listing all items not assigned to the visible slots.
|
||||
|
||||
### Circular — `NAV_MODE_CIRCULAR`
|
||||
|
||||
A radial wheel UI with 4 customisable wheel slots + a lock-icon centre — see [Circular Nav](26-circular-nav.md).
|
||||
|
||||
---
|
||||
|
||||
## Navigation Slots
|
||||
|
||||
10 possible navigation destinations can be assigned to slots. The user reorders them via drag-and-drop in Settings → Appearance.
|
||||
`NavCustomization.ALL_SWAPPABLE` enumerates every reorderable destination. Quick actions and bottom-bar slots persist as individual `SharedPreferences` keys (`NavCustomization.kt:52-93`).
|
||||
|
||||
| Destination | Default slot |
|
||||
| Destination | Nav ID |
|
||||
|---|---|
|
||||
| Accounts | 1 |
|
||||
| Transfer | 2 |
|
||||
| Activities | 3 |
|
||||
| Contacts | 4 |
|
||||
| Financing | 5 |
|
||||
| OTP | 6 |
|
||||
| PayMV QR | 7 |
|
||||
| BML QR Pay | 8 |
|
||||
| Transfer History | 9 |
|
||||
| Settings | 10 |
|
||||
| Accounts | `nav_accounts` |
|
||||
| Contacts | `nav_contacts` |
|
||||
| Transfer | `nav_transfer` |
|
||||
| PayMV QR | `nav_pay_mv_qr` |
|
||||
| Activities | `nav_activities` |
|
||||
| Transfer History | `nav_transfer_history` |
|
||||
| Financing | `nav_finances` |
|
||||
| Cards | `nav_pay_with_card` |
|
||||
| OTP | `nav_otp` |
|
||||
| Settings | `nav_settings` |
|
||||
|
||||
Two **Quick Action** slots appear as FAB-style buttons on the dashboard and are independently configurable.
|
||||
Defaults:
|
||||
|
||||
| Slot set | Defaults |
|
||||
|---|---|
|
||||
| Bottom-bar (3 slots) | Accounts, Contacts, Transfer |
|
||||
| Circular wheel (4 slots) | Transfer, Cards, Contacts, Accounts |
|
||||
| Quick actions (2 FAB slots on dashboard) | Transfer, PayMV QR |
|
||||
|
||||
---
|
||||
|
||||
@@ -101,10 +120,11 @@ Autolock fires after a configurable period of user inactivity. Any touch event r
|
||||
| Timeout option |
|
||||
|---|
|
||||
| 30 seconds |
|
||||
| 1 minute |
|
||||
| 1 minute (default) |
|
||||
| 3 minutes |
|
||||
| 5 minutes |
|
||||
| Never |
|
||||
|
||||
There is no "Never" option (`SettingsSecurityFragment.kt:81-86`) — auto-lock cannot be disabled.
|
||||
|
||||
When the timeout expires a 10-second countdown warning dialog appears. If dismissed, the timer resets. If ignored, the app calls `LockActivity` and clears `app.isUnlocked`.
|
||||
|
||||
@@ -112,17 +132,24 @@ When the timeout expires a 10-second countdown warning dialog appears. If dismis
|
||||
|
||||
## Global State — `BasedBankApp`
|
||||
|
||||
`BasedBankApp` holds all in-memory session data. Nothing is stored to disk except encrypted credentials.
|
||||
`BasedBankApp` holds all in-memory session data (`BasedBankApp.kt:27-49`). Nothing is stored to disk except encrypted credentials.
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `isUnlocked` | Set to `true` after successful lock-screen auth; guards against process-restart bypass |
|
||||
| `mibSessions` | Map of MIB profile ID → active session (cookies + DH key) |
|
||||
| `bmlSessions` | Map of BML profile ID → OAuth token pair |
|
||||
| `fahipaySessions` | Map of Fahipay login ID → authID + session cookie |
|
||||
| `mibLoginFlows` | Active `MibLoginFlow` instances per profile |
|
||||
| `bmlLoginFlows` | Active `BmlLoginFlow` instances per profile |
|
||||
| `mibMutex` | Coroutine mutex — serializes all MIB API calls to prevent session corruption |
|
||||
| `accounts: List<BankAccount>` | Combined view of every visible account across all banks |
|
||||
| `fullName: String` | Account holder name (best available across logins) |
|
||||
| `mibSessions` | Map of MIB loginId → active session (cookies + DH key) |
|
||||
| `mibProfilesMap` | Per-loginId list of MIB CIF profiles |
|
||||
| `mibLoginFlows` | Active `MibLoginFlow` instances per login |
|
||||
| `mibAccounts: List<BankAccount>` | MIB-only slice of `accounts` |
|
||||
| `bmlSessions` | Map of BML profileId → OAuth tokens |
|
||||
| `bmlProfilesMap` | Per-loginId list of `BmlProfile` |
|
||||
| `bmlLoginFlows` | Active `BmlLoginFlow` instances per login (holds web session cookies for activation) |
|
||||
| `bmlAccounts: List<BankAccount>` | BML-only slice |
|
||||
| `fahipaySessions` | Map of Fahipay loginId → authID + session cookie |
|
||||
| `fahipayAccounts: List<BankAccount>` | Fahipay-only slice |
|
||||
| `mibMutex` | Coroutine mutex — serializes all MIB profile-switch + request sequences |
|
||||
|
||||
### Profile Visibility
|
||||
|
||||
|
||||
@@ -6,18 +6,25 @@ Shown once on first launch. Walks the user through language selection, security
|
||||
|
||||
## Activity — `OnboardingActivity`
|
||||
|
||||
`OnboardingActivity` hosts three sequential fragments managed by a `ViewPager2` with manual paging (swipe disabled). Progress dots are shown below the pager.
|
||||
`OnboardingActivity` hosts **4** pages managed by a `ViewPager2` (`OnboardingPagerAdapter.kt:32-38`). The adapter returns `slides.size + 1` items — three text slides plus the security-setup page inserted at position 1. Tap-to-navigate on the page-indicator dots is disabled; the only forward motion is the bottom **Next** / **Get Started** button.
|
||||
|
||||
Each fragment has a **Continue** button that is only enabled after the user satisfies a completion requirement. Scrolling to the bottom of a slide is required before Continue activates on content slides.
|
||||
| Position | Fragment | Purpose |
|
||||
|---|---|---|
|
||||
| 0 | `OnboardingFragment` (welcome) | Language pick + welcome copy |
|
||||
| 1 | `SecuritySetupFragment` | PIN or pattern setup |
|
||||
| 2 | `OnboardingConfigureFragment` | Theme, accent, nav mode |
|
||||
| 3 | `OnboardingFragment` (`isLast = true`) | Final screen with **Get Started** |
|
||||
|
||||
Each page has a gate that controls when the bottom button activates.
|
||||
|
||||
---
|
||||
|
||||
## Slide 1 — Language & Welcome (`OnboardingFragment`)
|
||||
|
||||
- Displays a welcome illustration and app name
|
||||
- Language selector chip group (English / Dhivehi)
|
||||
- Selecting a language immediately updates the app locale
|
||||
- Continue button becomes active once a language is selected (or immediately if system locale is already supported)
|
||||
- Language toggle group (`languageToggle` in `OnboardingActivity.kt:86-92`): **English** / **Dhivehi** only
|
||||
- Selecting a language calls `AppCompatDelegate.setApplicationLocales()` immediately
|
||||
- The language section is only visible while the user is on page 0
|
||||
|
||||
---
|
||||
|
||||
@@ -70,9 +77,15 @@ All preferences are written to `CredentialStore` / `SharedPreferences` immediate
|
||||
|
||||
---
|
||||
|
||||
## Final Slide — Get Started
|
||||
|
||||
The last page is another `OnboardingFragment` with `isLast = true`. After the user scrolls it to the bottom, a 5-second `CountDownTimer` (`OnboardingActivity.kt:95-97`, `155-164`) starts — the **Get Started** button is disabled until the timer reaches zero, with the remaining seconds shown in the button label.
|
||||
|
||||
---
|
||||
|
||||
## Completion
|
||||
|
||||
When the user taps Continue on slide 3, `OnboardingActivity` sets the `onboardingDone` flag and finishes. `MainActivity` then routes to `LoginActivity` (no credentials yet) on the next launch or immediately via `startActivity`.
|
||||
When the user taps **Get Started**, `OnboardingActivity` sets the `onboarding_done` flag, marks `app.isUnlocked = true`, and launches `LoginActivity` directly so the user does not have to re-authenticate immediately after setup.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user