Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caf0db60da
|
||
|
|
b48022d249
|
||
|
|
7574a5e8b0
|
||
|
|
992c8d4364
|
||
|
|
80fd238195
|
||
|
|
0357d7e0bc
|
||
|
|
71dd654edc
|
||
|
|
a75d8e420a
|
||
|
|
ca6ecc4283
|
||
|
|
f59c2be6c4
|
||
|
|
eb019a20a0
|
||
|
|
c356762baa
|
||
|
|
255f43db24
|
||
|
|
01cae559cf
|
||
|
|
015919a4ac
|
||
|
|
93a7c8bbde
|
||
|
|
8f4672f269
|
||
|
|
00e6b40ee0
|
||
|
|
86e1dc0521
|
||
|
|
a90d832dba
|
||
|
|
51c2dff4b2
|
||
|
|
43f3cca2aa
|
||
|
|
0e226d17ae
|
@@ -16,6 +16,7 @@ local.properties
|
|||||||
docs/mibapi/tmp
|
docs/mibapi/tmp
|
||||||
docs/bmlapi/tmp
|
docs/bmlapi/tmp
|
||||||
docs/fahipayapi/tmp
|
docs/fahipayapi/tmp
|
||||||
|
docs/mfaisaapi/tmp
|
||||||
tmp
|
tmp
|
||||||
app/key.jks
|
app/key.jks
|
||||||
.kotlin/*
|
.kotlin/*
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ android {
|
|||||||
applicationId = "sh.sar.basedbank"
|
applicationId = "sh.sar.basedbank"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 20
|
versionCode = 26
|
||||||
versionName = "1.0.20"
|
versionName = "1.0.25"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import sh.sar.basedbank.api.bml.BmlLoginFlow
|
|||||||
import sh.sar.basedbank.api.bml.BmlProfile
|
import sh.sar.basedbank.api.bml.BmlProfile
|
||||||
import sh.sar.basedbank.api.bml.BmlSession
|
import sh.sar.basedbank.api.bml.BmlSession
|
||||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
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.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||||
import sh.sar.basedbank.api.mib.MibProfile
|
import sh.sar.basedbank.api.mib.MibProfile
|
||||||
@@ -48,6 +49,10 @@ class BasedBankApp : Application() {
|
|||||||
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
|
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
|
||||||
var fahipayAccounts: List<BankAccount> = emptyList()
|
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 ──────────────────────────────────────────────────────────
|
// ─── MIB helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Returns the MIB session for the given account (matched via loginTag). */
|
/** Returns the MIB session for the given account (matched via loginTag). */
|
||||||
@@ -110,6 +115,26 @@ class BasedBankApp : Application() {
|
|||||||
fun fahipaySessionFor(account: BankAccount): FahipaySession? =
|
fun fahipaySessionFor(account: BankAccount): FahipaySession? =
|
||||||
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
|
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. */
|
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
|
||||||
val mibMutex = Mutex()
|
val mibMutex = Mutex()
|
||||||
|
|
||||||
|
|||||||
@@ -279,7 +279,8 @@ class LockActivity : AppCompatActivity() {
|
|||||||
finish()
|
finish()
|
||||||
} else {
|
} else {
|
||||||
val store = CredentialStore(this)
|
val store = CredentialStore(this)
|
||||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() ||
|
||||||
|
store.hasFahipayCredentials() || store.hasMfaisaCredentials()
|
||||||
if (!hasCredentials) {
|
if (!hasCredentials) {
|
||||||
startActivity(Intent(this, sh.sar.basedbank.ui.login.LoginActivity::class.java))
|
startActivity(Intent(this, sh.sar.basedbank.ui.login.LoginActivity::class.java))
|
||||||
finish()
|
finish()
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
val onboardingDone = prefs.getBoolean("onboarding_done", false)
|
val onboardingDone = prefs.getBoolean("onboarding_done", false)
|
||||||
val securitySet = prefs.getString("security_method", null) != null
|
val securitySet = prefs.getString("security_method", null) != null
|
||||||
val store = CredentialStore(this)
|
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
|
// 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 &&
|
val shareQrText: String? = if (intent?.action == Intent.ACTION_SEND &&
|
||||||
|
|||||||
@@ -14,9 +14,15 @@ internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
|||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headers are sent in the order (and with the names) BML's own app uses. `accept` is not optional:
|
||||||
|
* handled errors come back as JSON either way, but when a route throws (HTTP 500) the server
|
||||||
|
* renders the Internet Banking HTML page — under HTTP 200 — unless the request asks for JSON.
|
||||||
|
*/
|
||||||
internal fun bmlApiRequest(session: BmlSession, url: String): Request =
|
internal fun bmlApiRequest(session: BmlSession, url: String): Request =
|
||||||
Request.Builder().url(url)
|
Request.Builder().url(url)
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
.header("accept", "application/json")
|
||||||
.header("User-Agent", BML_USER_AGENT)
|
|
||||||
.header("x-app-version", BML_APP_VERSION)
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.header("user-agent", BML_USER_AGENT)
|
||||||
|
.header("authorization", "Bearer ${session.accessToken}")
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -74,6 +74,13 @@ data class BmlQrPayInfo(
|
|||||||
val currency: String
|
val currency: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pay-request lookup answered `success: false` — [message] is BML's own wording, safe to show
|
||||||
|
* (e.g. code 103 "The payment request has expired", 112 "Unsupported payment link"). Network and
|
||||||
|
* parse failures stay plain exceptions so the generic message is used for those instead.
|
||||||
|
*/
|
||||||
|
class BmlQrPayLookupException(val code: Int, message: String) : Exception(message)
|
||||||
|
|
||||||
data class BmlQrPayResult(
|
data class BmlQrPayResult(
|
||||||
val success: Boolean,
|
val success: Boolean,
|
||||||
val merchant: String = "",
|
val merchant: String = "",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package sh.sar.basedbank.api.bml
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
@@ -10,17 +11,27 @@ class BmlQrPayClient {
|
|||||||
private val client = newBmlApiClient()
|
private val client = newBmlApiClient()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a BML QR URL to merchant details.
|
* Resolves a BML QR to merchant details. [payTarget] is the QR URL, or for POS QRs the bare
|
||||||
* [base64Url] is the full QR URL Base64-encoded (standard, with padding).
|
* `35` → `20` → `01` reference.
|
||||||
|
*
|
||||||
|
* The key is Base64-encoded here without padding, as BML's own app sends it. The padded form
|
||||||
|
* resolves too, so this is parity rather than a requirement.
|
||||||
*/
|
*/
|
||||||
fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo {
|
fun lookupPayRequest(session: BmlSession, payTarget: String): BmlQrPayInfo {
|
||||||
|
val key = Base64.encodeToString(
|
||||||
|
payTarget.toByteArray(Charsets.UTF_8), Base64.NO_WRAP or Base64.NO_PADDING)
|
||||||
val request = bmlApiRequest(session,
|
val request = bmlApiRequest(session,
|
||||||
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url")
|
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$key")
|
||||||
return client.newCall(request).execute().use { response ->
|
return client.newCall(request).execute().use { response ->
|
||||||
val body = response.body?.string() ?: throw Exception("No response")
|
val body = response.body?.string() ?: throw Exception("No response")
|
||||||
|
// A server-side error renders an HTML page under HTTP 200 rather than an error JSON.
|
||||||
|
if (!body.trimStart().startsWith("{"))
|
||||||
|
throw Exception("Unexpected non-JSON response (HTTP ${response.code})")
|
||||||
val json = JSONObject(body)
|
val json = JSONObject(body)
|
||||||
if (!json.optBoolean("success"))
|
if (!json.optBoolean("success"))
|
||||||
throw Exception(json.optString("message").ifBlank { "Lookup failed" })
|
throw BmlQrPayLookupException(
|
||||||
|
json.optInt("code"),
|
||||||
|
json.optString("message").ifBlank { "Lookup failed" })
|
||||||
val payload = json.getJSONObject("payload")
|
val payload = json.getJSONObject("payload")
|
||||||
val addr2 = payload.optString("narrative2").trim()
|
val addr2 = payload.optString("narrative2").trim()
|
||||||
val addr3 = payload.optString("narrative3").trim()
|
val addr3 = payload.optString("narrative3").trim()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,9 +84,10 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
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
|
val isCard = AccountListParser.from(account)?.isCard ?: false
|
||||||
if (!isCard) {
|
if (!isCard && account.bank != "MFAISA") {
|
||||||
val store = CredentialStore(requireContext())
|
val store = CredentialStore(requireContext())
|
||||||
adapter.showDefaultToggle = true
|
adapter.showDefaultToggle = true
|
||||||
adapter.isDefaultAccount = store.getDefaultAccountNumber() == account.accountNumber
|
adapter.isDefaultAccount = store.getDefaultAccountNumber() == account.accountNumber
|
||||||
|
|||||||
@@ -75,10 +75,12 @@ class AccountsAdapter(
|
|||||||
"BML" -> "Bank of Maldives"
|
"BML" -> "Bank of Maldives"
|
||||||
"FAHIPAY" -> "Fahipay"
|
"FAHIPAY" -> "Fahipay"
|
||||||
"MIB" -> "Maldives Islamic Bank"
|
"MIB" -> "Maldives Islamic Bank"
|
||||||
|
"MFAISA" -> if (account.profileType == "MFAISA_PAYPAL") "PayPal · M-Faisa" else "M-Faisa"
|
||||||
else -> account.bank
|
else -> account.bank
|
||||||
}
|
}
|
||||||
val profileLabel = when (account.bank) {
|
val profileLabel = when (account.bank) {
|
||||||
"MIB" -> account.productCode.ifBlank { account.profileName }
|
"MIB" -> account.productCode.ifBlank { account.profileName }
|
||||||
|
"MFAISA" -> "" // bank-level grouping is already specific (M-Faisa / PayPal · M-Faisa)
|
||||||
else -> account.profileName
|
else -> account.profileName
|
||||||
}
|
}
|
||||||
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
|
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
|
||||||
@@ -144,6 +146,7 @@ class AccountsAdapter(
|
|||||||
"BML" -> R.drawable.bml_logo_vector
|
"BML" -> R.drawable.bml_logo_vector
|
||||||
"FAHIPAY" -> R.drawable.fahipay_logo
|
"FAHIPAY" -> R.drawable.fahipay_logo
|
||||||
"MIB" -> R.drawable.mib_logo
|
"MIB" -> R.drawable.mib_logo
|
||||||
|
"MFAISA" -> R.drawable.ooredoo_logo
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (staticLogo != null) binding.ivBankLogo.setImageResource(staticLogo)
|
if (staticLogo != null) binding.ivBankLogo.setImageResource(staticLogo)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import android.graphics.Canvas
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Base64
|
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -128,7 +127,6 @@ class BmlQrPayFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun lookupMerchant(qrUrl: String) {
|
private fun lookupMerchant(qrUrl: String) {
|
||||||
val base64Url = Base64.encodeToString(qrUrl.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
val session = app.anyBmlSession() ?: run {
|
val session = app.anyBmlSession() ?: run {
|
||||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
@@ -141,7 +139,7 @@ class BmlQrPayFragment : Fragment() {
|
|||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val info = withContext(Dispatchers.IO) {
|
val info = withContext(Dispatchers.IO) {
|
||||||
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
|
try { BmlQrPayClient().lookupPayRequest(session, qrUrl) }
|
||||||
catch (_: Exception) { null }
|
catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
if (_binding == null) return@launch
|
if (_binding == null) return@launch
|
||||||
|
|||||||
@@ -172,6 +172,10 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
bundle.putString(KEY_SUBTITLE, "BML QR Merchant")
|
bundle.putString(KEY_SUBTITLE, "BML QR Merchant")
|
||||||
bundle.putString(KEY_COLOR, "#0066A1")
|
bundle.putString(KEY_COLOR, "#0066A1")
|
||||||
}
|
}
|
||||||
|
accountNumber.startsWith("mfaisaqr:") -> {
|
||||||
|
bundle.putString(KEY_SUBTITLE, "M-Faisa QR Merchant")
|
||||||
|
bundle.putString(KEY_COLOR, "#ED1C24")
|
||||||
|
}
|
||||||
account != null -> {
|
account != null -> {
|
||||||
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
|
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
|
||||||
bundle.putString(KEY_COLOR, "#FE860E")
|
bundle.putString(KEY_COLOR, "#FE860E")
|
||||||
@@ -195,6 +199,17 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
val hide = viewModel.hideAmounts.value ?: false
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
val items = mutableListOf<ContactPickerAdapter.PickerItem>()
|
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) {
|
if (tabTag == RECENTS_TAG) {
|
||||||
val recents = RecentsCache.load(requireContext())
|
val recents = RecentsCache.load(requireContext())
|
||||||
val filtered = if (search.isBlank()) recents else recents.filter {
|
val filtered = if (search.isBlank()) recents else recents.filter {
|
||||||
@@ -208,19 +223,15 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
subtitle = r.subtitle,
|
subtitle = r.subtitle,
|
||||||
colorHex = r.colorHex,
|
colorHex = r.colorHex,
|
||||||
isSameAsFrom = r.accountNumber == fromAccountNumber,
|
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
|
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) {
|
if (tabTag == MY_ACCOUNTS_TAG) {
|
||||||
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" }
|
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" }
|
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",
|
colorHex = "#FE860E",
|
||||||
isSameAsFrom = isSame,
|
isSameAsFrom = isSame,
|
||||||
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
||||||
inactiveReason = if (isSame) null
|
inactiveReason = when {
|
||||||
else if (fromIsCard && acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
isSame -> null
|
||||||
else currencyMismatchReason(fromCurrency, acc.currencyName),
|
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,
|
balance = balance,
|
||||||
bankLogoRes = logoRes
|
bankLogoRes = logoRes
|
||||||
))
|
))
|
||||||
@@ -283,10 +297,13 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
colorHex = "#FE860E",
|
colorHex = "#FE860E",
|
||||||
isSameAsFrom = isSame,
|
isSameAsFrom = isSame,
|
||||||
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
||||||
inactiveReason = if (isSame) null
|
inactiveReason = when {
|
||||||
else if (!isActive) acc.statusDesc
|
isSame -> null
|
||||||
else if (acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
mfaisaInactive != null -> mfaisaInactive
|
||||||
else currencyMismatchReason(fromCurrency, acc.currencyName),
|
!isActive -> acc.statusDesc
|
||||||
|
acc.loginTag != fromLoginTag -> "Cards can only be used within the same BML account"
|
||||||
|
else -> currencyMismatchReason(fromCurrency, acc.currencyName)
|
||||||
|
},
|
||||||
balance = balance,
|
balance = balance,
|
||||||
bankLogoRes = logoRes
|
bankLogoRes = logoRes
|
||||||
))
|
))
|
||||||
@@ -311,7 +328,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
colorHex = contact.bankColor,
|
colorHex = contact.bankColor,
|
||||||
isSameAsFrom = contact.benefAccount == fromAccountNumber,
|
isSameAsFrom = contact.benefAccount == fromAccountNumber,
|
||||||
imageHash = contact.customerImgHash,
|
imageHash = contact.customerImgHash,
|
||||||
inactiveReason = currencyMismatchReason(fromCurrency, contact.transferCyDesc)
|
inactiveReason = mfaisaInactive ?: currencyMismatchReason(fromCurrency, contact.transferCyDesc)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ class DashboardFragment : Fragment() {
|
|||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
||||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
if (bmlTarget != null) {
|
||||||
(requireActivity() as HomeActivity).navigateTo(
|
(requireActivity() as HomeActivity).navigateTo(
|
||||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
|
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val qr = PaymvQrParser.parse(raw)
|
val qr = PaymvQrParser.parse(raw)
|
||||||
|
|||||||
@@ -202,9 +202,9 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load data
|
// 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
|
// 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()
|
viewModel.accounts.value = merged.filterVisibleAccounts()
|
||||||
if (app.mibAccounts.isNotEmpty()) AccountCache.save(this, app.mibAccounts)
|
if (app.mibAccounts.isNotEmpty()) AccountCache.save(this, app.mibAccounts)
|
||||||
if (app.bmlAccounts.isNotEmpty()) {
|
if (app.bmlAccounts.isNotEmpty()) {
|
||||||
@@ -215,6 +215,10 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
val byLoginId = app.fahipayAccounts.groupBy { it.loginTag.removePrefix("fahipay_") }
|
val byLoginId = app.fahipayAccounts.groupBy { it.loginTag.removePrefix("fahipay_") }
|
||||||
byLoginId.forEach { (loginId, accs) -> AccountCache.saveFahipay(this, loginId, accs) }
|
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)
|
val cachedCards = CardsCache.load(this)
|
||||||
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
||||||
@@ -238,7 +242,8 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
val cachedMib = AccountCache.load(this)
|
val cachedMib = AccountCache.load(this)
|
||||||
val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds())
|
val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds())
|
||||||
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
|
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
|
if (merged.isNotEmpty()) viewModel.accounts.value = merged
|
||||||
val cachedCards = CardsCache.load(this)
|
val cachedCards = CardsCache.load(this)
|
||||||
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
||||||
@@ -523,9 +528,9 @@ fun applyNavLabelVisibility() {
|
|||||||
|
|
||||||
private fun routeSharedQrText(text: String) {
|
private fun routeSharedQrText(text: String) {
|
||||||
val store = CredentialStore(this)
|
val store = CredentialStore(this)
|
||||||
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text)
|
val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text)
|
||||||
if (text.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
if (bmlTarget != null) {
|
||||||
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: text, store.getDefaultCardAccountNumber()))
|
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
|
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
|
||||||
@@ -695,17 +700,20 @@ fun applyNavLabelVisibility() {
|
|||||||
val mibLoginIds = store.getMibLoginIds()
|
val mibLoginIds = store.getMibLoginIds()
|
||||||
val bmlLoginIds = store.getBmlLoginIds()
|
val bmlLoginIds = store.getBmlLoginIds()
|
||||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
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))
|
startActivity(Intent(this, LoginActivity::class.java))
|
||||||
finish()
|
finish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Immediately drop accounts for logged-out logins from the displayed list
|
// Immediately drop accounts for logged-out logins from the displayed list
|
||||||
val current = viewModel.accounts.value ?: emptyList()
|
val current = viewModel.accounts.value ?: emptyList()
|
||||||
|
val mfaisaLoginIdsForFilter = store.getMfaisaLoginIds()
|
||||||
viewModel.accounts.value = current.filter { acc ->
|
viewModel.accounts.value = current.filter { acc ->
|
||||||
if (acc.bank == "MIB") return@filter acc.loginTag.removePrefix("mib_") in mibLoginIds
|
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 == "BML") return@filter acc.loginTag.removePrefix("bml_") in bmlLoginIds
|
||||||
if (acc.bank == "FAHIPAY") return@filter acc.loginTag.removePrefix("fahipay_") in fahipayLoginIds
|
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
|
true
|
||||||
}
|
}
|
||||||
autoRefresh(store)
|
autoRefresh(store)
|
||||||
@@ -724,7 +732,8 @@ fun applyNavLabelVisibility() {
|
|||||||
val mibLoginIds = store.getMibLoginIds()
|
val mibLoginIds = store.getMibLoginIds()
|
||||||
val bmlLoginIds = store.getBmlLoginIds()
|
val bmlLoginIds = store.getBmlLoginIds()
|
||||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
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
|
binding.refreshIndicator.visibility = View.VISIBLE
|
||||||
hideConnectivityBanner()
|
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 mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() }
|
||||||
val mibAccounts = mibResults.flatMap { it.second }
|
val mibAccounts = mibResults.flatMap { it.second }
|
||||||
val bmlAccounts = bmlJobs.flatMap { (_, job) -> job.await() }
|
val bmlAccounts = bmlJobs.flatMap { (_, job) -> job.await() }
|
||||||
val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() }
|
val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() }
|
||||||
|
val mfaisaAccounts = mfaisaJobs.flatMap { (_, job) -> job.await() }
|
||||||
|
|
||||||
val app = application as BasedBankApp
|
val app = application as BasedBankApp
|
||||||
app.mibAccounts = mibAccounts
|
app.mibAccounts = mibAccounts
|
||||||
AccountCache.save(this@HomeActivity, mibAccounts)
|
AccountCache.save(this@HomeActivity, mibAccounts)
|
||||||
app.bmlAccounts = bmlAccounts
|
app.bmlAccounts = bmlAccounts
|
||||||
app.fahipayAccounts = fahipayAccounts
|
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
|
binding.refreshIndicator.visibility = View.GONE
|
||||||
|
|
||||||
val noInternet = refreshErrors.any { it == "NO_INTERNET" }
|
val noInternet = refreshErrors.any { it == "NO_INTERNET" }
|
||||||
@@ -955,6 +1005,11 @@ fun applyNavLabelVisibility() {
|
|||||||
val hidden = store.getHiddenBmlProfileIds(loginId)
|
val hidden = store.getHiddenBmlProfileIds(loginId)
|
||||||
hidden.isEmpty() || acc.profileId !in hidden
|
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
|
else -> true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class PayMvQrFragment : Fragment() {
|
|||||||
val eligible = accounts.filter {
|
val eligible = accounts.filter {
|
||||||
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" &&
|
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" &&
|
||||||
it.bank != "MIB" && // TODO: MIB does not support PayMV QR
|
it.bank != "MIB" && // TODO: MIB does not support PayMV QR
|
||||||
|
it.bank != "MFAISA" && // TODO: M-Faisa PayMV QR not implemented yet
|
||||||
!(it.bank == "BML" && it.currencyName.contains("USD", ignoreCase = true)) // TODO: BML USD not supported by MMA
|
!(it.bank == "BML" && it.currencyName.contains("USD", ignoreCase = true)) // TODO: BML USD not supported by MMA
|
||||||
}
|
}
|
||||||
val adapter = QrAccountAdapter(requireContext(), eligible)
|
val adapter = QrAccountAdapter(requireContext(), eligible)
|
||||||
|
|||||||
@@ -77,10 +77,10 @@ class CardsFragment : Fragment() {
|
|||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
||||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
if (bmlTarget != null) {
|
||||||
(requireActivity() as HomeActivity).navigateTo(
|
(requireActivity() as HomeActivity).navigateTo(
|
||||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
|
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
val qr = PaymvQrParser.parse(raw)
|
val qr = PaymvQrParser.parse(raw)
|
||||||
|
|||||||
@@ -359,46 +359,87 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class LoginEntry(val logoRes: Int, val displayName: String, val onClick: () -> Unit)
|
||||||
|
|
||||||
private fun buildLoginsSection() {
|
private fun buildLoginsSection() {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val store = CredentialStore(ctx)
|
val store = CredentialStore(ctx)
|
||||||
val container = binding.loginsContainer
|
val container = binding.loginsContainer
|
||||||
container.removeAllViews()
|
container.removeAllViews()
|
||||||
|
|
||||||
val mibLoginIds = store.getMibLoginIds()
|
var anyLogins = false
|
||||||
val bmlLoginIds = store.getBmlLoginIds()
|
|
||||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
|
||||||
|
|
||||||
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
|
fun addGroup(loginIds: List<String>, entryFor: (String) -> LoginEntry, persistOrder: (List<String>) -> Unit) {
|
||||||
|
if (loginIds.isEmpty()) return
|
||||||
|
anyLogins = true
|
||||||
|
val groupContainer = LinearLayout(ctx).apply { orientation = LinearLayout.VERTICAL }
|
||||||
|
loginIds.forEachIndexed { index, loginId ->
|
||||||
|
val entry = entryFor(loginId)
|
||||||
|
fun swapAndPersist(otherIndex: Int) {
|
||||||
|
val newOrder = loginIds.toMutableList().apply {
|
||||||
|
val tmp = this[index]; this[index] = this[otherIndex]; this[otherIndex] = tmp
|
||||||
|
}
|
||||||
|
persistOrder(newOrder)
|
||||||
|
buildLoginsSection()
|
||||||
|
}
|
||||||
|
addLoginRow(
|
||||||
|
groupContainer, entry.logoRes, entry.displayName, entry.onClick,
|
||||||
|
canMoveUp = index > 0,
|
||||||
|
canMoveDown = index < loginIds.size - 1,
|
||||||
|
onMoveUp = { swapAndPersist(index - 1) },
|
||||||
|
onMoveDown = { swapAndPersist(index + 1) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
container.addView(groupContainer)
|
||||||
|
}
|
||||||
|
|
||||||
for (loginId in mibLoginIds) {
|
addGroup(store.getMibLoginIds(), { loginId ->
|
||||||
val profile = store.loadMibUserProfile(loginId)
|
val profile = store.loadMibUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
||||||
val mibProfiles = store.loadMibProfiles(loginId)
|
val mibProfiles = store.loadMibProfiles(loginId)
|
||||||
addLoginRow(container, R.drawable.mib_logo, displayName) {
|
LoginEntry(R.drawable.mib_logo, displayName) {
|
||||||
showMibLoginDetails(store, loginId, profile, mibProfiles)
|
showMibLoginDetails(store, loginId, profile, mibProfiles)
|
||||||
}
|
}
|
||||||
}
|
}, store::setMibLoginIds)
|
||||||
|
|
||||||
for (loginId in bmlLoginIds) {
|
addGroup(store.getBmlLoginIds(), { loginId ->
|
||||||
val profile = store.loadBmlUserProfile(loginId)
|
val profile = store.loadBmlUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
||||||
val bmlProfiles = store.loadBmlProfiles(loginId)
|
val bmlProfiles = store.loadBmlProfiles(loginId)
|
||||||
addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
|
LoginEntry(R.drawable.bml_logo_vector, displayName) {
|
||||||
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
|
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
|
||||||
}
|
}
|
||||||
}
|
}, store::setBmlLoginIds)
|
||||||
|
|
||||||
for (loginId in fahipayLoginIds) {
|
addGroup(store.getFahipayLoginIds(), { loginId ->
|
||||||
val profile = store.loadFahipayUserProfile(loginId)
|
val profile = store.loadFahipayUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
||||||
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
|
LoginEntry(R.drawable.fahipay_logo, displayName) {
|
||||||
showFahipayLoginDetails(store, loginId, profile)
|
showFahipayLoginDetails(store, loginId, profile)
|
||||||
}
|
}
|
||||||
}
|
}, store::setFahipayLoginIds)
|
||||||
|
|
||||||
|
addGroup(store.getMfaisaLoginIds(), { loginId ->
|
||||||
|
val profile = store.loadMfaisaUserProfile(loginId)
|
||||||
|
val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name)
|
||||||
|
LoginEntry(R.drawable.ooredoo_logo, displayName) {
|
||||||
|
showMfaisaLoginDetails(store, loginId, profile)
|
||||||
|
}
|
||||||
|
}, store::setMfaisaLoginIds)
|
||||||
|
|
||||||
|
binding.tvLoginsTitle.visibility = if (anyLogins) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addLoginRow(container: LinearLayout, logoRes: Int, displayName: String, onClick: () -> Unit) {
|
private fun addLoginRow(
|
||||||
|
container: LinearLayout,
|
||||||
|
logoRes: Int,
|
||||||
|
displayName: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
canMoveUp: Boolean,
|
||||||
|
canMoveDown: Boolean,
|
||||||
|
onMoveUp: () -> Unit,
|
||||||
|
onMoveDown: () -> Unit
|
||||||
|
) {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val dp = ctx.resources.displayMetrics.density
|
val dp = ctx.resources.displayMetrics.density
|
||||||
val row = LinearLayout(ctx).apply {
|
val row = LinearLayout(ctx).apply {
|
||||||
@@ -420,10 +461,31 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
||||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
}
|
}
|
||||||
row.addView(logo); row.addView(tvName)
|
val btnUp = makeMoveButton(ctx, rotationDeg = -90f, enabled = canMoveUp, onClick = onMoveUp)
|
||||||
|
val btnDown = makeMoveButton(ctx, rotationDeg = 90f, enabled = canMoveDown, onClick = onMoveDown)
|
||||||
|
row.addView(logo); row.addView(tvName); row.addView(btnUp); row.addView(btnDown)
|
||||||
container.addView(row)
|
container.addView(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun makeMoveButton(ctx: Context, rotationDeg: Float, enabled: Boolean, onClick: () -> Unit): ImageView {
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val size = (32 * dp).toInt()
|
||||||
|
return ImageView(ctx).apply {
|
||||||
|
setImageResource(R.drawable.ic_arrow_right)
|
||||||
|
rotation = rotationDeg
|
||||||
|
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||||||
|
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackgroundBorderless))
|
||||||
|
background = ta.getDrawable(0); ta.recycle()
|
||||||
|
val iconColor = com.google.android.material.color.MaterialColors.getColor(
|
||||||
|
ctx, com.google.android.material.R.attr.colorOnSurfaceVariant, android.graphics.Color.GRAY)
|
||||||
|
imageTintList = android.content.res.ColorStateList.valueOf(iconColor)
|
||||||
|
alpha = if (enabled) 1f else 0.3f
|
||||||
|
isClickable = enabled; isFocusable = enabled
|
||||||
|
layoutParams = LinearLayout.LayoutParams(size, size).apply { marginStart = (4 * dp).toInt() }
|
||||||
|
if (enabled) setOnClickListener { onClick() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun showMibLoginDetails(
|
private fun showMibLoginDetails(
|
||||||
store: CredentialStore,
|
store: CredentialStore,
|
||||||
loginId: String,
|
loginId: String,
|
||||||
@@ -1065,6 +1127,152 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
buildLoginsSection()
|
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) {
|
private fun clearAllCaches(ctx: Context) {
|
||||||
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
||||||
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ import sh.sar.basedbank.BasedBankApp
|
|||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
||||||
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
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.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||||
@@ -61,12 +62,18 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
var bmlTotalPages: Int = -1,
|
var bmlTotalPages: Int = -1,
|
||||||
var cardMonthOffset: Int = 0,
|
var cardMonthOffset: Int = 0,
|
||||||
var fahipayNextStart: 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 {
|
fun hasMore(): Boolean = when {
|
||||||
account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||||
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT" -> cardMonthOffset < 2
|
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT" -> cardMonthOffset < 2
|
||||||
account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||||
|
account.bank == "MFAISA" -> !isMfaisaPaypal && mfaisaHasMore
|
||||||
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,6 +243,36 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}.awaitAll().flatten())
|
}.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
|
// Fahipay accounts
|
||||||
val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" }
|
val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" }
|
||||||
for (state in fahipayStates) {
|
for (state in fahipayStates) {
|
||||||
|
|||||||
@@ -19,4 +19,10 @@ data class TransferReceiptData(
|
|||||||
val bmlReference: String = "",
|
val bmlReference: String = "",
|
||||||
val bmlTimestamp: String = "",
|
val bmlTimestamp: String = "",
|
||||||
val bmlMessage: String = "",
|
val bmlMessage: String = "",
|
||||||
|
// M-Faisa receipt fields
|
||||||
|
val mfaisaTransactionType: String = "",
|
||||||
|
val mfaisaFromName: String = "",
|
||||||
|
val mfaisaFromMsisdn: String = "",
|
||||||
|
val mfaisaToMsisdn: String = "",
|
||||||
|
val mfaisaTimestamp: Long = 0L,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,17 +11,13 @@ import android.graphics.BitmapFactory
|
|||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.graphics.Rect
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.PixelCopy
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
@@ -38,6 +34,7 @@ import sh.sar.basedbank.BasedBankApp
|
|||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.databinding.FragmentReceiptBmlBinding
|
import sh.sar.basedbank.databinding.FragmentReceiptBmlBinding
|
||||||
|
import sh.sar.basedbank.databinding.FragmentReceiptMfaisaBinding
|
||||||
import sh.sar.basedbank.databinding.FragmentReceiptMibBinding
|
import sh.sar.basedbank.databinding.FragmentReceiptMibBinding
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
@@ -68,6 +65,11 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
private const val ARG_BML_REFERENCE = "bml_reference"
|
private const val ARG_BML_REFERENCE = "bml_reference"
|
||||||
private const val ARG_BML_TIMESTAMP = "bml_timestamp"
|
private const val ARG_BML_TIMESTAMP = "bml_timestamp"
|
||||||
private const val ARG_BML_MESSAGE = "bml_message"
|
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
|
// Holds the already-rendered to-avatar bitmap from TransferFragment
|
||||||
var pendingToAvatarBitmap: Bitmap? = null
|
var pendingToAvatarBitmap: Bitmap? = null
|
||||||
@@ -91,22 +93,36 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
putString(ARG_BML_REFERENCE, data.bmlReference)
|
putString(ARG_BML_REFERENCE, data.bmlReference)
|
||||||
putString(ARG_BML_TIMESTAMP, data.bmlTimestamp)
|
putString(ARG_BML_TIMESTAMP, data.bmlTimestamp)
|
||||||
putString(ARG_BML_MESSAGE, data.bmlMessage)
|
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 {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
||||||
return if (bank == "MIB") {
|
return when (bank) {
|
||||||
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
|
"MIB" -> {
|
||||||
bindMib(binding)
|
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
|
||||||
_receiptCard = binding.receiptCard
|
bindMib(binding)
|
||||||
binding.root
|
_receiptCard = binding.receiptCard
|
||||||
} else {
|
binding.root
|
||||||
val binding = FragmentReceiptBmlBinding.inflate(inflater, container, false)
|
}
|
||||||
bindBml(binding)
|
"MFAISA" -> {
|
||||||
_receiptCard = binding.receiptCard
|
val binding = FragmentReceiptMfaisaBinding.inflate(inflater, container, false)
|
||||||
binding.root
|
bindMfaisa(binding)
|
||||||
|
_receiptCard = binding.receiptCard
|
||||||
|
binding.root
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val binding = FragmentReceiptBmlBinding.inflate(inflater, container, false)
|
||||||
|
bindBml(binding)
|
||||||
|
_receiptCard = binding.receiptCard
|
||||||
|
binding.root
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,9 +155,6 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
|
|
||||||
parentFragmentManager.popBackStack()
|
|
||||||
}
|
|
||||||
view.findViewById<MaterialButton>(R.id.btnShare).setOnClickListener {
|
view.findViewById<MaterialButton>(R.id.btnShare).setOnClickListener {
|
||||||
shareReceipt()
|
shareReceipt()
|
||||||
}
|
}
|
||||||
@@ -250,6 +263,49 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun bindMfaisa(binding: FragmentReceiptMfaisaBinding) {
|
||||||
|
val args = requireArguments()
|
||||||
|
val currency = args.getString(ARG_CURRENCY, "MVR")
|
||||||
|
val amountStr = args.getString(ARG_AMOUNT, "")
|
||||||
|
|
||||||
|
val formattedAmount = try {
|
||||||
|
val d = amountStr.toDouble()
|
||||||
|
val intFmt = NumberFormat.getNumberInstance(Locale.US).apply { maximumFractionDigits = 0 }
|
||||||
|
intFmt.format(d.toLong()) + "%.2f".format(d).takeLast(3)
|
||||||
|
} catch (_: Exception) { amountStr }
|
||||||
|
|
||||||
|
binding.tvAmount.text = "$currency $formattedAmount"
|
||||||
|
binding.tvTransactionType.text = args.getString(ARG_MFAISA_TXN_TYPE, "")
|
||||||
|
.ifBlank { "Transfer to mobile" }
|
||||||
|
binding.tvFromName.text = args.getString(ARG_MFAISA_FROM_NAME, "")
|
||||||
|
.ifBlank { args.getString(ARG_FROM_LABEL, "") }
|
||||||
|
binding.tvFromMsisdn.text = args.getString(ARG_MFAISA_FROM_MSISDN, "")
|
||||||
|
binding.tvToName.text = args.getString(ARG_TO_LABEL, "")
|
||||||
|
binding.tvToMsisdn.text = args.getString(ARG_MFAISA_TO_MSISDN, "")
|
||||||
|
.ifBlank { args.getString(ARG_TO_ACCOUNT, "") }
|
||||||
|
binding.tvDateTime.text = formatMfaisaTimestamp(args.getLong(ARG_MFAISA_TIMESTAMP, 0L))
|
||||||
|
|
||||||
|
val remarks = args.getString(ARG_REMARKS, "")
|
||||||
|
if (!remarks.isNullOrBlank()) {
|
||||||
|
binding.tvRemarks.text = remarks
|
||||||
|
binding.remarksDivider.visibility = View.VISIBLE
|
||||||
|
binding.remarksRow.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
copyOnLongClick(
|
||||||
|
binding.tvAmount, binding.tvStatus, binding.tvTransactionType,
|
||||||
|
binding.tvFromName, binding.tvFromMsisdn,
|
||||||
|
binding.tvToName, binding.tvToMsisdn,
|
||||||
|
binding.tvDateTime, binding.tvRemarks
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatMfaisaTimestamp(millis: Long): String {
|
||||||
|
val effective = if (millis > 0) millis else System.currentTimeMillis()
|
||||||
|
val sdf = java.text.SimpleDateFormat("EEEE d MMMM yyyy HH:mm:ss z", Locale.US)
|
||||||
|
return sdf.format(java.util.Date(effective))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Share / Save ──────────────────────────────────────────────────────────
|
// ── Share / Save ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun shareReceipt() {
|
private fun shareReceipt() {
|
||||||
@@ -314,21 +370,19 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Captures the receipt card using PixelCopy, which correctly handles
|
* Draws the receipt card to an offscreen bitmap at its natural (unscaled)
|
||||||
* hardware-accelerated views (avoids the black-square problem with view.draw()).
|
* dimensions, so the captured image isn't affected by the on-screen scale
|
||||||
|
* applied to fit small viewports and doesn't pick up overlapping siblings.
|
||||||
*/
|
*/
|
||||||
private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) {
|
private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) {
|
||||||
val view = _receiptCard ?: run { callback(null); return }
|
val view = _receiptCard ?: run { callback(null); return }
|
||||||
if (view.width == 0 || view.height == 0) { callback(null); return }
|
if (view.width == 0 || view.height == 0) { callback(null); return }
|
||||||
|
|
||||||
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
|
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
|
||||||
val location = IntArray(2)
|
val canvas = Canvas(bitmap)
|
||||||
view.getLocationInWindow(location)
|
canvas.drawColor(Color.WHITE)
|
||||||
val srcRect = Rect(location[0], location[1], location[0] + view.width, location[1] + view.height)
|
view.draw(canvas)
|
||||||
|
callback(bitmap)
|
||||||
PixelCopy.request(requireActivity().window, srcRect, bitmap, { result ->
|
|
||||||
callback(if (result == PixelCopy.SUCCESS) bitmap else null)
|
|
||||||
}, Handler(Looper.getMainLooper()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatBmlTimestamp(raw: String): String {
|
private fun formatBmlTimestamp(raw: String): String {
|
||||||
@@ -366,14 +420,22 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
setBackgroundColor(Color.BLACK)
|
setBackgroundColor(Color.BLACK)
|
||||||
}
|
}
|
||||||
|
|
||||||
val cardView = if (bank == "MIB") {
|
val cardView = when (bank) {
|
||||||
val binding = FragmentReceiptMibBinding.inflate(layoutInflater)
|
"MIB" -> {
|
||||||
bindMib(binding)
|
val binding = FragmentReceiptMibBinding.inflate(layoutInflater)
|
||||||
binding.receiptCard
|
bindMib(binding)
|
||||||
} else {
|
binding.receiptCard
|
||||||
val binding = FragmentReceiptBmlBinding.inflate(layoutInflater)
|
}
|
||||||
bindBml(binding)
|
"MFAISA" -> {
|
||||||
binding.receiptCard
|
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.parent as? ViewGroup)?.removeView(cardView)
|
||||||
cardView.setOnClickListener { dialog.dismiss() }
|
cardView.setOnClickListener { dialog.dismiss() }
|
||||||
@@ -423,13 +485,9 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
(activity as? HomeActivity)?.setBottomNavVisible(false)
|
(activity as? HomeActivity)?.setBottomNavVisible(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
super.onPause()
|
|
||||||
(activity as? HomeActivity)?.setBottomNavVisible(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
|
(activity as? HomeActivity)?.setBottomNavVisible(true)
|
||||||
_receiptCard = null
|
_receiptCard = null
|
||||||
pendingToAvatarBitmap = null
|
pendingToAvatarBitmap = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,739 @@
|
|||||||
|
package sh.sar.basedbank.ui.home.transfer
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||||
|
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
||||||
|
import sh.sar.basedbank.api.bml.BmlQrPayClient
|
||||||
|
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
||||||
|
import sh.sar.basedbank.api.bml.BmlQrPayLookupException
|
||||||
|
import sh.sar.basedbank.api.bml.BmlQrPayResult
|
||||||
|
import sh.sar.basedbank.api.bml.BmlSession
|
||||||
|
import sh.sar.basedbank.api.bml.BmlTransferClient
|
||||||
|
import sh.sar.basedbank.api.bml.BmlValidateClient
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
|
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
||||||
|
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||||
|
import sh.sar.basedbank.ui.home.HomeActivity
|
||||||
|
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||||
|
import sh.sar.basedbank.ui.home.TransferFragment
|
||||||
|
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
|
import sh.sar.basedbank.util.RecentPick
|
||||||
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
|
import sh.sar.basedbank.util.Totp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the BML-only parts of the Transfer screen: session lookup, the CASA/card transfer itself,
|
||||||
|
* the business-profile OTP state machine (channel pick → initiate → verify), and merchant QR
|
||||||
|
* payment (card/gateway QR lookup + pre-initiate/initiate/confirm).
|
||||||
|
*
|
||||||
|
* Lives alongside [TransferFragment], which dispatches to it whenever the selected source's
|
||||||
|
* [BankAccount.bank] is "BML", and mirrors [MfaisaTransferHandler]'s shape: the fragment keeps
|
||||||
|
* the shared confirm dialog, the "To" lookup and the form state; the handler keeps everything
|
||||||
|
* BML-specific.
|
||||||
|
*
|
||||||
|
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
|
||||||
|
* [androidx.fragment.app.Fragment.viewLifecycleOwner] and Context) — and must be re-created when
|
||||||
|
* the view is recreated.
|
||||||
|
*/
|
||||||
|
class BmlTransferHandler(
|
||||||
|
private val fragment: TransferFragment,
|
||||||
|
private val binding: FragmentTransferBinding,
|
||||||
|
private val viewModel: HomeViewModel,
|
||||||
|
/** Reads the fragment's currently-selected source account. */
|
||||||
|
private val currentSource: () -> BankAccount?,
|
||||||
|
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
||||||
|
private val selectSource: (BankAccount) -> Unit,
|
||||||
|
/** Hook called whenever handler state changes in a way that affects the Send button. */
|
||||||
|
private val onStateChanged: () -> Unit,
|
||||||
|
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes balances. */
|
||||||
|
private val onTransferSuccess: (TransferReceiptData, Bitmap?) -> Unit,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||||
|
private val ctx get() = fragment.requireContext()
|
||||||
|
private val host get() = fragment.activity as? HomeActivity
|
||||||
|
|
||||||
|
// ─── State ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Business-profile OTP flow. NONE means the Send button behaves normally. */
|
||||||
|
private enum class OtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
|
||||||
|
private var otpState = OtpState.NONE
|
||||||
|
private var otpChannel: String? = null
|
||||||
|
|
||||||
|
private data class PendingTransfer(
|
||||||
|
val src: BankAccount,
|
||||||
|
val debitAccount: String,
|
||||||
|
val creditAccount: String,
|
||||||
|
val amount: Double,
|
||||||
|
val amountStr: String,
|
||||||
|
val remarks: String,
|
||||||
|
val transferType: String,
|
||||||
|
val currency: String,
|
||||||
|
val bank: String?,
|
||||||
|
val destDisplay: String,
|
||||||
|
val destAccount: String,
|
||||||
|
val toBank: String,
|
||||||
|
val toAvatar: Bitmap?
|
||||||
|
)
|
||||||
|
private var pendingTransfer: PendingTransfer? = null
|
||||||
|
|
||||||
|
/** Merchant QR payment mode (set when navigated from a card/gateway QR scan). */
|
||||||
|
var qrInfo: BmlQrPayInfo? = null
|
||||||
|
private set
|
||||||
|
/** True for pay.bml.com.mv QRs, which need an extra pre-initiate step. */
|
||||||
|
private var gatewayQr = false
|
||||||
|
/** Prevents re-running the lookup after the user clears the merchant. */
|
||||||
|
var qrLookupAttempted = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||||
|
|
||||||
|
/** Whether the business OTP flow is mid-way — the fragment freezes the Send button while it is. */
|
||||||
|
val isOtpFlowActive: Boolean get() = otpState != OtpState.NONE
|
||||||
|
|
||||||
|
/** Whether the Send button should verify an OTP instead of starting a new transfer. */
|
||||||
|
val isAwaitingOtp: Boolean get() = otpState == OtpState.AWAITING_OTP
|
||||||
|
|
||||||
|
/** Whether a merchant QR is loaded — counts as a resolved recipient for the Send button. */
|
||||||
|
val hasQrMerchant: Boolean get() = qrInfo != null
|
||||||
|
|
||||||
|
fun sessionFor(account: BankAccount?): BmlSession? =
|
||||||
|
account?.let { app.bmlSessionFor(it) } ?: app.anyBmlSession()
|
||||||
|
|
||||||
|
fun isBusinessProfile(account: BankAccount): Boolean {
|
||||||
|
val loginId = account.loginTag.removePrefix("bml_")
|
||||||
|
val profiles = app.bmlProfilesMap[loginId] ?: return false
|
||||||
|
return profiles.firstOrNull { it.profileId == account.profileId }?.profileType == "business"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a destination through BML — `verifyMibAccount` when [verifyAsMib], otherwise the
|
||||||
|
* regular alias/account validation. Blocking; call from IO. Returns null when BML can't
|
||||||
|
* resolve it, so the caller can fall back to the MIB IPS lookup.
|
||||||
|
*
|
||||||
|
* The result is mapped onto [MibIpsAccountInfo] because that is the Transfer screen's common
|
||||||
|
* "resolved recipient" shape, whichever bank did the resolving. Note that the MIB-verify
|
||||||
|
* endpoint leaves `currency` blank — the caller enriches it via
|
||||||
|
* [MibTransferHandler.lookupCurrency] when a MIB session is available.
|
||||||
|
*/
|
||||||
|
fun validateDestination(
|
||||||
|
session: BmlSession,
|
||||||
|
accountNumber: String,
|
||||||
|
verifyAsMib: Boolean
|
||||||
|
): MibIpsAccountInfo? {
|
||||||
|
val result = try {
|
||||||
|
if (verifyAsMib) BmlValidateClient().verifyMibAccount(session, accountNumber)
|
||||||
|
else BmlValidateClient().validateAccount(session, accountNumber)
|
||||||
|
} catch (_: Exception) { null } ?: return null
|
||||||
|
val bankId = when (result.trnType) {
|
||||||
|
"IAT" -> "MALBMVMV"
|
||||||
|
else -> result.agnt ?: result.account
|
||||||
|
}
|
||||||
|
return MibIpsAccountInfo(
|
||||||
|
accountName = result.name,
|
||||||
|
accountNumber = result.account,
|
||||||
|
bankId = bankId,
|
||||||
|
currency = result.currency
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
|
||||||
|
fun clearQrMerchant() {
|
||||||
|
if (qrInfo == null) return
|
||||||
|
qrInfo = null
|
||||||
|
gatewayQr = false
|
||||||
|
binding.tilAmount.isEnabled = true
|
||||||
|
binding.tilRemarks.isEnabled = true
|
||||||
|
binding.tilRemarks.alpha = 1f
|
||||||
|
binding.etAmount.setText("")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called when the view tears down — an in-flight OTP flow cannot sensibly resume. */
|
||||||
|
fun clearState() {
|
||||||
|
otpState = OtpState.NONE
|
||||||
|
otpChannel = null
|
||||||
|
pendingTransfer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Merchant QR ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun lookupQrMerchant(qrUrl: String) {
|
||||||
|
qrLookupAttempted = true
|
||||||
|
// Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount and
|
||||||
|
// need the extra pre-initiate POST; ebanking qrpay URLs do not.
|
||||||
|
gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
|
||||||
|
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
|
||||||
|
val session = app.anyBmlSession() ?: return
|
||||||
|
|
||||||
|
// Lock the "To" input row while loading
|
||||||
|
binding.tilTo.visibility = View.GONE
|
||||||
|
binding.btnPickContact.visibility = View.GONE
|
||||||
|
binding.btnScanQr.visibility = View.GONE
|
||||||
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
|
||||||
|
}
|
||||||
|
host?.setRefreshing(false)
|
||||||
|
val info = result.getOrNull()
|
||||||
|
if (info == null) {
|
||||||
|
// An expired or rejected QR is BML telling us something specific — show its own
|
||||||
|
// wording and stay put with the To row restored, rather than bouncing the user out
|
||||||
|
// of the screen they just scanned from.
|
||||||
|
val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message
|
||||||
|
?: ctx.getString(R.string.bml_qr_lookup_failed)
|
||||||
|
Toast.makeText(ctx, message, Toast.LENGTH_LONG).show()
|
||||||
|
fragment.resetToFieldVisibility()
|
||||||
|
onStateChanged()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
qrInfo = info
|
||||||
|
if (info.amount == 0.0) {
|
||||||
|
RecentsCache.save(ctx, RecentPick(
|
||||||
|
accountNumber = "bmlqr:$qrUrl",
|
||||||
|
displayName = info.merchantName,
|
||||||
|
subtitle = info.merchantAddress.ifBlank { "BML Merchant" },
|
||||||
|
colorHex = "#0066A1",
|
||||||
|
imageHash = null,
|
||||||
|
isProfileImage = false
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-select the user's default BML card if no card was pre-selected
|
||||||
|
if (currentSource() == null) {
|
||||||
|
val defaultNum = CredentialStore(ctx).getDefaultCardAccountNumber()
|
||||||
|
if (defaultNum != null) {
|
||||||
|
val allAccounts = viewModel.accounts.value ?: emptyList()
|
||||||
|
val defaultCard = allAccounts.firstOrNull {
|
||||||
|
it.accountNumber == defaultNum && isCard(it) &&
|
||||||
|
it.statusDesc.equals("Active", ignoreCase = true)
|
||||||
|
}
|
||||||
|
if (defaultCard != null) selectSource(defaultCard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show merchant in the "To" card — clear button hidden (can't change recipient for QR)
|
||||||
|
binding.tvToAccountName.text = info.merchantName
|
||||||
|
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
|
||||||
|
binding.tvToAccountDetails.visibility = View.GONE
|
||||||
|
binding.tvToBalance.visibility = View.GONE
|
||||||
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
|
binding.ivToPhoto.setImageBitmap(fragment.makeInitialsBitmap(info.merchantName, "#0066A1"))
|
||||||
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// Pre-fill amount if dynamic QR
|
||||||
|
if (info.amount > 0.0) {
|
||||||
|
binding.etAmount.setText("%.2f".format(info.amount))
|
||||||
|
binding.tilAmount.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remarks not applicable for merchant QR payments
|
||||||
|
binding.tilRemarks.isEnabled = false
|
||||||
|
binding.tilRemarks.alpha = 0.4f
|
||||||
|
|
||||||
|
onStateChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog and
|
||||||
|
* reports the outcome inside it — there is no receipt screen for merchant payments.
|
||||||
|
*/
|
||||||
|
fun submitQrPayment() {
|
||||||
|
val info = qrInfo ?: return
|
||||||
|
val src = currentSource() ?: run {
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
|
||||||
|
val amount = amountStr.toDoubleOrNull()
|
||||||
|
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
|
||||||
|
binding.tilAmount.error = null
|
||||||
|
val debitAccount = src.internalId.ifBlank {
|
||||||
|
Toast.makeText(ctx, R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val fromTypeLabel = sh.sar.basedbank.util.AccountListParser.from(src)?.typeLabel
|
||||||
|
?: sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(src.accountTypeName)
|
||||||
|
val fromDetail = listOfNotNull("BML", fromTypeLabel.ifBlank { null }).joinToString(" · ")
|
||||||
|
val confirmView = fragment.buildTransferConfirmView(
|
||||||
|
amountCurrency = info.currency,
|
||||||
|
amountValue = "%.2f".format(amount),
|
||||||
|
fromName = src.accountBriefName,
|
||||||
|
fromNumber = src.accountNumber,
|
||||||
|
fromDetail = fromDetail,
|
||||||
|
toName = info.merchantName,
|
||||||
|
toNumber = "",
|
||||||
|
toDetail = info.merchantAddress.ifBlank { "BML Merchant" }
|
||||||
|
)
|
||||||
|
fragment.showConfirmWithBiometric(
|
||||||
|
title = ctx.getString(R.string.transfer),
|
||||||
|
customView = confirmView,
|
||||||
|
biometricSubtitle = "${info.currency} ${"%.2f".format(amount)} → ${info.merchantName}",
|
||||||
|
onConfirmed = { dialog, frame ->
|
||||||
|
fragment.showProcessingInDialog(dialog, frame)
|
||||||
|
executeQrPayment(src, debitAccount, info, amount, dialog, frame)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executeQrPayment(
|
||||||
|
src: BankAccount,
|
||||||
|
debitAccount: String,
|
||||||
|
info: BmlQrPayInfo,
|
||||||
|
amount: Double,
|
||||||
|
dialog: AlertDialog,
|
||||||
|
frame: android.widget.FrameLayout
|
||||||
|
) {
|
||||||
|
val loginId = src.loginTag.removePrefix("bml_")
|
||||||
|
val session = sessionFor(src) ?: run {
|
||||||
|
dialog.dismiss()
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val otp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||||
|
?.let { Totp.generate(it) }
|
||||||
|
?: run { dialog.dismiss(); Toast.makeText(ctx, "OTP unavailable", Toast.LENGTH_SHORT).show(); return }
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
if (gatewayQr) {
|
||||||
|
val preOk = BmlQrPayClient().preInitiatePayment(
|
||||||
|
session, debitAccount, info.requestId, amount, info.currency)
|
||||||
|
if (!preOk) return@withContext null
|
||||||
|
}
|
||||||
|
val initiated = BmlQrPayClient().initiatePayment(
|
||||||
|
session, debitAccount, info.requestId, amount, info.currency)
|
||||||
|
if (!initiated) return@withContext null
|
||||||
|
val confirmOtp = CredentialStore(ctx).loadBmlCredentials(loginId)
|
||||||
|
?.otpSeed?.let { Totp.generate(it) } ?: otp
|
||||||
|
BmlQrPayClient().confirmPayment(
|
||||||
|
session, debitAccount, info.requestId, amount, info.currency, confirmOtp)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fragment.view == null) return@launch
|
||||||
|
|
||||||
|
if (result == null) {
|
||||||
|
dialog.dismiss()
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
Toast.makeText(ctx, "Failed to initiate payment", Toast.LENGTH_LONG).show()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
if (result.success) {
|
||||||
|
fragment.showSuccessInDialog(
|
||||||
|
dialog, frame,
|
||||||
|
amountCurrency = result.currency.ifBlank { info.currency },
|
||||||
|
amountValue = result.amount.ifBlank { "%.2f".format(amount) },
|
||||||
|
fromName = src.accountBriefName,
|
||||||
|
toName = result.merchant.ifBlank { info.merchantName }
|
||||||
|
) {
|
||||||
|
fragment.clearForm()
|
||||||
|
host?.triggerRefresh()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dialog.dismiss()
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
Toast.makeText(ctx, result.errorMessage, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Personal-profile transfer (token OTP, no user interaction) ──────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs initiate + confirm on the calling (IO) thread and returns
|
||||||
|
* `(ok, message, receipt)`. Message is "CONNECTIVITY" when the network was unreachable.
|
||||||
|
*/
|
||||||
|
fun doTransfer(
|
||||||
|
src: BankAccount,
|
||||||
|
destAccount: String,
|
||||||
|
destDisplay: String,
|
||||||
|
amount: Double,
|
||||||
|
amountStr: String,
|
||||||
|
remarks: String,
|
||||||
|
isSrcCard: Boolean,
|
||||||
|
isDestMib: Boolean,
|
||||||
|
currency: String,
|
||||||
|
allAccounts: List<BankAccount>,
|
||||||
|
allContacts: List<BankContact>
|
||||||
|
): Triple<Boolean, String, TransferReceiptData?> {
|
||||||
|
val loginId = src.loginTag.removePrefix("bml_")
|
||||||
|
val sess = sessionFor(src) ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
|
||||||
|
val otp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||||
|
?.let { Totp.generate(it) }
|
||||||
|
?: return Triple(false, "OTP unavailable", null)
|
||||||
|
val debitAccount = src.internalId.ifBlank {
|
||||||
|
return Triple(false, ctx.getString(R.string.transfer_missing_internal_id), null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val routed = routeTransfer(destAccount, isSrcCard, isDestMib, currency, allAccounts, allContacts)
|
||||||
|
?: return Triple(false, "BML contact not found for this account", null)
|
||||||
|
val (transferType, creditAccount, bank) = routed
|
||||||
|
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
|
||||||
|
|
||||||
|
// Step 1: initiate
|
||||||
|
val initiated = try {
|
||||||
|
BmlTransferClient().initiateTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, bank)
|
||||||
|
} catch (e: Exception) { return Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Initiation failed"), null) }
|
||||||
|
|
||||||
|
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
|
||||||
|
|
||||||
|
// Step 2: confirm with fresh OTP
|
||||||
|
val confirmOtp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||||
|
?.let { Totp.generate(it) } ?: otp
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val result = BmlTransferClient().confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
|
||||||
|
if (result.success) {
|
||||||
|
val receipt = TransferReceiptData(
|
||||||
|
bank = "BML",
|
||||||
|
amount = "%.2f".format(amount),
|
||||||
|
currency = currency,
|
||||||
|
fromLabel = src.accountBriefName,
|
||||||
|
fromColorHex = "#0066A1",
|
||||||
|
toLabel = destDisplay.ifBlank { destAccount },
|
||||||
|
toAccount = destAccount,
|
||||||
|
toBank = toBank,
|
||||||
|
remarks = remarks,
|
||||||
|
bmlFromName = src.accountBriefName,
|
||||||
|
bmlReference = result.reference,
|
||||||
|
bmlTimestamp = result.timestamp,
|
||||||
|
bmlMessage = result.message
|
||||||
|
)
|
||||||
|
val time = result.timestamp.take(19).replace("T", " ")
|
||||||
|
Triple(true, "Reference: ${result.reference}\n$time", receipt)
|
||||||
|
} else {
|
||||||
|
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Business profile OTP flow ───────────────────────────────────────────
|
||||||
|
|
||||||
|
fun startBusinessOtpFlow(
|
||||||
|
src: BankAccount,
|
||||||
|
destAccount: String,
|
||||||
|
destDisplay: String,
|
||||||
|
amount: Double,
|
||||||
|
amountStr: String,
|
||||||
|
remarks: String,
|
||||||
|
isSrcCard: Boolean,
|
||||||
|
isDestMib: Boolean,
|
||||||
|
currency: String,
|
||||||
|
allAccounts: List<BankAccount>,
|
||||||
|
allContacts: List<BankContact>,
|
||||||
|
toAvatar: Bitmap?
|
||||||
|
) {
|
||||||
|
val debitAccount = src.internalId.ifBlank {
|
||||||
|
Toast.makeText(ctx, ctx.getString(R.string.transfer_missing_internal_id), Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val routed = routeTransfer(destAccount, isSrcCard, isDestMib, currency, allAccounts, allContacts)
|
||||||
|
if (routed == null) {
|
||||||
|
Toast.makeText(ctx, "BML contact not found for this account", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val (transferType, creditAccount, bank) = routed
|
||||||
|
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
|
||||||
|
|
||||||
|
pendingTransfer = PendingTransfer(
|
||||||
|
src = src,
|
||||||
|
debitAccount = debitAccount,
|
||||||
|
creditAccount = creditAccount,
|
||||||
|
amount = amount,
|
||||||
|
amountStr = amountStr,
|
||||||
|
remarks = remarks,
|
||||||
|
transferType = transferType,
|
||||||
|
currency = currency,
|
||||||
|
bank = bank,
|
||||||
|
destDisplay = destDisplay,
|
||||||
|
destAccount = destAccount,
|
||||||
|
toBank = toBank,
|
||||||
|
toAvatar = toAvatar
|
||||||
|
)
|
||||||
|
|
||||||
|
otpState = OtpState.SELECTING_CHANNEL
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val sess = sessionFor(src)
|
||||||
|
val channels = if (sess != null) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try { BmlAccountClient().fetchTransferChannels(sess) }
|
||||||
|
catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
} else emptyList<BmlOtpChannel>()
|
||||||
|
|
||||||
|
host?.setRefreshing(false)
|
||||||
|
|
||||||
|
if (channels.isEmpty()) {
|
||||||
|
Toast.makeText(ctx, "Could not load OTP channels", Toast.LENGTH_SHORT).show()
|
||||||
|
resetOtpState()
|
||||||
|
onStateChanged()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
showChannelSelection(channels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showChannelSelection(channels: List<BmlOtpChannel>) {
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
binding.containerBmlChannels.removeAllViews()
|
||||||
|
|
||||||
|
for (channel in channels) {
|
||||||
|
val iconRes = when (channel.channel) {
|
||||||
|
"email" -> R.drawable.ic_channel_email
|
||||||
|
"mobile" -> R.drawable.ic_channel_sms
|
||||||
|
else -> R.drawable.ic_channel_sms
|
||||||
|
}
|
||||||
|
val iconSize = (24 * dp).toInt()
|
||||||
|
|
||||||
|
val textCol = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
|
||||||
|
marginStart = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = channel.description
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
||||||
|
})
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = channel.masked
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||||
|
alpha = 0.6f
|
||||||
|
})
|
||||||
|
|
||||||
|
val row = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||||
|
background = ta.getDrawable(0); ta.recycle()
|
||||||
|
isClickable = true; isFocusable = true
|
||||||
|
val hp = (16 * dp).toInt(); val vp = (12 * dp).toInt()
|
||||||
|
setPadding(hp, vp, hp, vp)
|
||||||
|
}
|
||||||
|
row.addView(ImageView(ctx).apply { setImageResource(iconRes) },
|
||||||
|
LinearLayout.LayoutParams(iconSize, iconSize))
|
||||||
|
row.addView(textCol)
|
||||||
|
row.setOnClickListener { selectOtpChannel(channel) }
|
||||||
|
binding.containerBmlChannels.addView(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
disableTransferFields()
|
||||||
|
binding.layoutBmlChannelSelection.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectOtpChannel(channel: BmlOtpChannel) {
|
||||||
|
otpChannel = channel.channel
|
||||||
|
binding.layoutBmlChannelSelection.visibility = View.GONE
|
||||||
|
|
||||||
|
val pending = pendingTransfer ?: return
|
||||||
|
val sess = sessionFor(pending.src) ?: run {
|
||||||
|
Toast.makeText(ctx, ctx.getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
|
||||||
|
resetOtpState()
|
||||||
|
onStateChanged()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val initiated = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
BmlTransferClient().initiateTransfer(
|
||||||
|
sess, pending.debitAccount, pending.creditAccount,
|
||||||
|
pending.amount, pending.transferType, pending.currency,
|
||||||
|
pending.bank, channel.channel
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
}
|
||||||
|
host?.setRefreshing(false)
|
||||||
|
|
||||||
|
if (!initiated) {
|
||||||
|
Toast.makeText(ctx, "Failed to initiate transfer — check your session", Toast.LENGTH_SHORT).show()
|
||||||
|
resetOtpState()
|
||||||
|
onStateChanged()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
otpState = OtpState.AWAITING_OTP
|
||||||
|
binding.tvBmlOtpSentVia.text = "OTP code sent via: ${channel.description} (${channel.masked})"
|
||||||
|
binding.tvBmlOtpSentVia.visibility = View.VISIBLE
|
||||||
|
binding.tilBmlOtp.visibility = View.VISIBLE
|
||||||
|
binding.etBmlOtp.requestFocus()
|
||||||
|
binding.btnTransfer.text = ctx.getString(R.string.transfer_verify_payment)
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send-button action while [isAwaitingOtp]: confirms the pending transfer with the typed code. */
|
||||||
|
fun verifyOtp() {
|
||||||
|
val otp = binding.etBmlOtp.text?.toString()?.trim() ?: ""
|
||||||
|
if (otp.isEmpty()) {
|
||||||
|
binding.tilBmlOtp.error = "Enter the verification code"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.tilBmlOtp.error = null
|
||||||
|
val pending = pendingTransfer ?: return
|
||||||
|
val channel = otpChannel ?: return
|
||||||
|
val sess = sessionFor(pending.src) ?: run {
|
||||||
|
Toast.makeText(ctx, ctx.getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
|
val capturedToAvatar = pending.toAvatar
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val result = BmlTransferClient().confirmTransfer(
|
||||||
|
sess, pending.debitAccount, pending.creditAccount,
|
||||||
|
pending.amount, pending.transferType, pending.currency,
|
||||||
|
otp, pending.remarks, pending.bank, channel
|
||||||
|
)
|
||||||
|
if (result.success) {
|
||||||
|
val r = TransferReceiptData(
|
||||||
|
bank = "BML",
|
||||||
|
amount = "%.2f".format(pending.amount),
|
||||||
|
currency = pending.currency,
|
||||||
|
fromLabel = pending.src.accountBriefName,
|
||||||
|
fromColorHex = "#0066A1",
|
||||||
|
toLabel = pending.destDisplay.ifBlank { pending.destAccount },
|
||||||
|
toAccount = pending.destAccount,
|
||||||
|
toBank = pending.toBank,
|
||||||
|
remarks = pending.remarks,
|
||||||
|
bmlFromName = pending.src.accountBriefName,
|
||||||
|
bmlReference = result.reference,
|
||||||
|
bmlTimestamp = result.timestamp,
|
||||||
|
bmlMessage = result.message
|
||||||
|
)
|
||||||
|
Triple(true, "", r)
|
||||||
|
} else {
|
||||||
|
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null as TransferReceiptData?)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null as TransferReceiptData?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
host?.setRefreshing(false)
|
||||||
|
|
||||||
|
if (ok && receipt != null) {
|
||||||
|
resetOtpState()
|
||||||
|
onTransferSuccess(receipt, capturedToAvatar)
|
||||||
|
} else {
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
if (msg == "CONNECTIVITY") {
|
||||||
|
host?.showConnectivityBanner(ctx.getString(R.string.connectivity_no_internet))
|
||||||
|
} else {
|
||||||
|
binding.tilBmlOtp.error = msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tears down the OTP UI and re-enables the form. Safe to call when no flow is running. */
|
||||||
|
fun resetOtpState() {
|
||||||
|
otpState = OtpState.NONE
|
||||||
|
otpChannel = null
|
||||||
|
pendingTransfer = null
|
||||||
|
if (fragment.view == null) return
|
||||||
|
binding.layoutBmlChannelSelection.visibility = View.GONE
|
||||||
|
binding.tvBmlOtpSentVia.visibility = View.GONE
|
||||||
|
binding.tilBmlOtp.visibility = View.GONE
|
||||||
|
binding.etBmlOtp.setText("")
|
||||||
|
binding.tilBmlOtp.error = null
|
||||||
|
enableTransferFields()
|
||||||
|
binding.btnTransfer.text = ctx.getString(R.string.transfer)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disableTransferFields() {
|
||||||
|
binding.tilAmount.isEnabled = false
|
||||||
|
binding.tilRemarks.isEnabled = false
|
||||||
|
binding.cardFromInfo.alpha = 0.5f
|
||||||
|
binding.btnClearFromInfo.isEnabled = false
|
||||||
|
binding.cardToInfo.alpha = 0.5f
|
||||||
|
binding.btnClearToInfo.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableTransferFields() {
|
||||||
|
binding.tilAmount.isEnabled = true
|
||||||
|
binding.tilRemarks.isEnabled = true
|
||||||
|
binding.cardFromInfo.alpha = 1f
|
||||||
|
binding.btnClearFromInfo.isEnabled = true
|
||||||
|
binding.cardToInfo.alpha = 1f
|
||||||
|
binding.btnClearToInfo.isEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shared routing ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the BML transfer type and the credit-account identifier the API expects.
|
||||||
|
* Returns null only for the USD → MIB case with no matching saved BML contact, which both
|
||||||
|
* callers report as "BML contact not found".
|
||||||
|
*/
|
||||||
|
private fun routeTransfer(
|
||||||
|
destAccount: String,
|
||||||
|
isSrcCard: Boolean,
|
||||||
|
isDestMib: Boolean,
|
||||||
|
currency: String,
|
||||||
|
allAccounts: List<BankAccount>,
|
||||||
|
allContacts: List<BankContact>
|
||||||
|
): Triple<String, String, String?>? {
|
||||||
|
val isDestMyCard = allAccounts.any { isCard(it) && it.accountNumber == destAccount }
|
||||||
|
return when {
|
||||||
|
isSrcCard -> {
|
||||||
|
// CAD: card → own BML account
|
||||||
|
val destBml = allAccounts.firstOrNull { it.accountNumber == destAccount && it.profileType == "BML" }
|
||||||
|
Triple("CAD", destBml?.internalId?.ifBlank { destAccount } ?: destAccount, null)
|
||||||
|
}
|
||||||
|
isDestMyCard -> {
|
||||||
|
// CPA: BML CASA → own card top-up
|
||||||
|
val card = allAccounts.first { isCard(it) && it.accountNumber == destAccount }
|
||||||
|
Triple("CPA", card.internalId.ifBlank { destAccount }, null)
|
||||||
|
}
|
||||||
|
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
|
||||||
|
isDestMib -> {
|
||||||
|
// USD DOT: requires BML contact numeric ID
|
||||||
|
val contact = allContacts.firstOrNull { it.benefCategoryId == "BML" && it.benefAccount == destAccount }
|
||||||
|
?: return null
|
||||||
|
Triple("DOT", contact.benefNo.removePrefix("bml_"), null)
|
||||||
|
}
|
||||||
|
else -> Triple("IAT", destAccount, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isCard(account: BankAccount) =
|
||||||
|
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package sh.sar.basedbank.ui.home.transfer
|
||||||
|
|
||||||
|
import android.view.View
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
|
||||||
|
import sh.sar.basedbank.api.fahipay.OoredooClient
|
||||||
|
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||||
|
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||||
|
import sh.sar.basedbank.ui.home.TransferFragment
|
||||||
|
import sh.sar.basedbank.util.AccountInputParser
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A service a Fahipay wallet can pay out to. The carrier lookup decides which of these apply to
|
||||||
|
* a given number; [label] names it in the recipient card, [destinationLabel] in the confirm
|
||||||
|
* dialog's "To" block.
|
||||||
|
*
|
||||||
|
* Wallet-to-wallet Fahipay transfer is not here yet — there is no send path for it (see the
|
||||||
|
* class KDoc on [FahipayTransferHandler]). Add it as a constant once that lands, and the
|
||||||
|
* exhaustive `when`s over this enum will point at every site that needs updating.
|
||||||
|
*/
|
||||||
|
enum class FahipayService(val label: String, val destinationLabel: String) {
|
||||||
|
RAASTAS("Raastas", "Ooredoo · Raastas"),
|
||||||
|
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay"),
|
||||||
|
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload"),
|
||||||
|
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay"),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the Fahipay-only parts of the Transfer screen: the carrier lookup that turns a phone
|
||||||
|
* number into a set of payable services, the chip picker shown when more than one applies, and
|
||||||
|
* the selected service that the confirm dialog labels the destination with.
|
||||||
|
*
|
||||||
|
* Mirrors [BmlTransferHandler] / [MfaisaTransferHandler]: the fragment keeps the shared confirm
|
||||||
|
* dialog, the recipient card and the form state; the handler keeps everything Fahipay-specific.
|
||||||
|
*
|
||||||
|
* **There is no send path yet.** A Fahipay source currently falls through to the MIB branch of
|
||||||
|
* `initiateTransfer`, which signs the request with a MIB session. When the real payout API is
|
||||||
|
* wired up it belongs here, as a `doTransfer(...)` alongside the lookup — same shape as the
|
||||||
|
* other handlers.
|
||||||
|
*
|
||||||
|
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment]
|
||||||
|
* (for `viewLifecycleOwner` and Context) — and must be re-created when the view is recreated.
|
||||||
|
*/
|
||||||
|
class FahipayTransferHandler(
|
||||||
|
private val fragment: TransferFragment,
|
||||||
|
private val binding: FragmentTransferBinding,
|
||||||
|
private val viewModel: HomeViewModel,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val ctx get() = fragment.requireContext()
|
||||||
|
|
||||||
|
/** The service picked for the current recipient; null until a lookup resolves one. */
|
||||||
|
var service: FahipayService? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** How the confirm dialog names the destination, or "" when nothing is selected. */
|
||||||
|
val destinationLabel: String get() = service?.destinationLabel.orEmpty()
|
||||||
|
|
||||||
|
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a destination for a Fahipay source. Only phone numbers are payable, so anything
|
||||||
|
* else is rejected inline on the "To" field.
|
||||||
|
*/
|
||||||
|
fun lookupRecipient(rawInput: String) {
|
||||||
|
if (AccountInputParser.detect(rawInput) != AccountInputParser.InputType.PHONE) {
|
||||||
|
binding.tilTo.error = ctx.getString(R.string.transfer_fahipay_phone_only)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lookupCarrier(rawInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the selected service and hides the chip picker. */
|
||||||
|
fun clearState() {
|
||||||
|
service = null
|
||||||
|
binding.layoutServiceSelector.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Carrier lookup ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private data class CarrierResult(
|
||||||
|
val dhiraagu: DhiraaguClient.Result,
|
||||||
|
val ooredoo: OoredooClient.CustType
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun lookupCarrier(number: String) {
|
||||||
|
fragment.startLookupLoading()
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) { queryCarriers(number) }
|
||||||
|
fragment.stopLookupLoading()
|
||||||
|
|
||||||
|
val dhiraaguName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() }
|
||||||
|
val services = servicesFor(result)
|
||||||
|
|
||||||
|
if (services.isEmpty()) return@launch
|
||||||
|
|
||||||
|
// Only one option — auto-select, no chip UI needed
|
||||||
|
if (services.size == 1) {
|
||||||
|
selectService(services[0], number, dhiraaguName)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple options (Ooredoo HYBRID) — show chips
|
||||||
|
showServiceChips(services, number, dhiraaguName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the likelier carrier first based on the leading digit and only falls back to the
|
||||||
|
* other when the first says it doesn't know the number. Blocking — call from IO.
|
||||||
|
*/
|
||||||
|
private fun queryCarriers(number: String): CarrierResult =
|
||||||
|
if (number.startsWith("7")) {
|
||||||
|
// Dhiraagu first, fall back to Ooredoo
|
||||||
|
val d = dhiraagu(number)
|
||||||
|
val o = if (d.type == DhiraaguClient.CustType.UNSUPPORTED) ooredoo(number)
|
||||||
|
else OoredooClient.CustType.UNSUPPORTED
|
||||||
|
CarrierResult(d, o)
|
||||||
|
} else {
|
||||||
|
// Ooredoo first, fall back to Dhiraagu
|
||||||
|
val o = ooredoo(number)
|
||||||
|
val d = if (o == OoredooClient.CustType.UNSUPPORTED) dhiraagu(number)
|
||||||
|
else DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED)
|
||||||
|
CarrierResult(d, o)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dhiraagu(number: String) =
|
||||||
|
try { DhiraaguClient().validateNumber(number) }
|
||||||
|
catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) }
|
||||||
|
|
||||||
|
private fun ooredoo(number: String) =
|
||||||
|
try { OoredooClient().validateNumber(number) }
|
||||||
|
catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED }
|
||||||
|
|
||||||
|
private fun servicesFor(result: CarrierResult): List<FahipayService> = buildList {
|
||||||
|
if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add(FahipayService.DHIRAAGU_RELOAD)
|
||||||
|
if (result.dhiraagu.type == DhiraaguClient.CustType.BILL_PAY) add(FahipayService.DHIRAAGU_BILL)
|
||||||
|
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.RAASTAS)
|
||||||
|
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.OOREDOO_BILL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Service picker ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun showServiceChips(
|
||||||
|
services: List<FahipayService>,
|
||||||
|
number: String,
|
||||||
|
dhiraaguName: String?
|
||||||
|
) {
|
||||||
|
binding.chipDhiraaguReload.visibility = visibilityFor(FahipayService.DHIRAAGU_RELOAD in services)
|
||||||
|
binding.chipDhiraaguBill.visibility = visibilityFor(FahipayService.DHIRAAGU_BILL in services)
|
||||||
|
binding.chipRaastas.visibility = visibilityFor(FahipayService.RAASTAS in services)
|
||||||
|
binding.chipOoredooBill.visibility = visibilityFor(FahipayService.OOREDOO_BILL in services)
|
||||||
|
binding.layoutServiceSelector.visibility = View.VISIBLE
|
||||||
|
binding.chipGroupService.clearCheck()
|
||||||
|
|
||||||
|
// Dhiraagu is the only carrier that hands back an owner name, so the Ooredoo chips
|
||||||
|
// resolve their display name from saved contacts instead.
|
||||||
|
bindChip(binding.chipDhiraaguReload, FahipayService.DHIRAAGU_RELOAD, number, dhiraaguName)
|
||||||
|
bindChip(binding.chipDhiraaguBill, FahipayService.DHIRAAGU_BILL, number, dhiraaguName)
|
||||||
|
bindChip(binding.chipRaastas, FahipayService.RAASTAS, number, null)
|
||||||
|
bindChip(binding.chipOoredooBill, FahipayService.OOREDOO_BILL, number, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindChip(
|
||||||
|
chip: com.google.android.material.chip.Chip,
|
||||||
|
picked: FahipayService,
|
||||||
|
number: String,
|
||||||
|
ownerName: String?
|
||||||
|
) {
|
||||||
|
chip.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (checked) {
|
||||||
|
selectService(picked, number, ownerName)
|
||||||
|
binding.layoutServiceSelector.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun visibilityFor(shown: Boolean) = if (shown) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
private fun selectService(picked: FahipayService, number: String, ownerName: String?) {
|
||||||
|
service = picked
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
val displayName = ownerName
|
||||||
|
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
|
||||||
|
?: number
|
||||||
|
fragment.prefillToDirectly(
|
||||||
|
accountNumber = number,
|
||||||
|
displayName = displayName,
|
||||||
|
subtitle = "${picked.label} · $number",
|
||||||
|
colorHex = "#FF6B00",
|
||||||
|
imageHash = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,653 @@
|
|||||||
|
package sh.sar.basedbank.ui.home.transfer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.widget.addTextChangedListener
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaInvalidOtpException
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaRecipientNotFoundException
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||||
|
import sh.sar.basedbank.ui.home.HomeActivity
|
||||||
|
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||||
|
import sh.sar.basedbank.ui.home.TransferFragment
|
||||||
|
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||||
|
import sh.sar.basedbank.util.AccountInputParser
|
||||||
|
import sh.sar.basedbank.util.RecentPick
|
||||||
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the M-Faisa-only parts of the Transfer screen: phone-based recipient lookup,
|
||||||
|
* initiate-with-OTP and confirm-with-OTP for wallet transfers, plus merchant QR pay
|
||||||
|
* (qrCodeId lookup + initiate/confirm purchase). Lives alongside [TransferFragment] which
|
||||||
|
* dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source, or whenever
|
||||||
|
* an M-Faisa merchant QR is loaded.
|
||||||
|
*
|
||||||
|
* Mirrors [BmlTransferHandler]: the fragment keeps the shared confirm dialog, the recipient card
|
||||||
|
* and the form state; the handler keeps everything M-Faisa-specific.
|
||||||
|
*
|
||||||
|
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
|
||||||
|
* [Fragment.viewLifecycleOwner] and Context) — and must be re-created when the view is recreated.
|
||||||
|
*/
|
||||||
|
class MfaisaTransferHandler(
|
||||||
|
private val fragment: TransferFragment,
|
||||||
|
private val binding: FragmentTransferBinding,
|
||||||
|
private val viewModel: HomeViewModel,
|
||||||
|
/** Reads the fragment's currently-selected source account. */
|
||||||
|
private val currentSource: () -> BankAccount?,
|
||||||
|
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
||||||
|
private val selectSource: (BankAccount) -> Unit,
|
||||||
|
/** Hook called when M-Faisa successfully resolves or clears a recipient — fragment uses this to update Send-button state. */
|
||||||
|
private val onRecipientChanged: () -> Unit,
|
||||||
|
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes account balances. */
|
||||||
|
private val onTransferSuccess: (TransferReceiptData, Bitmap?) -> Unit,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||||
|
private val ctx get() = fragment.requireContext()
|
||||||
|
private val host get() = fragment.activity as? HomeActivity
|
||||||
|
|
||||||
|
/** Set to the resolved recipient after a successful search; null otherwise. */
|
||||||
|
var recipient: MfaisaTransferClient.Recipient? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
/** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */
|
||||||
|
var qrInfo: MfaisaQrPayClient.QrMerchant? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var lookupInFlight = false
|
||||||
|
|
||||||
|
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||||
|
|
||||||
|
/** Whether the recipient lookup has resolved — gates the Send button. */
|
||||||
|
fun isRecipientReady(): Boolean = recipient?.isMvr == true
|
||||||
|
|
||||||
|
/** Triggered when the user taps the search end-icon in `tilTo` (and source bank is MFAISA). */
|
||||||
|
fun searchRecipient(rawInput: String) {
|
||||||
|
if (lookupInFlight) return
|
||||||
|
// Reuse the shared normalizer so "+960", "960", and embedded spaces work the same as
|
||||||
|
// they do for MIB/BML lookup. The result is a bare 7-digit MSISDN when input was a
|
||||||
|
// local phone number, untouched otherwise.
|
||||||
|
val phone = AccountInputParser.normalize(rawInput)
|
||||||
|
if (AccountInputParser.detect(phone) != AccountInputParser.InputType.PHONE) {
|
||||||
|
binding.tilTo.error = "Enter a valid mobile number"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.tilTo.error = null
|
||||||
|
|
||||||
|
val source = mfaisaSource() ?: return
|
||||||
|
val session = app.mfaisaSessionFor(source) ?: run {
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lookupInFlight = true
|
||||||
|
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
MfaisaTransferClient.forContext(ctx).searchRecipient(session, phone)
|
||||||
|
} catch (_: MfaisaSessionExpiredException) {
|
||||||
|
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||||
|
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||||
|
MfaisaTransferClient.forContext(ctx).searchRecipient(fresh, phone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!result.isMvr) {
|
||||||
|
// Server returned the user but only with a PayPal pocket — not supported.
|
||||||
|
binding.tilTo.error = "This number doesn't have an MVR M-Faisa pocket"
|
||||||
|
} else {
|
||||||
|
recipient = result
|
||||||
|
showResolvedRecipient(result)
|
||||||
|
}
|
||||||
|
} catch (_: MfaisaRecipientNotFoundException) {
|
||||||
|
binding.tilTo.error = "No M-Faisa wallet found for this number"
|
||||||
|
} catch (e: java.io.IOException) {
|
||||||
|
Toast.makeText(ctx, R.string.connectivity_no_internet, Toast.LENGTH_SHORT).show()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
binding.tilTo.error = e.message ?: "Lookup failed"
|
||||||
|
} finally {
|
||||||
|
lookupInFlight = false
|
||||||
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
onRecipientChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Triggered when the user taps the Send button (and source bank is MFAISA). */
|
||||||
|
fun submit() {
|
||||||
|
val source = mfaisaSource() ?: return
|
||||||
|
val r = recipient ?: run {
|
||||||
|
Toast.makeText(ctx, "Search for a recipient first", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val amountStr = binding.etAmount.text?.toString()?.trim().orEmpty()
|
||||||
|
val amount = amountStr.toDoubleOrNull()
|
||||||
|
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
|
||||||
|
binding.tilAmount.error = null
|
||||||
|
val remarks = binding.etRemarks.text?.toString()?.trim().orEmpty()
|
||||||
|
|
||||||
|
val sourcePocketId = source.accountNumber // pocketId IS the accountNumber for M-Faisa accounts
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val refId = try {
|
||||||
|
withContext(Dispatchers.IO) { initiateWithRetry(source, sourcePocketId, r, amountStr, remarks) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
showError(e)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
// Server has now SMSed an OTP to the user's phone. Prompt them for it.
|
||||||
|
promptForOtp(source, r, amountStr, remarks, refId, errorMsg = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a merchant QR is loaded — counts as a resolved recipient for the Send button. */
|
||||||
|
val hasQrMerchant: Boolean get() = qrInfo != null
|
||||||
|
|
||||||
|
/** Called when the source account changes away from M-Faisa (or the view tears down). */
|
||||||
|
fun clearState() {
|
||||||
|
recipient = null
|
||||||
|
lookupInFlight = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
|
||||||
|
fun clearQrMerchant() {
|
||||||
|
if (qrInfo == null) return
|
||||||
|
qrInfo = null
|
||||||
|
binding.tilAmount.isEnabled = true
|
||||||
|
binding.tilRemarks.isEnabled = true
|
||||||
|
binding.tilRemarks.alpha = 1f
|
||||||
|
binding.etAmount.setText("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Merchant QR ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves an M-Faisa qrCodeId to a merchant, paints it in the "To" card, auto-selects an
|
||||||
|
* M-Faisa source if none is selected (or the current one is the wrong bank), and pre-fills
|
||||||
|
* the amount if the QR is dynamic.
|
||||||
|
*/
|
||||||
|
fun lookupQrMerchant(qrCodeId: String) {
|
||||||
|
val source = mfaisaSource() ?: run {
|
||||||
|
Toast.makeText(ctx, "No M-Faisa account available", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val session = app.mfaisaSessionFor(source) ?: run {
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually
|
||||||
|
if (currentSource()?.bank != "MFAISA") selectSource(source)
|
||||||
|
|
||||||
|
// Lock the "To" input row while loading
|
||||||
|
binding.tilTo.visibility = View.GONE
|
||||||
|
binding.btnPickContact.visibility = View.GONE
|
||||||
|
binding.btnScanQr.visibility = View.GONE
|
||||||
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val merchant = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
MfaisaQrPayClient().fetchQrDetails(session, qrCodeId)
|
||||||
|
} catch (_: MfaisaSessionExpiredException) {
|
||||||
|
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||||
|
?: return@withContext null
|
||||||
|
try { MfaisaQrPayClient().fetchQrDetails(fresh, qrCodeId) }
|
||||||
|
catch (_: Exception) { null }
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
host?.setRefreshing(false)
|
||||||
|
if (merchant == null) {
|
||||||
|
Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
|
||||||
|
fragment.resetToFieldVisibility()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
qrInfo = merchant
|
||||||
|
|
||||||
|
// Static QRs (no preset amount) make sense to keep in Recents — the merchant is
|
||||||
|
// reusable. Dynamic QRs are one-off so we skip them, same rule as BML QR pay.
|
||||||
|
if (merchant.txnAmount.isNullOrBlank()) {
|
||||||
|
RecentsCache.save(ctx, RecentPick(
|
||||||
|
accountNumber = "mfaisaqr:${merchant.qrCodeId}",
|
||||||
|
displayName = merchant.merchantName,
|
||||||
|
subtitle = "M-Faisa merchant · ${merchant.merchantMsisdn}",
|
||||||
|
colorHex = "#ED1C24",
|
||||||
|
imageHash = null,
|
||||||
|
isProfileImage = false,
|
||||||
|
bank = "MFAISA"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show merchant in the "To" card — clear button is the only way to back out
|
||||||
|
binding.tvToAccountName.text = merchant.merchantName
|
||||||
|
binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}"
|
||||||
|
binding.tvToAccountDetails.visibility = View.GONE
|
||||||
|
binding.tvToBalance.visibility = View.GONE
|
||||||
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
|
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
|
||||||
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// Pre-fill + lock amount if the QR is dynamic
|
||||||
|
val dynamicAmount = merchant.txnAmount?.toDoubleOrNull()
|
||||||
|
if (dynamicAmount != null && dynamicAmount > 0.0) {
|
||||||
|
binding.etAmount.setText("%.2f".format(dynamicAmount))
|
||||||
|
binding.tilAmount.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onRecipientChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog —
|
||||||
|
* the /initiateNewBuy + /confirmNewBuy pair does NOT require OTP for wallet QR pay
|
||||||
|
* (2FARequired=NONE), so unlike [submit] there is no code to enter.
|
||||||
|
*/
|
||||||
|
fun submitQrPayment() {
|
||||||
|
val merchant = qrInfo ?: return
|
||||||
|
val src = currentSource() ?: run {
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (src.bank != "MFAISA") {
|
||||||
|
Toast.makeText(ctx, "Switch to an M-Faisa account to pay this QR", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
|
||||||
|
val amount = amountStr.toDoubleOrNull()
|
||||||
|
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
|
||||||
|
binding.tilAmount.error = null
|
||||||
|
val remarks = binding.etRemarks.text?.toString()?.trim().orEmpty()
|
||||||
|
val amountValue = "%.2f".format(amount)
|
||||||
|
|
||||||
|
val confirmView = fragment.buildTransferConfirmView(
|
||||||
|
amountCurrency = merchant.currencyCode,
|
||||||
|
amountValue = amountValue,
|
||||||
|
fromName = src.accountBriefName,
|
||||||
|
fromNumber = src.accountNumber,
|
||||||
|
fromDetail = "M-Faisa",
|
||||||
|
toName = merchant.merchantName,
|
||||||
|
toNumber = merchant.merchantMsisdn,
|
||||||
|
toDetail = "Ooredoo M-Faisa merchant"
|
||||||
|
)
|
||||||
|
fragment.showConfirmWithBiometric(
|
||||||
|
title = ctx.getString(R.string.transfer),
|
||||||
|
customView = confirmView,
|
||||||
|
biometricSubtitle = "${merchant.currencyCode} $amountValue → ${merchant.merchantName}",
|
||||||
|
onConfirmed = { dialog, frame ->
|
||||||
|
fragment.showProcessingInDialog(dialog, frame)
|
||||||
|
executeQrPayment(src, merchant, amountValue, remarks, dialog)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executeQrPayment(
|
||||||
|
src: BankAccount,
|
||||||
|
merchant: MfaisaQrPayClient.QrMerchant,
|
||||||
|
amountStr: String,
|
||||||
|
remarks: String,
|
||||||
|
dialog: AlertDialog
|
||||||
|
) {
|
||||||
|
val loginId = src.loginTag.removePrefix("mfaisa_")
|
||||||
|
val initialSession = app.mfaisaSessionFor(src) ?: run {
|
||||||
|
dialog.dismiss()
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// M-Faisa expects the user's MSISDN with the "960" country prefix (the session stores the
|
||||||
|
// bare 7-digit form). The pocket itself is identified by [BankAccount.accountNumber].
|
||||||
|
val sourceMdn = "960${initialSession.msisdn}"
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val outcome = withContext(Dispatchers.IO) {
|
||||||
|
val client = MfaisaQrPayClient()
|
||||||
|
try {
|
||||||
|
val refId = try {
|
||||||
|
client.initiatePurchase(initialSession, src.accountNumber, sourceMdn, merchant, amountStr, remarks)
|
||||||
|
} catch (_: MfaisaSessionExpiredException) {
|
||||||
|
val fresh = app.refreshMfaisaSession(loginId)
|
||||||
|
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||||
|
client.initiatePurchase(fresh, src.accountNumber, "960${fresh.msisdn}", merchant, amountStr, remarks)
|
||||||
|
}
|
||||||
|
val confirmSession = app.mfaisaSessionFor(src) ?: initialSession
|
||||||
|
try {
|
||||||
|
client.confirmPurchase(confirmSession, refId)
|
||||||
|
} catch (_: MfaisaSessionExpiredException) {
|
||||||
|
val fresh = app.refreshMfaisaSession(loginId)
|
||||||
|
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||||
|
client.confirmPurchase(fresh, refId)
|
||||||
|
}
|
||||||
|
Result.success(refId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure<String>(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fragment.view == null) return@launch
|
||||||
|
|
||||||
|
outcome.fold(
|
||||||
|
onSuccess = { _ ->
|
||||||
|
val receipt = TransferReceiptData(
|
||||||
|
bank = "MFAISA",
|
||||||
|
amount = amountStr,
|
||||||
|
currency = merchant.currencyCode,
|
||||||
|
fromLabel = src.accountBriefName,
|
||||||
|
fromColorHex = "#ED1C24",
|
||||||
|
toLabel = merchant.merchantName,
|
||||||
|
toAccount = merchant.merchantMsisdn,
|
||||||
|
toBank = "Ooredoo M-Faisa",
|
||||||
|
remarks = remarks,
|
||||||
|
mfaisaTransactionType = "Merchant payment",
|
||||||
|
mfaisaFromName = src.accountBriefName,
|
||||||
|
mfaisaFromMsisdn = src.accountNumber,
|
||||||
|
mfaisaToMsisdn = merchant.merchantMsisdn,
|
||||||
|
mfaisaTimestamp = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
dialog.dismiss()
|
||||||
|
onTransferSuccess(receipt, null)
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
dialog.dismiss()
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
showError(e, fallback = "Payment failed")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internal ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The M-Faisa account to act on: the selected source when it is one, otherwise the first
|
||||||
|
* M-Faisa account available. The fallback is what lets a scanned merchant QR auto-switch
|
||||||
|
* the source away from another bank.
|
||||||
|
*/
|
||||||
|
private fun mfaisaSource(): BankAccount? =
|
||||||
|
currentSource()?.takeIf { it.bank == "MFAISA" }
|
||||||
|
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" }
|
||||||
|
|
||||||
|
private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) {
|
||||||
|
// Reuse the same recipient card the fragment uses for other banks. The fragment owns the
|
||||||
|
// card view, so we just populate its text fields and toggle visibility.
|
||||||
|
binding.tvToAccountName.text = r.name.ifBlank { r.msisdn }
|
||||||
|
binding.tvToBankBic.text = r.msisdn
|
||||||
|
binding.tvToAccountDetails.text = "Ooredoo M-Faisa · MVR"
|
||||||
|
binding.tvToAccountDetails.visibility = View.VISIBLE
|
||||||
|
binding.tvToBalance.visibility = View.GONE
|
||||||
|
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
|
||||||
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
|
|
||||||
|
binding.tilTo.visibility = View.GONE
|
||||||
|
binding.btnPickContact.visibility = View.GONE
|
||||||
|
binding.btnScanQr.visibility = View.GONE
|
||||||
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
RecentsCache.save(ctx, RecentPick(
|
||||||
|
accountNumber = r.msisdn,
|
||||||
|
displayName = r.name.ifBlank { r.msisdn },
|
||||||
|
subtitle = "Ooredoo M-Faisa · ${r.msisdn}",
|
||||||
|
colorHex = "#ED1C24",
|
||||||
|
imageHash = null,
|
||||||
|
isProfileImage = false,
|
||||||
|
bank = "MFAISA"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initiate with one automatic retry if the session has expired. */
|
||||||
|
private fun initiateWithRetry(
|
||||||
|
source: BankAccount,
|
||||||
|
sourcePocketId: String,
|
||||||
|
r: MfaisaTransferClient.Recipient,
|
||||||
|
amountStr: String,
|
||||||
|
remarks: String
|
||||||
|
): String {
|
||||||
|
val session = app.mfaisaSessionFor(source) ?: throw IllegalStateException("No M-Faisa session")
|
||||||
|
return try {
|
||||||
|
MfaisaTransferClient.forContext(ctx).initiateTransfer(session, sourcePocketId, r, amountStr, remarks)
|
||||||
|
} catch (_: MfaisaSessionExpiredException) {
|
||||||
|
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||||
|
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||||
|
MfaisaTransferClient.forContext(ctx).initiateTransfer(fresh, sourcePocketId, r, amountStr, remarks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun confirmWithRetry(source: BankAccount, refId: String, otpCode: String) {
|
||||||
|
val session = app.mfaisaSessionFor(source) ?: throw IllegalStateException("No M-Faisa session")
|
||||||
|
try {
|
||||||
|
MfaisaTransferClient.forContext(ctx).confirmTransfer(session, refId, otpCode)
|
||||||
|
} catch (_: MfaisaSessionExpiredException) {
|
||||||
|
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||||
|
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||||
|
MfaisaTransferClient.forContext(ctx).confirmTransfer(fresh, refId, otpCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the unified "confirm + enter OTP" dialog. Body is the standard transfer-confirm
|
||||||
|
* view (amount + from/to blocks via [TransferFragment.buildTransferConfirmView]) plus an
|
||||||
|
* OTP input. The Confirm button stays disabled until a 6-digit code is entered. Biometric
|
||||||
|
* gating + invalid-OTP re-prompt + session-refresh retry are all preserved.
|
||||||
|
*
|
||||||
|
* The displayed "code sent to" line uses the SOURCE M-Faisa login's MSISDN (where the SMS
|
||||||
|
* was actually sent) — the old standalone OTP dialog mistakenly showed the recipient.
|
||||||
|
*/
|
||||||
|
private fun promptForOtp(
|
||||||
|
source: BankAccount,
|
||||||
|
r: MfaisaTransferClient.Recipient,
|
||||||
|
amountStr: String,
|
||||||
|
remarks: String,
|
||||||
|
refId: String,
|
||||||
|
errorMsg: String?
|
||||||
|
) {
|
||||||
|
val view = fragment.view ?: return
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val colorMuted = MaterialColors.getColor(
|
||||||
|
view, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
|
||||||
|
val colorOutline = MaterialColors.getColor(
|
||||||
|
view, com.google.android.material.R.attr.colorOutlineVariant, Color.LTGRAY)
|
||||||
|
|
||||||
|
val amountValue = try { "%.2f".format(amountStr.toDouble()) } catch (_: Exception) { amountStr }
|
||||||
|
val confirmView = fragment.buildTransferConfirmView(
|
||||||
|
amountCurrency = "MVR",
|
||||||
|
amountValue = amountValue,
|
||||||
|
fromName = source.accountBriefName,
|
||||||
|
fromNumber = source.accountNumber,
|
||||||
|
fromDetail = "M-Faisa",
|
||||||
|
toName = r.name.ifBlank { r.msisdn },
|
||||||
|
toNumber = r.msisdn,
|
||||||
|
toDetail = "Ooredoo M-Faisa"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The user's own M-Faisa MSISDN (where the SMS is sent). The session stores the bare
|
||||||
|
// 7 digits; prefix with 960 for display.
|
||||||
|
val userMsisdn = app.mfaisaSessionFor(source)?.msisdn
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { "960$it" }
|
||||||
|
?: "your registered number"
|
||||||
|
|
||||||
|
val otpHeader = TextView(ctx).apply {
|
||||||
|
text = "A 6-digit verification code has been sent to $userMsisdn"
|
||||||
|
textSize = 13f
|
||||||
|
setTextColor(colorMuted)
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
}
|
||||||
|
val otpInput = android.widget.EditText(ctx).apply {
|
||||||
|
hint = "Enter 6-digit code"
|
||||||
|
inputType = android.text.InputType.TYPE_CLASS_NUMBER
|
||||||
|
filters = arrayOf(android.text.InputFilter.LengthFilter(6))
|
||||||
|
textSize = 20f
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
letterSpacing = 0.3f
|
||||||
|
}
|
||||||
|
val errorView = errorMsg?.let {
|
||||||
|
TextView(ctx).apply {
|
||||||
|
text = it
|
||||||
|
textSize = 13f
|
||||||
|
setTextColor(Color.RED)
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val divider = View(ctx).apply {
|
||||||
|
setBackgroundColor(colorOutline)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).apply {
|
||||||
|
topMargin = (8 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val otpSection = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding((20 * dp).toInt(), (12 * dp).toInt(), (20 * dp).toInt(), (4 * dp).toInt())
|
||||||
|
addView(otpHeader, LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT))
|
||||||
|
addView(otpInput, LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
topMargin = (8 * dp).toInt()
|
||||||
|
})
|
||||||
|
if (errorView != null) {
|
||||||
|
addView(errorView, LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
topMargin = (8 * dp).toInt()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val container = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
addView(confirmView)
|
||||||
|
addView(divider)
|
||||||
|
addView(otpSection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide any previously-open keyboard so the OTP field can claim focus cleanly.
|
||||||
|
val imm = ctx.getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
|
||||||
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(R.string.transfer)
|
||||||
|
.setView(container)
|
||||||
|
.setPositiveButton(R.string.transfer_confirm, null)
|
||||||
|
.setNegativeButton(R.string.cancel) { d, _ ->
|
||||||
|
d.dismiss()
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
}
|
||||||
|
.setCancelable(false)
|
||||||
|
.show()
|
||||||
|
|
||||||
|
val confirmBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||||
|
confirmBtn.isEnabled = false
|
||||||
|
otpInput.addTextChangedListener { text ->
|
||||||
|
confirmBtn.isEnabled = (text?.length ?: 0) == 6
|
||||||
|
}
|
||||||
|
|
||||||
|
val prefs = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false)
|
||||||
|
val canAuth = BiometricManager.from(ctx)
|
||||||
|
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
|
||||||
|
|
||||||
|
val runConfirm: () -> Unit = {
|
||||||
|
val otp = otpInput.text?.toString()?.trim().orEmpty()
|
||||||
|
dialog.dismiss()
|
||||||
|
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) { confirmWithRetry(source, refId, otp) }
|
||||||
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
val receipt = TransferReceiptData(
|
||||||
|
bank = "MFAISA",
|
||||||
|
amount = amountValue,
|
||||||
|
currency = "MVR",
|
||||||
|
fromLabel = source.accountBriefName,
|
||||||
|
fromColorHex = "#ED1C24",
|
||||||
|
toLabel = r.name.ifBlank { r.msisdn },
|
||||||
|
toAccount = r.msisdn,
|
||||||
|
toBank = "Ooredoo M-Faisa",
|
||||||
|
remarks = remarks,
|
||||||
|
mfaisaTransactionType = "Transfer to mobile",
|
||||||
|
mfaisaFromName = source.accountBriefName,
|
||||||
|
mfaisaFromMsisdn = source.accountNumber,
|
||||||
|
mfaisaToMsisdn = r.msisdn,
|
||||||
|
mfaisaTimestamp = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
onTransferSuccess(receipt, null)
|
||||||
|
} catch (e: MfaisaInvalidOtpException) {
|
||||||
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
// Server kept the referenceId alive — re-prompt without restarting initiate.
|
||||||
|
promptForOtp(source, r, amountStr, remarks, refId, e.message)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
showError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmBtn.setOnClickListener {
|
||||||
|
val otp = otpInput.text?.toString()?.trim().orEmpty()
|
||||||
|
if (otp.length != 6) { otpInput.error = "Enter 6 digits"; return@setOnClickListener }
|
||||||
|
if (biometricTransferConfirm && canAuth) {
|
||||||
|
val prompt = BiometricPrompt(fragment, ContextCompat.getMainExecutor(ctx),
|
||||||
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||||
|
runConfirm()
|
||||||
|
}
|
||||||
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||||
|
if (errorCode != BiometricPrompt.ERROR_CANCELED &&
|
||||||
|
errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
|
||||||
|
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
|
||||||
|
Toast.makeText(ctx, errString, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onAuthenticationFailed() { /* keep dialog open */ }
|
||||||
|
})
|
||||||
|
prompt.authenticate(
|
||||||
|
BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle(ctx.getString(R.string.biometric_transfer_title))
|
||||||
|
.setSubtitle("MVR $amountValue → ${r.name.ifBlank { r.msisdn }}")
|
||||||
|
.setNegativeButtonText(ctx.getString(android.R.string.cancel))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
runConfirm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showError(e: Throwable, fallback: String = "Transfer failed") {
|
||||||
|
val msg = when {
|
||||||
|
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
|
||||||
|
!e.message.isNullOrBlank() -> e.message!!
|
||||||
|
else -> fallback
|
||||||
|
}
|
||||||
|
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package sh.sar.basedbank.ui.home.transfer
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
|
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
||||||
|
import sh.sar.basedbank.api.mib.MibLookupException
|
||||||
|
import sh.sar.basedbank.api.mib.MibSession
|
||||||
|
import sh.sar.basedbank.api.mib.MibTransferClient
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||||
|
import sh.sar.basedbank.util.AccountInputParser
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.Totp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the MIB-only parts of the Transfer screen: session resolution, IPS destination lookup,
|
||||||
|
* the transfer itself (profile switch + TOTP + `MibTransferClient`), and the profile/contact
|
||||||
|
* avatar API that every bank's "To" card borrows.
|
||||||
|
*
|
||||||
|
* Unlike [BmlTransferHandler] and [MfaisaTransferHandler] this one holds no view binding and
|
||||||
|
* paints nothing — every method is either a session accessor or a blocking API call meant for
|
||||||
|
* `Dispatchers.IO`. [sh.sar.basedbank.ui.home.TransferFragment] keeps the coroutine plumbing and
|
||||||
|
* the view updates, so this handler can outlive a view recreation.
|
||||||
|
*/
|
||||||
|
class MibTransferHandler(
|
||||||
|
private val fragment: Fragment,
|
||||||
|
/** Reads the fragment's currently-selected source account. */
|
||||||
|
private val currentSource: () -> BankAccount?,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||||
|
private val ctx get() = fragment.requireContext()
|
||||||
|
|
||||||
|
/** The selected source's MIB session, falling back to any logged-in MIB session. */
|
||||||
|
val session: MibSession?
|
||||||
|
get() = currentSource()?.let { app.mibSessionFor(it) } ?: app.anyMibSession()
|
||||||
|
|
||||||
|
/** Any logged-in MIB session, ignoring which source is selected. */
|
||||||
|
val anySession: MibSession? get() = app.anyMibSession()
|
||||||
|
|
||||||
|
// ─── Destination lookup ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Either a resolved destination or the message to show the user — never both. */
|
||||||
|
data class LookupOutcome(val info: MibIpsAccountInfo?, val error: String?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IPS lookup for a destination account/alias. Blocking — call from IO.
|
||||||
|
* A missing MIB session is reported as "account not found", same as a failed lookup.
|
||||||
|
*/
|
||||||
|
fun lookupDestination(accountNumber: String): LookupOutcome {
|
||||||
|
val sess = session ?: return LookupOutcome(null, notFound())
|
||||||
|
return try {
|
||||||
|
LookupOutcome(MibTransferClient().lookup(sess, accountNumber), null)
|
||||||
|
} catch (e: MibLookupException) {
|
||||||
|
LookupOutcome(null, e.message)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
LookupOutcome(null, notFound())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currency-only lookup, used to enrich a BML "verify MIB account" result — that endpoint
|
||||||
|
* resolves the name but not the currency. Blocking; returns "" when it can't be determined.
|
||||||
|
*/
|
||||||
|
fun lookupCurrency(accountNumber: String): String {
|
||||||
|
val sess = session ?: return ""
|
||||||
|
return try { MibTransferClient().lookup(sess, accountNumber).currency }
|
||||||
|
catch (_: Exception) { "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notFound() = ctx.getString(R.string.transfer_account_not_found)
|
||||||
|
|
||||||
|
// ─── Avatars ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches and decodes a profile image (P41, [isProfile] true) or a saved contact's image.
|
||||||
|
* Blocking — call from IO. Returns null on any failure, including a missing image.
|
||||||
|
*
|
||||||
|
* The session is passed in rather than read from [session] because callers differ: the "To"
|
||||||
|
* card uses the selected source's session, the account dropdown uses [anySession].
|
||||||
|
*/
|
||||||
|
fun fetchAvatar(session: MibSession, hash: String, isProfile: Boolean): Bitmap? = try {
|
||||||
|
val base64 = if (isProfile) {
|
||||||
|
app.anyMibFlow()?.fetchProfileImage(session, hash)
|
||||||
|
} else {
|
||||||
|
MibContactsClient().fetchProfileImageBase64(session, hash)
|
||||||
|
}
|
||||||
|
if (base64 == null) null else {
|
||||||
|
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
|
||||||
|
// ─── Transfer ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches to the profile that owns [src], then runs the transfer with a freshly generated
|
||||||
|
* TOTP. Blocking — call from IO. Returns `(ok, message, receipt)`; the message is
|
||||||
|
* "CONNECTIVITY" when the network was unreachable.
|
||||||
|
*/
|
||||||
|
fun doTransfer(
|
||||||
|
src: BankAccount,
|
||||||
|
destAccount: String,
|
||||||
|
destName: String,
|
||||||
|
destDisplay: String,
|
||||||
|
amount: String,
|
||||||
|
remarks: String,
|
||||||
|
bankName: String
|
||||||
|
): Triple<Boolean, String, TransferReceiptData?> {
|
||||||
|
val sess = session ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
|
||||||
|
val loginId = src.loginTag.removePrefix("mib_")
|
||||||
|
val otp = CredentialStore(ctx).loadMibCredentials(loginId)?.otpSeed
|
||||||
|
?.let { Totp.generate(it) }
|
||||||
|
?: return Triple(false, "OTP unavailable", null)
|
||||||
|
val currencyCode = if (src.currencyName == "USD") "840" else "462"
|
||||||
|
val currency = if (src.currencyName == "USD") "USD" else "MVR"
|
||||||
|
val isDestMib = AccountInputParser.detect(destAccount) == AccountInputParser.InputType.MIB_ACCOUNT
|
||||||
|
val bankNo = if (isDestMib) 2 else 3
|
||||||
|
val toBank = when {
|
||||||
|
isDestMib -> "MIB"
|
||||||
|
else -> when (bankName.uppercase()) {
|
||||||
|
"MALBMVMV" -> "BML"
|
||||||
|
"MADVMVMV" -> "MIB"
|
||||||
|
else -> bankName.ifBlank { "LOCAL" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
// Switch to the profile that owns the source account
|
||||||
|
if (src.profileId.isNotBlank()) {
|
||||||
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
|
val profile = profiles.firstOrNull { it.profileId == src.profileId }
|
||||||
|
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
|
||||||
|
}
|
||||||
|
val result = MibTransferClient().transfer(
|
||||||
|
session = sess,
|
||||||
|
fromAccount = src.accountNumber,
|
||||||
|
toAccount = destAccount,
|
||||||
|
amount = amount,
|
||||||
|
currencyCode = currencyCode,
|
||||||
|
benefName = destName.ifBlank { "Recipient" },
|
||||||
|
bankNo = bankNo,
|
||||||
|
purpose = remarks,
|
||||||
|
otp = otp
|
||||||
|
)
|
||||||
|
if (result.success) {
|
||||||
|
val receipt = TransferReceiptData(
|
||||||
|
bank = "MIB",
|
||||||
|
amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0),
|
||||||
|
currency = currency,
|
||||||
|
fromLabel = src.accountBriefName,
|
||||||
|
fromColorHex = "#FE860E",
|
||||||
|
fromProfileImageHash = src.profileImageHash,
|
||||||
|
toLabel = destDisplay.ifBlank { destName },
|
||||||
|
toAccount = destAccount,
|
||||||
|
toBank = toBank,
|
||||||
|
remarks = remarks,
|
||||||
|
mibReferenceNo = result.trxId,
|
||||||
|
mibTransactionDate = result.date
|
||||||
|
)
|
||||||
|
Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt)
|
||||||
|
} else {
|
||||||
|
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@ class BankSelectionFragment : Fragment() {
|
|||||||
val args = android.os.Bundle().apply { putString("bankType", "FAHIPAY") }
|
val args = android.os.Bundle().apply { putString("bankType", "FAHIPAY") }
|
||||||
findNavController().navigate(R.id.action_bankSelection_to_credentials_fahipay, args)
|
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() {
|
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.FahipayAccountClient
|
||||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
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.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||||
import sh.sar.basedbank.api.mib.MibProfileClient
|
import sh.sar.basedbank.api.mib.MibProfileClient
|
||||||
@@ -102,6 +108,19 @@ class CredentialsFragment : Fragment() {
|
|||||||
binding.etOtpSeed.isEnabled = false
|
binding.etOtpSeed.isEnabled = false
|
||||||
binding.etOtpSeed.isFocusable = 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
|
binding.btnLogin.isEnabled = false
|
||||||
@@ -133,7 +152,7 @@ class CredentialsFragment : Fragment() {
|
|||||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (bankType != "FAHIPAY") {
|
if (bankType != "FAHIPAY" && bankType != "OOREDOO") {
|
||||||
binding.etOtpSeed.addTextChangedListener(object : TextWatcher {
|
binding.etOtpSeed.addTextChangedListener(object : TextWatcher {
|
||||||
override fun afterTextChanged(s: Editable?) {
|
override fun afterTextChanged(s: Editable?) {
|
||||||
updateOtpDisplay()
|
updateOtpDisplay()
|
||||||
@@ -147,7 +166,7 @@ class CredentialsFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
if (bankType != "FAHIPAY") otpHandler.post(otpRunnable)
|
if (bankType != "FAHIPAY" && bankType != "OOREDOO") otpHandler.post(otpRunnable)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
@@ -170,6 +189,7 @@ class CredentialsFragment : Fragment() {
|
|||||||
val otpSeed = resolveOtpSeed(otpSeedRaw)
|
val otpSeed = resolveOtpSeed(otpSeedRaw)
|
||||||
binding.btnLogin.isEnabled = when (bankType) {
|
binding.btnLogin.isEnabled = when (bankType) {
|
||||||
"FAHIPAY" -> username.isNotEmpty() && password.isNotEmpty()
|
"FAHIPAY" -> username.isNotEmpty() && password.isNotEmpty()
|
||||||
|
"OOREDOO" -> username.isNotEmpty() && password.length == 4
|
||||||
else -> username.isNotEmpty() && password.isNotEmpty() && otpSeed.isNotEmpty() && password != otpSeedRaw
|
else -> username.isNotEmpty() && password.isNotEmpty() && otpSeed.isNotEmpty() && password != otpSeedRaw
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,6 +224,7 @@ class CredentialsFragment : Fragment() {
|
|||||||
when (bankType) {
|
when (bankType) {
|
||||||
"BML" -> { attemptBmlLogin(); return }
|
"BML" -> { attemptBmlLogin(); return }
|
||||||
"FAHIPAY" -> { attemptFahipayLogin(); return }
|
"FAHIPAY" -> { attemptFahipayLogin(); return }
|
||||||
|
"OOREDOO" -> { attemptMfaisaLogin(); return }
|
||||||
}
|
}
|
||||||
|
|
||||||
val username = binding.etUsername.text.toString().trim()
|
val username = binding.etUsername.text.toString().trim()
|
||||||
@@ -411,6 +432,97 @@ class CredentialsFragment : Fragment() {
|
|||||||
startActivity(intent)
|
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() {
|
private fun attemptFahipayLogin() {
|
||||||
if (fahipayAwaitingTotp) {
|
if (fahipayAwaitingTotp) {
|
||||||
submitFahipayTotp()
|
submitFahipayTotp()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ object AccountCache {
|
|||||||
private const val KEY_MIB = "mib_accounts"
|
private const val KEY_MIB = "mib_accounts"
|
||||||
private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
|
private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
|
||||||
private fun fahipayKey(loginId: String) = "fahipay_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>) {
|
fun save(context: Context, accounts: List<BankAccount>) {
|
||||||
val arr = JSONArray()
|
val arr = JSONArray()
|
||||||
@@ -150,6 +151,62 @@ object AccountCache {
|
|||||||
fun loadFahipay(context: Context, loginIds: List<String>): List<BankAccount> =
|
fun loadFahipay(context: Context, loginIds: List<String>): List<BankAccount> =
|
||||||
loginIds.flatMap { loadFahipay(context, it) }
|
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) {
|
fun clear(context: Context) {
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package sh.sar.basedbank.util
|
|||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlHistoryParser
|
import sh.sar.basedbank.util.bmlapi.BmlHistoryParser
|
||||||
import sh.sar.basedbank.util.fahipayapi.FahipayHistoryParser
|
import sh.sar.basedbank.util.fahipayapi.FahipayHistoryParser
|
||||||
|
import sh.sar.basedbank.util.mfaisaapi.MfaisaHistoryParser
|
||||||
import sh.sar.basedbank.util.mibapi.MibHistoryParser
|
import sh.sar.basedbank.util.mibapi.MibHistoryParser
|
||||||
|
|
||||||
object AccountHistoryParser {
|
object AccountHistoryParser {
|
||||||
@@ -11,6 +12,7 @@ object AccountHistoryParser {
|
|||||||
"BML" -> BmlHistoryParser.displayData(account)
|
"BML" -> BmlHistoryParser.displayData(account)
|
||||||
"FAHIPAY" -> FahipayHistoryParser.displayData(account)
|
"FAHIPAY" -> FahipayHistoryParser.displayData(account)
|
||||||
"MIB" -> MibHistoryParser.displayData(account)
|
"MIB" -> MibHistoryParser.displayData(account)
|
||||||
|
"MFAISA" -> MfaisaHistoryParser.displayData(account)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package sh.sar.basedbank.util
|
|||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||||
import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser
|
import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser
|
||||||
|
import sh.sar.basedbank.util.mfaisaapi.MfaisaAccountParser
|
||||||
import sh.sar.basedbank.util.mibapi.MibAccountParser
|
import sh.sar.basedbank.util.mibapi.MibAccountParser
|
||||||
|
|
||||||
object AccountListParser {
|
object AccountListParser {
|
||||||
@@ -11,6 +12,7 @@ object AccountListParser {
|
|||||||
"BML" -> BmlDashboardParser.displayData(account)
|
"BML" -> BmlDashboardParser.displayData(account)
|
||||||
"FAHIPAY" -> FahipayAccountParser.displayData(account)
|
"FAHIPAY" -> FahipayAccountParser.displayData(account)
|
||||||
"MIB" -> MibAccountParser.displayData(account)
|
"MIB" -> MibAccountParser.displayData(account)
|
||||||
|
"MFAISA" -> MfaisaAccountParser.displayData(account)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class CredentialStore(context: Context) {
|
|||||||
data class MibCredentials(val username: String, val passwordHash: String, val otpSeed: String)
|
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 BmlCredentials(val username: String, val password: String, val otpSeed: String)
|
||||||
data class FahipayCredentials(val idCard: String, val password: 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) ─────
|
// ── MIB login credentials (multi-login, keyed by loginId = username) ─────
|
||||||
|
|
||||||
@@ -35,6 +36,11 @@ class CredentialStore(context: Context) {
|
|||||||
|
|
||||||
fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty()
|
fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty()
|
||||||
|
|
||||||
|
fun setMibLoginIds(order: List<String>) {
|
||||||
|
if (order.toSet() != getMibLoginIds().toSet()) return
|
||||||
|
prefs.edit().putString("mib_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
private fun addMibLoginId(loginId: String) {
|
private fun addMibLoginId(loginId: String) {
|
||||||
val ids = getMibLoginIds().toMutableList()
|
val ids = getMibLoginIds().toMutableList()
|
||||||
if (loginId !in ids) {
|
if (loginId !in ids) {
|
||||||
@@ -159,6 +165,11 @@ class CredentialStore(context: Context) {
|
|||||||
|
|
||||||
fun hasBmlCredentials(): Boolean = getBmlLoginIds().isNotEmpty()
|
fun hasBmlCredentials(): Boolean = getBmlLoginIds().isNotEmpty()
|
||||||
|
|
||||||
|
fun setBmlLoginIds(order: List<String>) {
|
||||||
|
if (order.toSet() != getBmlLoginIds().toSet()) return
|
||||||
|
prefs.edit().putString("bml_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
private fun addBmlLoginId(loginId: String) {
|
private fun addBmlLoginId(loginId: String) {
|
||||||
val ids = getBmlLoginIds().toMutableList()
|
val ids = getBmlLoginIds().toMutableList()
|
||||||
if (loginId !in ids) {
|
if (loginId !in ids) {
|
||||||
@@ -315,6 +326,11 @@ class CredentialStore(context: Context) {
|
|||||||
|
|
||||||
fun hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty()
|
fun hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty()
|
||||||
|
|
||||||
|
fun setFahipayLoginIds(order: List<String>) {
|
||||||
|
if (order.toSet() != getFahipayLoginIds().toSet()) return
|
||||||
|
prefs.edit().putString("fahipay_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
private fun addFahipayLoginId(loginId: String) {
|
private fun addFahipayLoginId(loginId: String) {
|
||||||
val ids = getFahipayLoginIds().toMutableList()
|
val ids = getFahipayLoginIds().toMutableList()
|
||||||
if (loginId !in ids) {
|
if (loginId !in ids) {
|
||||||
@@ -460,6 +476,115 @@ class CredentialStore(context: Context) {
|
|||||||
editor.apply()
|
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()
|
||||||
|
|
||||||
|
fun setMfaisaLoginIds(order: List<String>) {
|
||||||
|
if (order.toSet() != getMfaisaLoginIds().toSet()) return
|
||||||
|
prefs.edit().putString("mfaisa_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
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) ──────────────────────────────
|
// ── Security credential (PIN / pattern hash) ──────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
||||||
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
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.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||||
import sh.sar.basedbank.api.models.BankTransaction
|
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 isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
|
||||||
private val isBmlLoan get() = account.profileType == "BML_LOAN"
|
private val isBmlLoan get() = account.profileType == "BML_LOAN"
|
||||||
private val isFahipay get() = account.bank == "FAHIPAY"
|
private val isFahipay get() = account.bank == "FAHIPAY"
|
||||||
|
private val isMfaisa get() = account.bank == "MFAISA"
|
||||||
|
private val isMfaisaPaypal get() = account.profileType == "MFAISA_PAYPAL"
|
||||||
|
|
||||||
// MIB pagination
|
// MIB pagination
|
||||||
private var mibNextStart = 1
|
private var mibNextStart = 1
|
||||||
@@ -55,12 +58,19 @@ class HistoryFetcher(private val account: BankAccount) {
|
|||||||
private var fahipayNextStart = 0
|
private var fahipayNextStart = 0
|
||||||
private var fahipayTotal = -1
|
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 {
|
fun hasMore(): Boolean = when {
|
||||||
isBmlLoan -> false
|
isBmlLoan -> false
|
||||||
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
isMfaisaPaypal -> false // PayPal pockets have no known history endpoint
|
||||||
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||||
isBmlCard -> cardMonthOffset < 3
|
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||||
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
isBmlCard -> cardMonthOffset < 3
|
||||||
|
isMfaisa -> mfaisaHasMore
|
||||||
|
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<BankTransaction> = when {
|
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<BankTransaction> = when {
|
||||||
@@ -68,9 +78,36 @@ class HistoryFetcher(private val account: BankAccount) {
|
|||||||
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
|
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
|
||||||
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
|
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
|
||||||
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
|
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
|
||||||
|
isMfaisa -> withContext(Dispatchers.IO) { fetchMfaisa(app) }
|
||||||
else -> withContext(Dispatchers.IO) { fetchBmlCasa(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> {
|
private fun fetchFahipay(app: BasedBankApp): List<BankTransaction> {
|
||||||
val session = app.fahipaySessionFor(account) ?: return emptyList()
|
val session = app.fahipaySessionFor(account) ?: return emptyList()
|
||||||
val (list, total) = FahipayHistoryClient().fetchHistory(
|
val (list, total) = FahipayHistoryClient().fetchHistory(
|
||||||
|
|||||||
@@ -9,22 +9,58 @@ data class PaymvQrData(
|
|||||||
|
|
||||||
object PaymvQrParser {
|
object PaymvQrParser {
|
||||||
|
|
||||||
|
private const val BML_GATEWAY_PREFIX = "https://pay.bml.com.mv/app/"
|
||||||
|
private const val BML_EBANKING_PREFIX = "https://ebanking.bankofmaldives.com.mv/qrpay/"
|
||||||
|
private const val BML_POS_DOMAIN = "mv.com.bml.qtr"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the BML gateway URL if [raw] is or contains one, otherwise null.
|
* Returns the value to hand to the BML QR payment flow, or null when [raw] is not a BML QR.
|
||||||
* Handles both plain URL QRs and combined EMV QRs (e.g. Fahipay+BML card QR).
|
*
|
||||||
* For combined EMV QRs the URL is parsed from TLV (root tag 35 → sub-tag 20 → sub-sub-tag 01)
|
* Three shapes exist:
|
||||||
* rather than via regex, to avoid greedily consuming subsequent EMV tag bytes.
|
* - plain URL QRs — the QR text is the gateway short link or an ebanking `qrpay` link;
|
||||||
|
* - combined EMV QRs (e.g. Fahipay+BML card QRs), which carry the full gateway URL in TLV at
|
||||||
|
* `35` → `20` → `01`;
|
||||||
|
* - BML POS QRs (supplementary data domain `mv.com.bml.qtr`), where the same TLV path holds a
|
||||||
|
* bare reference such as `02:<32 hex>` rather than a URL. Those return the whole EMV payload,
|
||||||
|
* which [bmlPayRequestKey] then reduces back to the reference the lookup wants.
|
||||||
|
*
|
||||||
|
* The TLV is walked rather than regex-matched so a URL cannot greedily swallow the EMV tags
|
||||||
|
* that follow it.
|
||||||
*/
|
*/
|
||||||
fun extractBmlGatewayUrl(raw: String): String? {
|
fun bmlQrPayTarget(raw: String): String? {
|
||||||
if (raw.startsWith("https://pay.bml.com.mv/app/")) return raw
|
if (raw.startsWith(BML_GATEWAY_PREFIX) || raw.startsWith(BML_EBANKING_PREFIX)) return raw
|
||||||
return try {
|
val ref = bmlQrReference(raw) ?: return null
|
||||||
val root = parseTlv(raw)
|
if (ref.startsWith("https://")) return ref.takeIf { it.startsWith(BML_GATEWAY_PREFIX) }
|
||||||
val bmlMerchantInfo = root["35"]?.let { parseTlv(it) } ?: return null
|
// A bare reference only means a BML POS QR when the supplementary domain says so; anything
|
||||||
val inner = bmlMerchantInfo["20"]?.let { parseTlv(it) } ?: return null
|
// else keeps falling through to the PayMV parse, as it did before POS QRs existed.
|
||||||
inner["01"]?.takeIf { it.startsWith("https://pay.bml.com.mv/app/") }
|
return raw.takeIf { supplementaryDomain(it)?.startsWith(BML_POS_DOMAIN) == true }
|
||||||
} catch (_: Exception) {
|
}
|
||||||
null
|
|
||||||
}
|
/** TLV `80` → `00` — the supplementary data domain (`mv.favara.mpqr`, `mv.com.bml.qtr`, …). */
|
||||||
|
private fun supplementaryDomain(raw: String): String? = try {
|
||||||
|
parseTlv(raw)["80"]?.let { parseTlv(it) }?.get("00")
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TLV `35` → `20` → `01` — the BML payment reference carried inside an EMV QR. */
|
||||||
|
private fun bmlQrReference(raw: String): String? = try {
|
||||||
|
val merchantInfo = parseTlv(raw)["35"]?.let { parseTlv(it) }
|
||||||
|
val inner = merchantInfo?.get("20")?.let { parseTlv(it) }
|
||||||
|
inner?.get("01")?.takeIf { it.isNotBlank() }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `payrequest` lookup key for [target] (a value returned by [bmlQrPayTarget]), to be
|
||||||
|
* Base64-encoded into the URL. URL QRs resolve under the URL itself; POS QRs resolve under the
|
||||||
|
* bare `35` → `20` → `01` reference (the whole EMV payload is rejected with code 112,
|
||||||
|
* "Unsupported payment link").
|
||||||
|
*/
|
||||||
|
fun bmlPayRequestKey(target: String): String {
|
||||||
|
if (target.startsWith("https://")) return target
|
||||||
|
return bmlQrReference(target) ?: target
|
||||||
}
|
}
|
||||||
|
|
||||||
fun parse(raw: String): PaymvQrData? {
|
fun parse(raw: String): PaymvQrData? {
|
||||||
|
|||||||
@@ -43,7 +43,12 @@ object ReceiptStore {
|
|||||||
bmlFromName = o.optString("bmlFromName"),
|
bmlFromName = o.optString("bmlFromName"),
|
||||||
bmlReference = o.optString("bmlReference"),
|
bmlReference = o.optString("bmlReference"),
|
||||||
bmlTimestamp = o.optString("bmlTimestamp"),
|
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)
|
savedAt = o.optLong("savedAt", 0L)
|
||||||
)
|
)
|
||||||
@@ -75,6 +80,11 @@ object ReceiptStore {
|
|||||||
put("bmlReference", d.bmlReference)
|
put("bmlReference", d.bmlReference)
|
||||||
put("bmlTimestamp", d.bmlTimestamp)
|
put("bmlTimestamp", d.bmlTimestamp)
|
||||||
put("bmlMessage", d.bmlMessage)
|
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)
|
put("savedAt", ts)
|
||||||
})
|
})
|
||||||
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(arr.toString()))
|
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(arr.toString()))
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ data class RecentPick(
|
|||||||
val subtitle: String,
|
val subtitle: String,
|
||||||
val colorHex: String,
|
val colorHex: String,
|
||||||
val imageHash: 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 {
|
object RecentsCache {
|
||||||
@@ -34,6 +37,7 @@ object RecentsCache {
|
|||||||
put("colorHex", r.colorHex)
|
put("colorHex", r.colorHex)
|
||||||
if (r.imageHash != null) put("imageHash", r.imageHash)
|
if (r.imageHash != null) put("imageHash", r.imageHash)
|
||||||
put("isProfileImage", r.isProfileImage)
|
put("isProfileImage", r.isProfileImage)
|
||||||
|
if (r.bank != null) put("bank", r.bank)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
@@ -51,6 +55,7 @@ object RecentsCache {
|
|||||||
put("colorHex", r.colorHex)
|
put("colorHex", r.colorHex)
|
||||||
if (r.imageHash != null) put("imageHash", r.imageHash)
|
if (r.imageHash != null) put("imageHash", r.imageHash)
|
||||||
put("isProfileImage", r.isProfileImage)
|
put("isProfileImage", r.isProfileImage)
|
||||||
|
if (r.bank != null) put("bank", r.bank)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
@@ -75,7 +80,8 @@ object RecentsCache {
|
|||||||
subtitle = o.getString("subtitle"),
|
subtitle = o.getString("subtitle"),
|
||||||
colorHex = o.getString("colorHex"),
|
colorHex = o.getString("colorHex"),
|
||||||
imageHash = o.optString("imageHash").takeIf { it.isNotBlank() },
|
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) {
|
} catch (_: Exception) {
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ object BmlCardParser {
|
|||||||
"C8902", "C8907", "C8909", "C8912", "C8992", "C8996", "C8997", "C8982", "C8983" -> "cards/bml/master_islamic.png"
|
"C8902", "C8907", "C8909", "C8912", "C8992", "C8996", "C8997", "C8982", "C8983" -> "cards/bml/master_islamic.png"
|
||||||
"C8101" -> "cards/bml/master_masveriyaa.png"
|
"C8101" -> "cards/bml/master_masveriyaa.png"
|
||||||
"C8102" -> "cards/bml/master_odiveriyaa.png"
|
"C8102" -> "cards/bml/master_odiveriyaa.png"
|
||||||
"C8010", "C8011" -> "cards/bml/master_platinum.png"
|
"C8010", "C8011", "C8033" -> "cards/bml/master_platinum.png"
|
||||||
"C8040", "C8044" -> "cards/bml/master_world.png"
|
"C8040", "C8044" -> "cards/bml/master_world.png"
|
||||||
"C8030", "C8033" -> "cards/bml/master_business_debit.png"
|
"C8030" -> "cards/bml/master_business_debit.png"
|
||||||
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png"
|
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png"
|
||||||
"C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
|
"C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
|
||||||
"C8905", "C8995" -> "cards/bml/visa_credit.png"
|
"C8905", "C8995" -> "cards/bml/visa_credit.png"
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package sh.sar.basedbank.util.mfaisaapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.util.AccountListDisplay
|
||||||
|
|
||||||
|
object MfaisaAccountParser {
|
||||||
|
|
||||||
|
fun displayData(account: BankAccount) = AccountListDisplay(
|
||||||
|
name = account.accountBriefName,
|
||||||
|
number = account.accountNumber,
|
||||||
|
typeLabel = account.accountTypeName,
|
||||||
|
balance = "${account.currencyName} ${account.availableBalance}"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package sh.sar.basedbank.util.mfaisaapi
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.util.AccountHistoryDisplay
|
||||||
|
|
||||||
|
object MfaisaHistoryParser {
|
||||||
|
|
||||||
|
fun displayData(account: BankAccount) = AccountHistoryDisplay(
|
||||||
|
name = account.accountBriefName,
|
||||||
|
number = account.accountNumber,
|
||||||
|
bankPill = if (account.profileType == "MFAISA_PAYPAL") "PP" else "MF",
|
||||||
|
typeLabel = account.accountTypeName,
|
||||||
|
availableBalance = "${account.currencyName} ${account.availableBalance}",
|
||||||
|
workingBalance = "${account.currencyName} ${account.currentBalance}",
|
||||||
|
blockedBalance = null
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
</LinearLayout>
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</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 -->
|
<!-- Fahipay Card -->
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
android:id="@+id/cardFahipay"
|
android:id="@+id/cardFahipay"
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilPassword"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:hint="@string/password"
|
android:hint="@string/password"
|
||||||
|
|||||||
@@ -246,18 +246,10 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_marginHorizontal="4dp"
|
android:layout_marginStart="4dp"
|
||||||
android:text="Save"
|
android:text="Save"
|
||||||
app:icon="@drawable/ic_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>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="?attr/colorSurface">
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
android:id="@+id/receiptContainer"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:overScrollMode="never"
|
||||||
|
android:scrollbars="none">
|
||||||
|
|
||||||
|
<!-- ════════════════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- Renderable receipt card -->
|
||||||
|
<!-- ════════════════════════════════════════════════════════════════════ -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/receiptCard"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="#FFFFFF">
|
||||||
|
|
||||||
|
<!-- Top: m-faisaa logo on white -->
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="78dp"
|
||||||
|
android:layout_height="102dp"
|
||||||
|
android:layout_gravity="center_horizontal"
|
||||||
|
android:layout_marginTop="42dp"
|
||||||
|
android:src="@drawable/mfaisaa_logo_with_text"
|
||||||
|
android:contentDescription="@null" />
|
||||||
|
|
||||||
|
<!-- White→gray fade leading into the top zigzag tear -->
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="38dp"
|
||||||
|
android:layout_marginTop="22dp"
|
||||||
|
android:background="@drawable/bg_mfaisa_receipt_gradient_top" />
|
||||||
|
|
||||||
|
<!-- Top zigzag tear: white teeth poking up into the gray fade above -->
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="20dp"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/receipt_mfaisa_top"
|
||||||
|
android:contentDescription="@null" />
|
||||||
|
|
||||||
|
<!-- ════════════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- Receipt body -->
|
||||||
|
<!-- ════════════════════════════════════════════════════════════════ -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="#FFFFFF"
|
||||||
|
android:paddingHorizontal="24dp"
|
||||||
|
android:paddingTop="40dp"
|
||||||
|
android:paddingBottom="32dp">
|
||||||
|
|
||||||
|
<!-- Total amount row with green check -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Total Amount"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#7A7A7A"
|
||||||
|
android:fontFamily="@font/nunito_sans" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvAmount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textSize="30sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#A2D40A"
|
||||||
|
android:fontFamily="@font/nunito_sans" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="28dp"
|
||||||
|
android:layout_height="28dp"
|
||||||
|
android:src="@drawable/ic_mfaisa_receipt_check"
|
||||||
|
android:contentDescription="@null" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:layout_marginTop="20dp"
|
||||||
|
android:background="#EAEAEA" />
|
||||||
|
|
||||||
|
<!-- Status -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingVertical="14dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Status"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#7A7A7A"
|
||||||
|
android:fontFamily="@font/nunito_sans" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:text="Success"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#7A7A7A"
|
||||||
|
android:fontFamily="@font/nunito_sans" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="#EAEAEA" />
|
||||||
|
|
||||||
|
<!-- Transaction type -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingVertical="14dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Transaction type"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#7A7A7A"
|
||||||
|
android:fontFamily="@font/nunito_sans" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvTransactionType"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#7A7A7A"
|
||||||
|
android:fontFamily="@font/nunito_sans" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="#EAEAEA" />
|
||||||
|
|
||||||
|
<!-- From -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingVertical="14dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="From"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#7A7A7A"
|
||||||
|
android:fontFamily="@font/nunito_sans" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvFromName"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#7A7A7A"
|
||||||
|
android:fontFamily="@font/nunito_sans" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvFromMsisdn"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#7A7A7A"
|
||||||
|
android:fontFamily="@font/nunito_sans" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="#EAEAEA" />
|
||||||
|
|
||||||
|
<!-- To -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingVertical="14dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="To"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#7A7A7A"
|
||||||
|
android:fontFamily="@font/nunito_sans" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvToName"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#7A7A7A"
|
||||||
|
android:fontFamily="@font/nunito_sans" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvToMsisdn"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#7A7A7A"
|
||||||
|
android:fontFamily="@font/nunito_sans" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="#EAEAEA" />
|
||||||
|
|
||||||
|
<!-- Date & Time -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingVertical="14dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Date & Time"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#7A7A7A"
|
||||||
|
android:fontFamily="@font/nunito_sans" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvDateTime"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#7A7A7A"
|
||||||
|
android:fontFamily="@font/nunito_sans" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Remarks (hidden when empty) -->
|
||||||
|
<View
|
||||||
|
android:id="@+id/remarksDivider"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:background="#EAEAEA"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/remarksRow"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingVertical="14dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Remarks"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#7A7A7A"
|
||||||
|
android:fontFamily="@font/nunito_sans" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvRemarks"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:textSize="15sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#7A7A7A"
|
||||||
|
android:fontFamily="@font/nunito_sans" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Bottom zigzag tear: white teeth poking down into the gray fade below -->
|
||||||
|
<ImageView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="20dp"
|
||||||
|
android:scaleType="fitXY"
|
||||||
|
android:src="@drawable/receipt_mfaisa_bottom"
|
||||||
|
android:contentDescription="@null" />
|
||||||
|
|
||||||
|
<!-- Gray→white fade trailing the bottom zigzag -->
|
||||||
|
<View
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="@drawable/bg_mfaisa_receipt_gradient_bottom" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<!-- ════════════════════════════════════════════════════════════════════════ -->
|
||||||
|
<!-- Action buttons — outside renderable area -->
|
||||||
|
<!-- ════════════════════════════════════════════════════════════════════════ -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/btnRow"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:background="?attr/colorSurface"
|
||||||
|
android:paddingHorizontal="12dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:paddingBottom="12dp">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnShare"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginEnd="4dp"
|
||||||
|
android:text="Share"
|
||||||
|
app:icon="@drawable/ic_share" />
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnSave"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:layout_marginStart="4dp"
|
||||||
|
android:text="Save"
|
||||||
|
app:icon="@drawable/ic_save" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -273,18 +273,10 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_marginHorizontal="4dp"
|
android:layout_marginStart="4dp"
|
||||||
android:text="Save"
|
android:text="Save"
|
||||||
app:icon="@drawable/ic_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>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -148,12 +148,15 @@
|
|||||||
app:endIconDrawable="@android:drawable/ic_menu_search"
|
app:endIconDrawable="@android:drawable/ic_menu_search"
|
||||||
app:endIconContentDescription="@string/transfer_lookup_account">
|
app:endIconContentDescription="@string/transfer_lookup_account">
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
<com.google.android.material.textfield.MaterialAutoCompleteTextView
|
||||||
android:id="@+id/etTo"
|
android:id="@+id/etTo"
|
||||||
|
style="@style/Widget.Material3.AutoCompleteTextView.OutlinedBox"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:inputType="textNoSuggestions"
|
android:inputType="textNoSuggestions"
|
||||||
android:maxLines="1" />
|
android:maxLines="1"
|
||||||
|
android:completionThreshold="3"
|
||||||
|
android:dropDownHeight="wrap_content" />
|
||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,19 @@
|
|||||||
android:defaultValue="MIB" />
|
android:defaultValue="MIB" />
|
||||||
</action>
|
</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>
|
||||||
|
|
||||||
<fragment
|
<fragment
|
||||||
|
|||||||
@@ -28,6 +28,11 @@
|
|||||||
<string name="fahipay_totp_code">Authenticator Code (6 digits)</string>
|
<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_totp_hint">Enter the code from your authenticator app</string>
|
||||||
<string name="fahipay_verify">Verify</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">Sign In</string>
|
||||||
<string name="sign_in_desc">Enter your Maldives Islamic Bank credentials.</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>
|
<string name="bml_sign_in_desc">Enter your Bank of Maldives credentials.</string>
|
||||||
|
|||||||
@@ -190,9 +190,9 @@ Known asset mappings:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `C8201`, `C8001`, `C8009` | Mastercard Prepaid | `master_prepaid` |
|
| `C8201`, `C8001`, `C8009` | Mastercard Prepaid | `master_prepaid` |
|
||||||
| `C8205`, `C8005`, `C8008` | Mastercard Prepaid Travel | `master_prepaid_travel` |
|
| `C8205`, `C8005`, `C8008` | Mastercard Prepaid Travel | `master_prepaid_travel` |
|
||||||
| `C8010`, `C8011` | Mastercard Platinum | `master_platinum` |
|
| `C8010`, `C8011`, `C8033` | Mastercard Platinum | `master_platinum` |
|
||||||
| `C8020`, `C8022` | Mastercard Gold | `master_gold` |
|
| `C8020`, `C8022` | Mastercard Gold | `master_gold` |
|
||||||
| `C8030`, `C8033` | Mastercard Business Debit | `master_business_debit` |
|
| `C8030` | Mastercard Business Debit | `master_business_debit` |
|
||||||
| `C8040`, `C8044` | Mastercard World | `master_world` |
|
| `C8040`, `C8044` | Mastercard World | `master_world` |
|
||||||
| `C8101` | Mastercard Masveriyaa | `master_masveriyaa` |
|
| `C8101` | Mastercard Masveriyaa | `master_masveriyaa` |
|
||||||
| `C8102` | Mastercard Odiveriyaa | `master_odiveriyaa` |
|
| `C8102` | Mastercard Odiveriyaa | `master_odiveriyaa` |
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ TLV path: **root tag `35` → sub-tag `20` → sub-sub-tag `01`**
|
|||||||
|
|
||||||
The value at tag `01` is the full `https://pay.bml.com.mv/app/...` URL.
|
The value at tag `01` is the full `https://pay.bml.com.mv/app/...` URL.
|
||||||
|
|
||||||
|
### 3. POS QR (`mv.com.bml.qtr`)
|
||||||
|
|
||||||
|
BML POS terminals emit an EMVCo-style dynamic QR with no tag `26` and supplementary domain
|
||||||
|
`mv.com.bml.qtr`. The same TLV path (`35` → `20` → `01`) holds a bare reference such as
|
||||||
|
`02:215c7b9f15ce4ed28e15697ea976db99` instead of a URL. That bare reference — not the whole payload,
|
||||||
|
which is rejected with code `112` — is what gets Base64-encoded into the payrequest lookup below.
|
||||||
|
The reference does **not** resolve as a `pay.bml.com.mv/app/` short code in a browser; only the API
|
||||||
|
understands it. See
|
||||||
|
[PayMV QR Format → BML POS QR](../thijooree/18-paymv-qr-format.md#bml-pos-qr-mvcombmlqtr).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## PayMV QR Format (TLV)
|
## PayMV QR Format (TLV)
|
||||||
@@ -71,19 +81,29 @@ PayMV QRs (static, PayMV-native) use a decimal TLV encoding (not BER-TLV):
|
|||||||
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/{base64Url}
|
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/{base64Url}
|
||||||
```
|
```
|
||||||
|
|
||||||
`{base64Url}` is the full QR URL (e.g. `https://pay.bml.com.mv/app/...`) base64-encoded with standard encoding (with padding).
|
`{base64Url}` is the lookup key, base64-encoded with standard encoding — the full QR URL
|
||||||
|
(e.g. `https://pay.bml.com.mv/app/...`), or for POS QRs the bare `35` → `20` → `01` reference.
|
||||||
|
BML's own app omits the `=` padding; the padded form resolves as well, so the client matches the app
|
||||||
|
rather than relying on either being required.
|
||||||
|
|
||||||
### Headers
|
### Headers
|
||||||
|
|
||||||
| Header | Value |
|
| Header | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| `accept` | `application/json` — **required in practice**, see below |
|
||||||
| `Authorization` | `Bearer <access_token>` |
|
| `Authorization` | `Bearer <access_token>` |
|
||||||
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||||
| `x-app-version` | `2.1.44.348` |
|
| `x-app-version` | `2.1.44.348` |
|
||||||
|
|
||||||
|
> **`accept: application/json` is not optional.** Handled errors (codes 103, 112, …) come back as
|
||||||
|
> JSON regardless, but when the route throws, the server renders the Internet Banking HTML login
|
||||||
|
> page — under HTTP **200** — instead of a JSON error body. A client without the header then sees a
|
||||||
|
> "successful" HTML response it cannot parse. All BML API requests set it in `bmlApiRequest()`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl --request GET \
|
curl --request GET \
|
||||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/<base64Url>' \
|
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/<base64Url>' \
|
||||||
|
--header 'accept: application/json' \
|
||||||
--header 'Authorization: Bearer <access_token>' \
|
--header 'Authorization: Bearer <access_token>' \
|
||||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||||
--header 'x-app-version: 2.1.44.348'
|
--header 'x-app-version: 2.1.44.348'
|
||||||
@@ -116,6 +136,16 @@ curl --request GET \
|
|||||||
| `amount` | Payment amount (`"0.00"` for static QRS) |
|
| `amount` | Payment amount (`"0.00"` for static QRS) |
|
||||||
| `currency` | Currency code (typically `"MVR"`) |
|
| `currency` | Currency code (typically `"MVR"`) |
|
||||||
|
|
||||||
|
### Failure Responses
|
||||||
|
|
||||||
|
`success: false` comes back with a code and a user-facing message; the client shows BML's own
|
||||||
|
wording and keeps the user on the Transfer screen.
|
||||||
|
|
||||||
|
| Code | Message |
|
||||||
|
|---|---|
|
||||||
|
| `103` | The payment request has expired |
|
||||||
|
| `112` | Unsupported payment link |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Step 2 — Pay (3-Step TOTP Flow)
|
## Step 2 — Pay (3-Step TOTP Flow)
|
||||||
|
|||||||
@@ -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) | **Next →** [QR Merchant Payment](05-qr-pay.md)
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
# QR Merchant Payment ("Smart Pay")
|
||||||
|
|
||||||
|
Pay an Ooredoo M-Faisa merchant by scanning their QR. The QR encodes only a numeric `qrCodeId` (e.g. `1594103440350`) — no URL, no EMV TLV envelope. The flow is three calls and **does not require OTP** (`2FARequired=NONE`).
|
||||||
|
|
||||||
|
> **Currency / pocket constraints:** captures cover MVR→MVR purchases from the user's EMONEY pocket only. The `transactionCurrency` field is taken from the QR lookup; we have not seen a non-MVR variant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Client Server
|
||||||
|
| |
|
||||||
|
| POST /QRCodeUtility/fetchQRCodeById | ← user scanned a QR
|
||||||
|
| formData = { qrCodeId, tenantCode } |
|
||||||
|
|------------------------------------------------>|
|
||||||
|
| [{ success, response:[{ commercialName, |
|
||||||
|
| customerId, mobileNumber, currencyCode, |
|
||||||
|
| txnAmount, status, ... }] }] |
|
||||||
|
|<------------------------------------------------|
|
||||||
|
| |
|
||||||
|
| (show merchant, accept amount + remarks)
|
||||||
|
| |
|
||||||
|
| POST /initiateNewBuy |
|
||||||
|
| formData = { merchantId, mobileNumber, |
|
||||||
|
| sourceDetails, transactionAmount, |
|
||||||
|
| transactionType:"PURCHASE", … } |
|
||||||
|
|------------------------------------------------>|
|
||||||
|
| [{ 2FARequired:"NONE", |
|
||||||
|
| authenticationType:"NONE", |
|
||||||
|
| success:true, |
|
||||||
|
| response:[{ responseObject:{ referenceId, |
|
||||||
|
| chargeDetails, … } }] }] |
|
||||||
|
|<------------------------------------------------|
|
||||||
|
| |
|
||||||
|
| (no OTP — go straight to confirm)
|
||||||
|
| |
|
||||||
|
| POST /confirmNewBuy |
|
||||||
|
| formData = { referenceId } |
|
||||||
|
| transactionAuthDetails = "null" ← literal |
|
||||||
|
|------------------------------------------------>|
|
||||||
|
| [{ success:true, |
|
||||||
|
| message:"Payment Completed Successfully", |
|
||||||
|
| response:[{ responseObject:{ isCompleted, |
|
||||||
|
| balanceInquiryDTO, ... } }] }] |
|
||||||
|
|<------------------------------------------------|
|
||||||
|
```
|
||||||
|
|
||||||
|
All three endpoints carry the standard anti-replay pair (`rndValue` + `csValue`) derived from each request's `formData` JSON — see [01-encryption.md → rndValue / csValue](01-encryption.md#anti-replay-envelope-rndvalue--csvalue).
|
||||||
|
|
||||||
|
Unlike the transfer flow, **every endpoint here returns its envelope as a JSON array** `[{...}]` for both success and error.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1: `QRCodeUtility/fetchQRCodeById` — resolve merchant
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/QRCodeUtility/fetchQRCodeById
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
**Content-Type:** `application/x-www-form-urlencoded`
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| `role` | **`R01`** (the other two endpoints use `RETAIL_SUBSCRIBER` — this one does not) |
|
||||||
|
| `channel` | `C03` |
|
||||||
|
| `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
|
||||||
|
{
|
||||||
|
"qrCodeId": "<numeric id from QR>",
|
||||||
|
"tenantCode": "ooredoo"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response — happy path
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "QRCode fetched Successfully.",
|
||||||
|
"response": [
|
||||||
|
{
|
||||||
|
"mobileNumber": "9609569506", /* merchant's '960' + msisdn */
|
||||||
|
"customerId": "72518", /* used as merchantId in step 2 */
|
||||||
|
"commercialName": "Family Room", /* merchant display name */
|
||||||
|
"qrCodeId": "1594103440350",
|
||||||
|
"qrImageString": "<base64 PNG>", /* unused */
|
||||||
|
"accountNumber": null,
|
||||||
|
"txnAmount": null, /* static QR; dynamic QRs put a number here */
|
||||||
|
"currencyCode": "MVR",
|
||||||
|
"status": "Active",
|
||||||
|
"role": "AGENT",
|
||||||
|
"tenantCode": "ooredoo",
|
||||||
|
"...": "..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
`accountNumber` / `txnAmount` are JSON `null` for **static QRs** (the user chooses the amount). For **dynamic QRs** the server returns the fixed amount in `txnAmount` and the client should lock the amount field.
|
||||||
|
|
||||||
|
The `mobileNumber` field already includes the `960` country prefix.
|
||||||
|
|
||||||
|
### Response — QR not found / inactive
|
||||||
|
|
||||||
|
```json
|
||||||
|
[{ "success": false, "message": "QRCode not found." }]
|
||||||
|
```
|
||||||
|
|
||||||
|
The client also rejects entries with `status != "Active"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2: `initiateNewBuy` — initiate purchase (no OTP triggered)
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/initiateNewBuy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
|
||||||
|
**Content-Type:** `application/x-www-form-urlencoded`
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| `role` | `RETAIL_SUBSCRIBER` |
|
||||||
|
| `channel` | `C03` (top-level differs from inner `formData.channel`, which is `SubscriberApp`) |
|
||||||
|
| `loginExchangeKey` | From login |
|
||||||
|
| `rndValue` / `csValue` | Standard anti-replay (derived from the `formData` below) |
|
||||||
|
| `formData` | JSON below |
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channel": "SubscriberApp",
|
||||||
|
"commodityType": "WALLET",
|
||||||
|
"description": "<remarks>", /* free text, may be empty */
|
||||||
|
"merchantId": "<customerId from step 1>",
|
||||||
|
"mobileNumber": "<merchant '960' msisdn from step 1>",
|
||||||
|
"sourceDetails": {
|
||||||
|
"MDNId": "960<myMsisdn>", /* PLAINTEXT — '960' + my phone */
|
||||||
|
"actorRoleType": "RETAIL_SUBSCRIBER",
|
||||||
|
"pocketId": "<my source pocket id>" /* EMONEY pocket from login */
|
||||||
|
},
|
||||||
|
"transactionAmount": "<amount>", /* string, e.g. "7.56" */
|
||||||
|
"transactionCurrency": "MVR",
|
||||||
|
"transactionType": "PURCHASE"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Unlike the transfer flow's `initiateFTRequest`, this endpoint does **not** take an `identifier` header field or `tPin`.
|
||||||
|
|
||||||
|
### Response — happy path
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"2FARequired": "NONE", /* ← the key difference */
|
||||||
|
"authenticationType": "NONE",
|
||||||
|
"success": true,
|
||||||
|
"message": "Purchase Initiated Successfully",
|
||||||
|
"response": [
|
||||||
|
{
|
||||||
|
"requestObject": { "...": "..." },
|
||||||
|
"responseObject": {
|
||||||
|
"referenceId": "685011023630",
|
||||||
|
"transactionAmount": { "amount": 7.56, "currencyCode": "MVR" },
|
||||||
|
"netAmount": { "amount": 7.56, "currencyCode": "MVR" },
|
||||||
|
"chargeDetailsDTO": { "totalFeesInTenantCurrency": { "amount": 0.0, "...": "..." }, "...": "..." },
|
||||||
|
"isCompleted": false,
|
||||||
|
"authenticationType":"NONE",
|
||||||
|
"...": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Cache `referenceId` for step 3. No SMS is sent — proceed straight to confirm.
|
||||||
|
|
||||||
|
> **Defensive check:** the Thijooree client throws if it ever sees `2FARequired != "NONE"` on this endpoint so a no-op confirm can't silently complete. If Ooredoo ever turns 2FA on for QR pay, you'll see a clear error instead of a partial transaction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 3: `confirmNewBuy` — settle purchase
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web/confirmNewBuy
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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` | **literal string `"null"`** (not a JSON null, not an empty string — the captured request sends the four-character string `null`) |
|
||||||
|
|
||||||
|
### Response — happy path
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Payment Completed Successfully",
|
||||||
|
"response": [
|
||||||
|
{
|
||||||
|
"responseObject": {
|
||||||
|
"isCompleted": true,
|
||||||
|
"balanceInquiryDTO": {
|
||||||
|
"currencyCode": "MVR",
|
||||||
|
"pocketAmount": 92.85,
|
||||||
|
"pocketId": "<source pocket id>",
|
||||||
|
"pocketBalanceMap": { "...": "..." }
|
||||||
|
},
|
||||||
|
"status": { "replyCode": 0.0 },
|
||||||
|
"...": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Optional: `save/smart-pay-recipient` — bookkeeping
|
||||||
|
|
||||||
|
After a successful confirm the official Ooredoo app saves the merchant to a server-side "recent recipients" list:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://superapp.ooredoo.mv/api/mfaisaa-bff/save/smart-pay-recipient
|
||||||
|
```
|
||||||
|
|
||||||
|
This is **not** required for the payment itself — the transfer is final once `confirmNewBuy` succeeds. Thijooree skips it; the merchant is kept in the local picker `RecentsCache` under an `mfaisaqr:<qrCodeId>` synthetic accountNumber (mirroring the BML QR `bmlqr:` scheme).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **← Back to** [Transfer Money](04-transfer.md) | [README](README.md)
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# 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 |
|
||||||
|
| 5 | [QR Merchant Payment](05-qr-pay.md) | Three-step "smart pay" scan-to-merchant: QR lookup → initiate → confirm. **No OTP** (`2FARequired=NONE`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **Next →** [Encryption & Anti-Replay](01-encryption.md)
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
> **This flow no longer lives in a dedicated fragment.** `BmlQrPayFragment.kt` still exists as a source file but is unreachable — no callers, no nav graph entry, no intent action routes here. The actual BML gateway QR flow runs inside `TransferFragment` via `TransferFragment.newInstanceFromBmlQr(qrUrl, fromAccountNumber)`. See [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md).
|
> **This flow no longer lives in a dedicated fragment.** `BmlQrPayFragment.kt` still exists as a source file but is unreachable — no callers, no nav graph entry, no intent action routes here. The actual BML gateway QR flow runs inside `TransferFragment` via `TransferFragment.newInstanceFromBmlQr(qrUrl, fromAccountNumber)`. See [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md).
|
||||||
|
|
||||||
The on-the-wire payment protocol is unchanged — see [BML QR Payment API](../bmlapi/13-qr-payment.md) for the 3-step TOTP flow (`approve` → `channel: token` → `otp`).
|
The on-the-wire payment protocol is unchanged — see [BML QR Payment API](../bmlapi/13-qr-payment.md) for the 3-step TOTP flow (`approve` → `channel: token` → `otp`), and [Transfer Flows — BML QR Merchant Payment Flow](20-transfer-flows.md#bml-qr-merchant-payment-flow) for the three QR sub-modes the live path handles.
|
||||||
|
|
||||||
|
Two differences remain in the stale fragment, should it ever be revived: it calls `lookupPayRequest()` with the scanned URL only (no `PaymvQrParser.bmlPayRequestKey()`, so POS QRs would not resolve), and it swallows every lookup error into the hardcoded `bml_qr_lookup_failed` toast instead of surfacing BML's own message.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -28,12 +28,13 @@ Tags and lengths are always exactly 2 decimal digits. Fields are concatenated di
|
|||||||
| `00` | Format indicator | Always `"01"` |
|
| `00` | Format indicator | Always `"01"` |
|
||||||
| `01` | Point-of-initiation method | `"11"` = static QR, `"12"` = dynamic QR |
|
| `01` | Point-of-initiation method | `"11"` = static QR, `"12"` = dynamic QR |
|
||||||
| `26` | Merchant account information | Container — see sub-tags below |
|
| `26` | Merchant account information | Container — see sub-tags below |
|
||||||
| `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs only |
|
| `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs and in BML POS QRs |
|
||||||
| `52` | Merchant category code | `"0000"` (generic) |
|
| `52` | Merchant category code | `"0000"` (generic) |
|
||||||
| `53` | Transaction currency | `"462"` = MVR (ISO 4217 numeric) |
|
| `53` | Transaction currency | `"462"` = MVR (ISO 4217 numeric) |
|
||||||
| `54` | Transaction amount | Decimal string (e.g. `"1.50"`); absent for open-amount QRs |
|
| `54` | Transaction amount | Decimal string (e.g. `"1.50"`); absent for open-amount QRs |
|
||||||
| `58` | Country code | `"MV"` |
|
| `58` | Country code | `"MV"` |
|
||||||
| `59` | Merchant / recipient name | Max 25 characters |
|
| `59` | Merchant / recipient name | Max 25 characters |
|
||||||
|
| `60` | Merchant city / store code | BML POS QRs only |
|
||||||
| `62` | Additional data field | Container — see sub-tags below |
|
| `62` | Additional data field | Container — see sub-tags below |
|
||||||
| `63` | CRC | `6304` prefix + 4-char hex checksum — always last |
|
| `63` | CRC | `6304` prefix + 4-char hex checksum — always last |
|
||||||
| `80` | Supplementary data | Container — timestamp and domain |
|
| `80` | Supplementary data | Container — timestamp and domain |
|
||||||
@@ -153,6 +154,67 @@ The value at sub-sub-tag `01` is a full `https://pay.bml.com.mv/app/...` URL. Ex
|
|||||||
|
|
||||||
Plain BML QR codes (not combined) start with `https://pay.bml.com.mv/app/` directly.
|
Plain BML QR codes (not combined) start with `https://pay.bml.com.mv/app/` directly.
|
||||||
|
|
||||||
|
`PaymvQrParser.bmlQrPayTarget()` covers all of these: it returns the URL for plain URL QRs and for
|
||||||
|
combined QRs, the whole EMV payload for POS QRs (below, recognised by the `mv.com.bml.qtr`
|
||||||
|
supplementary domain), and null for everything else — PayMV QRs keep falling through to `parse()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BML POS QR (`mv.com.bml.qtr`)
|
||||||
|
|
||||||
|
BML POS terminals emit a third shape — an EMVCo-style dynamic QR whose supplementary data domain
|
||||||
|
(tag `80` → `00`) is `mv.com.bml.qtr` and which has **no tag `26`**, so there is no PayMV account
|
||||||
|
number to transfer to. The same `35` → `20` container is used as in combined QRs, but sub-tag `01`
|
||||||
|
holds a bare reference instead of a URL.
|
||||||
|
|
||||||
|
Example (CRC verified, same CRC-16/CCITT-FALSE as above):
|
||||||
|
|
||||||
|
```
|
||||||
|
00020101021235752071000202013502:215c7b9f15ce4ed28e15697ea976db9902109809724081030874009538520400005303462540436005802MV5911BEST BANANA6006LD044262220510dtyams497d0804POPE80470014mv.com.bml.qtr01252026-09-21T13:33:22.00000630443FA
|
||||||
|
```
|
||||||
|
|
||||||
|
| TLV path | Value | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `00` | `01` | Format indicator |
|
||||||
|
| `01` | `12` | Dynamic QR |
|
||||||
|
| `35`→`20`→`00` | `02` | Version / format of the container (combined QRs use the URL form) |
|
||||||
|
| `35`→`20`→`01` | `02:215c7b9f15ce4ed28e15697ea976db99` | Payment reference — `<type>:<32 hex>` |
|
||||||
|
| `35`→`20`→`02` | `9809724081` | Merchant identifier (10 digits) |
|
||||||
|
| `35`→`20`→`03` | `74009538` | Terminal identifier (8 digits) |
|
||||||
|
| `52` | `0000` | MCC |
|
||||||
|
| `53` | `462` | MVR |
|
||||||
|
| `54` | `3600` | Amount — **no decimal point**, unlike PayMV's `"1.50"` |
|
||||||
|
| `58` / `59` / `60` | `MV` / `BEST BANANA` / `LD0442` | Country, merchant name, merchant city/store code |
|
||||||
|
| `62`→`05` | `dtyams497d` | Reference / bill number |
|
||||||
|
| `62`→`08` | `POPE` | Purpose / terminal label |
|
||||||
|
| `80`→`00` | `mv.com.bml.qtr` | Domain — identifies the POS format |
|
||||||
|
| `80`→`01` | `2026-09-21T13:33:22.00000` | Timestamp |
|
||||||
|
|
||||||
|
### Pay-Request Lookup Key
|
||||||
|
|
||||||
|
The lookup key is the **bare reference**, Base64-encoded without padding:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET .../walletpayments/payrequest/MDI6MjE1YzdiOWYxNWNlNGVkMjhlMTU2OTdlYTk3NmRiOTk
|
||||||
|
```
|
||||||
|
|
||||||
|
BML's own app omits the `=` padding, so `BmlQrPayClient.lookupPayRequest()` encodes with
|
||||||
|
`NO_WRAP or NO_PADDING` for every QR type; the padded form resolves too. What the request *must*
|
||||||
|
carry is `accept: application/json` — see
|
||||||
|
[QR Payment → Step 1 headers](../bmlapi/13-qr-payment.md#headers).
|
||||||
|
|
||||||
|
Confirmed against the live API (the example QR had already expired, so BML answered `103` rather
|
||||||
|
than with merchant details — but `103` means the reference itself resolved):
|
||||||
|
|
||||||
|
| Key tried | Response |
|
||||||
|
|---|---|
|
||||||
|
| `02:215c7b9f15ce4ed28e15697ea976db99` | `103` — "The payment request has expired" ✅ recognised |
|
||||||
|
| the whole EMV payload | `112` — "Unsupported payment link" ❌ |
|
||||||
|
| `https://pay.bml.com.mv/app/02:215c…` | `103` — recognised too; the host itself 400s on that path, so the backend must strip the prefix |
|
||||||
|
|
||||||
|
`PaymvQrParser.bmlPayRequestKey()` returns the URL for URL QRs and the reference for POS QRs, so
|
||||||
|
exactly one request is made either way.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Example Payload
|
## Example Payload
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ The transfer screen (`TransferFragment`) handles all outgoing payments across MI
|
|||||||
| `newInstance(accountNumber, displayName, subtitle, colorHex, imageHash)` | Pre-fills the "To" card from a contact, recents pick, or About → Donate |
|
| `newInstance(accountNumber, displayName, subtitle, colorHex, imageHash)` | Pre-fills the "To" card from a contact, recents pick, or About → Donate |
|
||||||
| `newInstanceFrom(account: BankAccount)` | Pre-selects the given account in the "From" dropdown |
|
| `newInstanceFrom(account: BankAccount)` | Pre-selects the given account in the "From" dropdown |
|
||||||
| `newInstanceFromQr(accountNumber, displayName, amount, remarks, fromAccountNumber?)` | Pre-fills recipient + optional amount/remarks from a PayMV QR scan |
|
| `newInstanceFromQr(accountNumber, displayName, amount, remarks, fromAccountNumber?)` | Pre-fills recipient + optional amount/remarks from a PayMV QR scan |
|
||||||
| `newInstanceFromBmlQr(qrUrl, fromAccountNumber?)` | BML card/gateway QR merchant payment mode — locks recipient, may pre-fill amount |
|
| `newInstanceFromBmlQr(qrUrl, fromAccountNumber?)` | BML card/gateway/POS QR merchant payment mode — locks recipient, may pre-fill amount |
|
||||||
| `newInstanceWithAutoScan()` | Opens the [QR scanner](25-qr-scanner.md) immediately on load |
|
| `newInstanceWithAutoScan()` | Opens the [QR scanner](25-qr-scanner.md) immediately on load |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -215,22 +215,29 @@ If channel fetch fails or returns empty, the flow is aborted and the form is re-
|
|||||||
|
|
||||||
## BML QR Merchant Payment Flow
|
## BML QR Merchant Payment Flow
|
||||||
|
|
||||||
Triggered when the transfer screen is opened via `newInstanceFromBmlQr()` or when a BML ebanking/pay.bml URL is scanned from the QR scanner.
|
Triggered when the transfer screen is opened via `newInstanceFromBmlQr()`, which every scanner caller reaches through `PaymvQrParser.bmlQrPayTarget(raw)` — it returns the value to pay with, or null for a QR that is not BML's.
|
||||||
|
|
||||||
Two sub-modes:
|
Three sub-modes:
|
||||||
|
|
||||||
| Mode | Trigger | Extra step |
|
| Mode | `bmlQrPayTarget()` returns | Extra step |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Static card QR | URL starts with `https://ebanking.bankofmaldives.com.mv/qrpay/` | None |
|
| Static card QR | the QR text, when it starts with `https://ebanking.bankofmaldives.com.mv/qrpay/` | None |
|
||||||
| Gateway QR | URL starts with `https://pay.bml.com.mv/app/` | `BmlQrPayClient.preInitiatePayment()` required before initiate |
|
| Gateway QR | the QR text, or the URL at TLV `35`→`20`→`01` in a combined EMV QR | `BmlQrPayClient.preInitiatePayment()` required before initiate |
|
||||||
|
| POS QR | the whole EMV payload, for QRs whose tag `80`→`00` domain is `mv.com.bml.qtr` | Treated as a gateway QR — see the note below |
|
||||||
|
|
||||||
|
`BmlTransferHandler.lookupQrMerchant()` passes that value through `PaymvQrParser.bmlPayRequestKey()`, which hands the URL to the lookup for URL QRs and the bare `35`→`20`→`01` reference for POS QRs. See [PayMV QR Format — BML POS QR](18-paymv-qr-format.md#bml-pos-qr-mvcombmlqtr).
|
||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
1. `lookupBmlQrMerchant()` — fetches merchant info via `BmlQrPayClient.lookupPayRequest()`. Locks the "To" row.
|
1. `lookupQrMerchant()` — fetches merchant info via `BmlQrPayClient.lookupPayRequest()`. Locks the "To" row.
|
||||||
2. For dynamic QRs (`info.amount > 0`), pre-fills the amount and locks the amount field.
|
2. For dynamic QRs (`info.amount > 0`), pre-fills the amount and locks the amount field.
|
||||||
3. Remarks field is locked (not applicable for merchant payments).
|
3. Remarks field is locked (not applicable for merchant payments).
|
||||||
4. On confirm: TOTP is generated, then `initiatePayment()` → (for gateway QR: `preInitiatePayment()` first) → `confirmPayment()` with a fresh TOTP.
|
4. On confirm: TOTP is generated, then `initiatePayment()` → (for gateway QR: `preInitiatePayment()` first) → `confirmPayment()` with a fresh TOTP.
|
||||||
5. On success: a success dialog is shown (no receipt saved). Back-press returns to previous screen.
|
5. On success: a success dialog is shown (no receipt saved). Back-press returns to previous screen.
|
||||||
|
|
||||||
|
**Lookup failure:** the user stays on the Transfer screen with the "To" row restored via `resetToFieldVisibility()` — the screen is no longer popped. When BML answered with `success: false`, its own wording is toasted (`BmlQrPayLookupException.message`, e.g. "The payment request has expired"); network, empty and non-JSON responses fall back to the `bml_qr_lookup_failed` string.
|
||||||
|
|
||||||
|
> **Unverified:** POS QRs are treated as gateway QRs (pre-initiate before initiate) because they carry a preset amount. No POS payment has been completed end-to-end yet — the reference captured for testing had already expired.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Transfer Button Enable Conditions
|
## Transfer Button Enable Conditions
|
||||||
@@ -238,7 +245,7 @@ Flow:
|
|||||||
The transfer button is only enabled when all of the following are true:
|
The transfer button is only enabled when all of the following are true:
|
||||||
|
|
||||||
- A source account is selected
|
- A source account is selected
|
||||||
- A recipient is resolved (`resolvedAccountNumber` not blank, or `bmlQrInfo` is set)
|
- A recipient is resolved (`resolvedAccountNumber` not blank, or the BML handler's `qrInfo` is set)
|
||||||
- Amount is greater than `0`
|
- Amount is greater than `0`
|
||||||
- No connectivity error for `NO_INTERNET` or for the source bank
|
- No connectivity error for `NO_INTERNET` or for the source bank
|
||||||
|
|
||||||
|
|||||||
@@ -57,10 +57,10 @@ Each caller registers an `ActivityResultContracts.StartActivityForResult` launch
|
|||||||
|
|
||||||
| Caller | Result handling |
|
| Caller | Result handling |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [TransferFragment](07-transfer.md) | Routes PayMV / BML URL via `PaymvQrParser` + `extractBmlGatewayUrl` |
|
| [TransferFragment](07-transfer.md) | `PaymvQrParser.bmlQrPayTarget()` first (URL, combined and POS QRs → BML QR pay), then M-Faisa numeric ids, then `PaymvQrParser.parse()` |
|
||||||
| [PayMvQrFragment](11-paymv-qr-screen.md) | Generation only — does not call the scanner directly |
|
| [PayMvQrFragment](11-paymv-qr-screen.md) | Generation only — does not call the scanner directly |
|
||||||
| `CredentialsFragment` (login) | `OtpauthParser.parse(raw)` → fills `etOtpSeed` or shows a chooser for multi-entry QRs |
|
| `CredentialsFragment` (login) | `OtpauthParser.parse(raw)` → fills `etOtpSeed` or shows a chooser for multi-entry QRs |
|
||||||
| [DashboardFragment](21-dashboard.md) | BML URL → BML QR pay; PayMV → pre-fill Transfer; otherwise toast |
|
| [DashboardFragment](21-dashboard.md) | BML QR (URL or POS) → BML QR pay; PayMV → pre-fill Transfer; otherwise toast |
|
||||||
| [CardsFragment](22-cards.md) | Same routing as Dashboard, scoped to the active BML card |
|
| [CardsFragment](22-cards.md) | Same routing as Dashboard, scoped to the active BML card |
|
||||||
|
|
||||||
### Share-to-Scan Fast Path
|
### Share-to-Scan Fast Path
|
||||||
|
|||||||
Reference in New Issue
Block a user