This commit is contained in:
@@ -8,6 +8,7 @@ import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.bml.BmlProfile
|
||||
import sh.sar.basedbank.api.bml.BmlSession
|
||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaSession
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibProfile
|
||||
@@ -48,6 +49,10 @@ class BasedBankApp : Application() {
|
||||
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
|
||||
var fahipayAccounts: List<BankAccount> = emptyList()
|
||||
|
||||
/** Active M-Faisa sessions keyed by loginId (= msisdn). */
|
||||
val mfaisaSessions: MutableMap<String, MfaisaSession> = mutableMapOf()
|
||||
var mfaisaAccounts: List<BankAccount> = emptyList()
|
||||
|
||||
// ─── MIB helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the MIB session for the given account (matched via loginTag). */
|
||||
@@ -110,6 +115,26 @@ class BasedBankApp : Application() {
|
||||
fun fahipaySessionFor(account: BankAccount): FahipaySession? =
|
||||
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
|
||||
|
||||
// ─── M-Faisa helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the M-Faisa session for the given account (matched via loginTag = "mfaisa_${msisdn}"). */
|
||||
fun mfaisaSessionFor(account: BankAccount): MfaisaSession? =
|
||||
mfaisaSessions[account.loginTag.removePrefix("mfaisa_")]
|
||||
|
||||
/**
|
||||
* Re-runs `fetchSubscriber` + `doMobileLogin` using the saved credentials for [loginId] and
|
||||
* replaces the cached session. Call this after catching [sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException].
|
||||
* Returns the fresh session, or null if no credentials are saved for that login.
|
||||
*/
|
||||
fun refreshMfaisaSession(loginId: String): MfaisaSession? {
|
||||
val creds = CredentialStore(this).loadMfaisaCredentials(loginId) ?: return null
|
||||
val flow = sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow(this)
|
||||
flow.fetchSubscriber(creds.msisdn)
|
||||
val result = flow.doMobileLogin(creds.msisdn, creds.pin)
|
||||
mfaisaSessions[loginId] = result.session
|
||||
return result.session
|
||||
}
|
||||
|
||||
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
|
||||
val mibMutex = Mutex()
|
||||
|
||||
|
||||
@@ -279,7 +279,8 @@ class LockActivity : AppCompatActivity() {
|
||||
finish()
|
||||
} else {
|
||||
val store = CredentialStore(this)
|
||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() ||
|
||||
store.hasFahipayCredentials() || store.hasMfaisaCredentials()
|
||||
if (!hasCredentials) {
|
||||
startActivity(Intent(this, sh.sar.basedbank.ui.login.LoginActivity::class.java))
|
||||
finish()
|
||||
|
||||
@@ -41,7 +41,8 @@ class MainActivity : AppCompatActivity() {
|
||||
val onboardingDone = prefs.getBoolean("onboarding_done", false)
|
||||
val securitySet = prefs.getString("security_method", null) != null
|
||||
val store = CredentialStore(this)
|
||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() ||
|
||||
store.hasFahipayCredentials() || store.hasMfaisaCredentials()
|
||||
|
||||
// Image shared via "Scan to Pay" — decode QR here while we still hold the URI permission
|
||||
val shareQrText: String? = if (intent?.action == Intent.ACTION_SEND &&
|
||||
|
||||
@@ -0,0 +1,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,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)
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||
|
||||
// Show default account toggle only for non-card accounts
|
||||
// Show default account toggle only for non-card, non-M-Faisa accounts.
|
||||
// M-Faisa pockets (including PayPal) cannot be set as the default transfer/QR account.
|
||||
val isCard = AccountListParser.from(account)?.isCard ?: false
|
||||
if (!isCard) {
|
||||
if (!isCard && account.bank != "MFAISA") {
|
||||
val store = CredentialStore(requireContext())
|
||||
adapter.showDefaultToggle = true
|
||||
adapter.isDefaultAccount = store.getDefaultAccountNumber() == account.accountNumber
|
||||
|
||||
@@ -75,10 +75,12 @@ class AccountsAdapter(
|
||||
"BML" -> "Bank of Maldives"
|
||||
"FAHIPAY" -> "Fahipay"
|
||||
"MIB" -> "Maldives Islamic Bank"
|
||||
"MFAISA" -> if (account.profileType == "MFAISA_PAYPAL") "PayPal · M-Faisa" else "M-Faisa"
|
||||
else -> account.bank
|
||||
}
|
||||
val profileLabel = when (account.bank) {
|
||||
"MIB" -> account.productCode.ifBlank { account.profileName }
|
||||
"MFAISA" -> "" // bank-level grouping is already specific (M-Faisa / PayPal · M-Faisa)
|
||||
else -> account.profileName
|
||||
}
|
||||
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
|
||||
@@ -144,6 +146,7 @@ class AccountsAdapter(
|
||||
"BML" -> R.drawable.bml_logo_vector
|
||||
"FAHIPAY" -> R.drawable.fahipay_logo
|
||||
"MIB" -> R.drawable.mib_logo
|
||||
"MFAISA" -> R.drawable.ooredoo_logo
|
||||
else -> null
|
||||
}
|
||||
if (staticLogo != null) binding.ivBankLogo.setImageResource(staticLogo)
|
||||
|
||||
@@ -202,9 +202,9 @@ class HomeActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
// Load data
|
||||
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
|
||||
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty() || app.mfaisaAccounts.isNotEmpty()) {
|
||||
// Came from fresh manual login — accounts ready, rest fetched in background
|
||||
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts
|
||||
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts + app.mfaisaAccounts
|
||||
viewModel.accounts.value = merged.filterVisibleAccounts()
|
||||
if (app.mibAccounts.isNotEmpty()) AccountCache.save(this, app.mibAccounts)
|
||||
if (app.bmlAccounts.isNotEmpty()) {
|
||||
@@ -215,6 +215,10 @@ class HomeActivity : AppCompatActivity() {
|
||||
val byLoginId = app.fahipayAccounts.groupBy { it.loginTag.removePrefix("fahipay_") }
|
||||
byLoginId.forEach { (loginId, accs) -> AccountCache.saveFahipay(this, loginId, accs) }
|
||||
}
|
||||
if (app.mfaisaAccounts.isNotEmpty()) {
|
||||
val byLoginId = app.mfaisaAccounts.groupBy { it.loginTag.removePrefix("mfaisa_") }
|
||||
byLoginId.forEach { (loginId, accs) -> AccountCache.saveMfaisa(this, loginId, accs) }
|
||||
}
|
||||
|
||||
val cachedCards = CardsCache.load(this)
|
||||
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
||||
@@ -238,7 +242,8 @@ class HomeActivity : AppCompatActivity() {
|
||||
val cachedMib = AccountCache.load(this)
|
||||
val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds())
|
||||
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
|
||||
val merged = cachedMib + cachedBml + cachedFahipay
|
||||
val cachedMfaisa = AccountCache.loadMfaisa(this, store.getMfaisaLoginIds())
|
||||
val merged = cachedMib + cachedBml + cachedFahipay + cachedMfaisa
|
||||
if (merged.isNotEmpty()) viewModel.accounts.value = merged
|
||||
val cachedCards = CardsCache.load(this)
|
||||
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
||||
@@ -695,17 +700,20 @@ fun applyNavLabelVisibility() {
|
||||
val mibLoginIds = store.getMibLoginIds()
|
||||
val bmlLoginIds = store.getBmlLoginIds()
|
||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) {
|
||||
val mfaisaLoginIds = store.getMfaisaLoginIds()
|
||||
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty() && mfaisaLoginIds.isEmpty()) {
|
||||
startActivity(Intent(this, LoginActivity::class.java))
|
||||
finish()
|
||||
return
|
||||
}
|
||||
// Immediately drop accounts for logged-out logins from the displayed list
|
||||
val current = viewModel.accounts.value ?: emptyList()
|
||||
val mfaisaLoginIdsForFilter = store.getMfaisaLoginIds()
|
||||
viewModel.accounts.value = current.filter { acc ->
|
||||
if (acc.bank == "MIB") return@filter acc.loginTag.removePrefix("mib_") in mibLoginIds
|
||||
if (acc.bank == "BML") return@filter acc.loginTag.removePrefix("bml_") in bmlLoginIds
|
||||
if (acc.bank == "FAHIPAY") return@filter acc.loginTag.removePrefix("fahipay_") in fahipayLoginIds
|
||||
if (acc.bank == "MFAISA") return@filter acc.loginTag.removePrefix("mfaisa_") in mfaisaLoginIdsForFilter
|
||||
true
|
||||
}
|
||||
autoRefresh(store)
|
||||
@@ -724,7 +732,8 @@ fun applyNavLabelVisibility() {
|
||||
val mibLoginIds = store.getMibLoginIds()
|
||||
val bmlLoginIds = store.getBmlLoginIds()
|
||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) return
|
||||
val mfaisaLoginIds = store.getMfaisaLoginIds()
|
||||
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty() && mfaisaLoginIds.isEmpty()) return
|
||||
binding.refreshIndicator.visibility = View.VISIBLE
|
||||
hideConnectivityBanner()
|
||||
|
||||
@@ -898,17 +907,58 @@ fun applyNavLabelVisibility() {
|
||||
}
|
||||
}
|
||||
|
||||
// One async job per M-Faisa login, all run in parallel.
|
||||
// M-Faisa has no session refresh — sessions expire after ~240s — so we re-login each refresh.
|
||||
val mfaisaJobs = mfaisaLoginIds.mapNotNull { loginId ->
|
||||
val creds = store.loadMfaisaCredentials(loginId) ?: return@mapNotNull null
|
||||
loginId to async(Dispatchers.IO) {
|
||||
val loginTag = "mfaisa_$loginId"
|
||||
val flow = sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow(this@HomeActivity)
|
||||
try {
|
||||
flow.fetchSubscriber(creds.msisdn)
|
||||
val result = flow.doMobileLogin(creds.msisdn, creds.pin)
|
||||
val accounts = sh.sar.basedbank.api.mfaisa.MfaisaAccountClient.buildAccounts(result, loginTag)
|
||||
val app = application as BasedBankApp
|
||||
app.mfaisaSessions[loginId] = result.session
|
||||
store.saveMfaisaUserProfile(
|
||||
loginId,
|
||||
CredentialStore.MfaisaUserProfile(
|
||||
name = result.profile.name,
|
||||
email = result.profile.email,
|
||||
mdnId = result.profile.mdnId,
|
||||
subscriberId = result.profile.subscriberId,
|
||||
walletId = result.profile.walletId,
|
||||
roleId = result.profile.roleId,
|
||||
offerId = result.profile.offerId
|
||||
)
|
||||
)
|
||||
AccountCache.saveMfaisa(this@HomeActivity, loginId, accounts)
|
||||
accounts
|
||||
} catch (e: java.io.IOException) {
|
||||
refreshErrors.add("NO_INTERNET")
|
||||
AccountCache.loadMfaisa(this@HomeActivity, loginId)
|
||||
} catch (e: BankServerException) {
|
||||
refreshErrors.add("SERVER:${e.bankName}")
|
||||
AccountCache.loadMfaisa(this@HomeActivity, loginId)
|
||||
} catch (_: Exception) {
|
||||
AccountCache.loadMfaisa(this@HomeActivity, loginId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() }
|
||||
val mibAccounts = mibResults.flatMap { it.second }
|
||||
val bmlAccounts = bmlJobs.flatMap { (_, job) -> job.await() }
|
||||
val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() }
|
||||
val mfaisaAccounts = mfaisaJobs.flatMap { (_, job) -> job.await() }
|
||||
|
||||
val app = application as BasedBankApp
|
||||
app.mibAccounts = mibAccounts
|
||||
AccountCache.save(this@HomeActivity, mibAccounts)
|
||||
app.bmlAccounts = bmlAccounts
|
||||
app.fahipayAccounts = fahipayAccounts
|
||||
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts())
|
||||
app.mfaisaAccounts = mfaisaAccounts
|
||||
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts + mfaisaAccounts).filterVisibleAccounts())
|
||||
binding.refreshIndicator.visibility = View.GONE
|
||||
|
||||
val noInternet = refreshErrors.any { it == "NO_INTERNET" }
|
||||
@@ -955,6 +1005,11 @@ fun applyNavLabelVisibility() {
|
||||
val hidden = store.getHiddenBmlProfileIds(loginId)
|
||||
hidden.isEmpty() || acc.profileId !in hidden
|
||||
}
|
||||
"MFAISA" -> {
|
||||
val loginId = acc.loginTag.removePrefix("mfaisa_")
|
||||
val hidden = store.getHiddenMfaisaPocketIds(loginId)
|
||||
hidden.isEmpty() || acc.accountNumber !in hidden
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,8 +368,9 @@ class SettingsLoginsFragment : Fragment() {
|
||||
val mibLoginIds = store.getMibLoginIds()
|
||||
val bmlLoginIds = store.getBmlLoginIds()
|
||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||
val mfaisaLoginIds = store.getMfaisaLoginIds()
|
||||
|
||||
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
|
||||
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty() || mfaisaLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
|
||||
|
||||
for (loginId in mibLoginIds) {
|
||||
val profile = store.loadMibUserProfile(loginId)
|
||||
@@ -396,6 +397,14 @@ class SettingsLoginsFragment : Fragment() {
|
||||
showFahipayLoginDetails(store, loginId, profile)
|
||||
}
|
||||
}
|
||||
|
||||
for (loginId in mfaisaLoginIds) {
|
||||
val profile = store.loadMfaisaUserProfile(loginId)
|
||||
val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name)
|
||||
addLoginRow(container, R.drawable.ooredoo_logo, displayName) {
|
||||
showMfaisaLoginDetails(store, loginId, profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addLoginRow(container: LinearLayout, logoRes: Int, displayName: String, onClick: () -> Unit) {
|
||||
@@ -1065,6 +1074,152 @@ class SettingsLoginsFragment : Fragment() {
|
||||
buildLoginsSection()
|
||||
}
|
||||
|
||||
private fun showMfaisaLoginDetails(
|
||||
store: CredentialStore,
|
||||
loginId: String,
|
||||
profile: CredentialStore.MfaisaUserProfile?
|
||||
) {
|
||||
val ctx = requireContext()
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val masked = "••••••"
|
||||
|
||||
val pockets = sh.sar.basedbank.util.AccountCache.loadMfaisa(ctx, loginId)
|
||||
val hidden = store.getHiddenMfaisaPocketIds(loginId).toMutableSet()
|
||||
val originalHidden = hidden.toSet()
|
||||
|
||||
// The user-visible "profiles" are: M-Faisa (every non-PayPal pocket) and PayPal (if linked).
|
||||
// Each toggle covers the set of pocket account numbers that belong to that profile.
|
||||
data class MfaisaProfileRow(val label: String, val pocketIds: Set<String>)
|
||||
val mfaisaPockets = pockets.filter { it.profileType != "MFAISA_PAYPAL" }
|
||||
val paypalPockets = pockets.filter { it.profileType == "MFAISA_PAYPAL" }
|
||||
val profileRows = buildList {
|
||||
if (mfaisaPockets.isNotEmpty()) {
|
||||
add(MfaisaProfileRow("M-Faisa", mfaisaPockets.map { it.accountNumber }.toSet()))
|
||||
}
|
||||
if (paypalPockets.isNotEmpty()) {
|
||||
add(MfaisaProfileRow("PayPal", paypalPockets.map { it.accountNumber }.toSet()))
|
||||
}
|
||||
}
|
||||
|
||||
val scroll = android.widget.ScrollView(ctx)
|
||||
val container = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
val pad = (16 * dp).toInt()
|
||||
setPadding(pad, (8 * dp).toInt(), pad, pad)
|
||||
}
|
||||
scroll.addView(container)
|
||||
|
||||
listOfNotNull(
|
||||
profile?.name?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
|
||||
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: ${if (hide) masked else it}" },
|
||||
profile?.mdnId?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: ${if (hide) masked else it}" }
|
||||
).forEach { line ->
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = line
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
bottomMargin = (4 * dp).toInt()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (profileRows.isNotEmpty()) {
|
||||
if (profile != null) {
|
||||
container.addView(View(ctx).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
|
||||
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
|
||||
}
|
||||
setBackgroundColor(0x1F000000)
|
||||
})
|
||||
}
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = getString(R.string.login_detail_profiles)
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||
it.bottomMargin = (8 * dp).toInt()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
val toggleRows = profileRows.map { row ->
|
||||
val v = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||
it.bottomMargin = (4 * dp).toInt()
|
||||
}
|
||||
}
|
||||
val label = TextView(ctx).apply {
|
||||
text = row.label
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
val toggle = MaterialSwitch(ctx).apply {
|
||||
isChecked = row.pocketIds.any { it !in hidden }
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
marginStart = (4 * dp).toInt()
|
||||
}
|
||||
}
|
||||
v.addView(label)
|
||||
v.addView(toggle)
|
||||
container.addView(v)
|
||||
row to toggle
|
||||
}
|
||||
|
||||
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||
val visibleCount = toggleRows.count { (row, _) -> row.pocketIds.any { it !in hidden } }
|
||||
toggleRows.forEach { (_, toggle) ->
|
||||
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||
}
|
||||
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
||||
}
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(getString(R.string.ooredoo_name))
|
||||
.setView(scroll)
|
||||
.apply {
|
||||
if (profileRows.isNotEmpty()) setPositiveButton(R.string.save, null)
|
||||
setNeutralButton(R.string.close, null)
|
||||
setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||
confirmLogout(getString(R.string.ooredoo_name)) { logoutMfaisa(store, loginId) }
|
||||
}
|
||||
}
|
||||
.show()
|
||||
|
||||
if (profileRows.isNotEmpty()) {
|
||||
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||
saveBtn.isEnabled = false
|
||||
updateToggleStates(saveBtn)
|
||||
|
||||
toggleRows.forEach { (row, toggle) ->
|
||||
toggle.setOnCheckedChangeListener { _, checked ->
|
||||
if (checked) hidden.removeAll(row.pocketIds) else hidden.addAll(row.pocketIds)
|
||||
updateToggleStates(saveBtn)
|
||||
}
|
||||
}
|
||||
|
||||
saveBtn.setOnClickListener {
|
||||
store.setHiddenMfaisaPocketIds(loginId, hidden)
|
||||
clearAllCaches(ctx)
|
||||
dialog.dismiss()
|
||||
(activity as? HomeActivity)?.relogin()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun logoutMfaisa(store: CredentialStore, loginId: String) {
|
||||
val ctx = requireContext()
|
||||
store.clearMfaisaCredentials(loginId)
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
app.mfaisaSessions.remove(loginId)
|
||||
app.mfaisaAccounts = app.mfaisaAccounts.filter { it.loginTag != "mfaisa_$loginId" }
|
||||
app.accounts = app.accounts.filter { it.loginTag != "mfaisa_$loginId" }
|
||||
clearAllCaches(ctx)
|
||||
(activity as HomeActivity).relogin()
|
||||
buildLoginsSection()
|
||||
}
|
||||
|
||||
private fun clearAllCaches(ctx: Context) {
|
||||
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
||||
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
||||
|
||||
@@ -57,6 +57,7 @@ import sh.sar.basedbank.api.mib.MibTransferResult
|
||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
||||
import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler
|
||||
import sh.sar.basedbank.util.AccountListParser
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.AccountInputParser
|
||||
@@ -130,6 +131,39 @@ class TransferFragment : Fragment() {
|
||||
private var pendingBmlTransfer: PendingBmlTransfer? = null
|
||||
private var accountDropdownAdapter: AccountDropdownAdapter? = null
|
||||
|
||||
/** Lazy: created the first time the user selects an MFAISA source account. */
|
||||
private var mfaisaHandler: MfaisaTransferHandler? = null
|
||||
private fun mfaisaHandler(): MfaisaTransferHandler =
|
||||
mfaisaHandler ?: MfaisaTransferHandler(
|
||||
fragment = this,
|
||||
binding = binding,
|
||||
viewModel = viewModel,
|
||||
onRecipientChanged = {
|
||||
// The handler resolved or cleared a recipient — keep the resolvedAccountNumber
|
||||
// mirror in sync so the shared `updateTransferButton()` / clearForm() logic works.
|
||||
val r = mfaisaHandler?.recipient
|
||||
if (r != null) {
|
||||
resolvedAccountNumber = r.msisdn
|
||||
resolvedRecipientName = r.name
|
||||
resolvedDestCurrency = "MVR"
|
||||
resolvedBankName = "Ooredoo M-Faisa"
|
||||
} else {
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
resolvedDestCurrency = ""
|
||||
resolvedBankName = ""
|
||||
}
|
||||
updateTransferButton()
|
||||
},
|
||||
onTransferSuccess = { receipt, avatar ->
|
||||
ReceiptStore.save(requireContext(), receipt)
|
||||
clearForm()
|
||||
val activity = requireActivity() as HomeActivity
|
||||
activity.triggerRefresh()
|
||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar))
|
||||
}
|
||||
).also { mfaisaHandler = it }
|
||||
|
||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
@@ -518,16 +552,60 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle the "To" row's affordances + keyboard hints depending on whether the source is MFAISA. */
|
||||
private fun applyMfaisaToFieldMode(mfaisa: Boolean) {
|
||||
if (mfaisa) {
|
||||
binding.tilTo.hint = getString(R.string.ooredoo_phone)
|
||||
binding.etTo.inputType = android.text.InputType.TYPE_CLASS_PHONE
|
||||
// Phone-only flow — hide the QR scanner + contact picker icons next to the To field
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
// Any previously-resolved non-MFAISA recipient (or stale state) is no longer valid
|
||||
if (resolvedAccountNumber.isNotBlank() && mfaisaHandler?.recipient == null) {
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
resolvedDestCurrency = ""
|
||||
resolvedToOwnAccount = null
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
binding.tilTo.visibility = View.VISIBLE
|
||||
binding.etTo.setText("")
|
||||
}
|
||||
} else {
|
||||
binding.tilTo.hint = getString(R.string.transfer_to)
|
||||
binding.etTo.inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||
binding.btnPickContact.visibility = View.VISIBLE
|
||||
binding.btnScanQr.visibility = View.VISIBLE
|
||||
// Drop any M-Faisa-resolved recipient when switching banks
|
||||
if (mfaisaHandler?.recipient != null) {
|
||||
mfaisaHandler?.clearState()
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
resolvedDestCurrency = ""
|
||||
resolvedToOwnAccount = null
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
binding.tilTo.visibility = View.VISIBLE
|
||||
binding.etTo.setText("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFromCard(account: BankAccount) {
|
||||
// Apply per-source "To"-row configuration before painting the card. M-Faisa transfers
|
||||
// only target phone numbers (looked up via basicBeneDetails), so the QR / contact-picker
|
||||
// affordances are hidden and the keyboard is set to phone-numeric.
|
||||
applyMfaisaToFieldMode(account.bank == "MFAISA")
|
||||
|
||||
val colorHex = when (account.bank) {
|
||||
"BML" -> "#0066A1"
|
||||
"FAHIPAY" -> "#15BEA7"
|
||||
"MFAISA" -> "#ED1C24"
|
||||
else -> "#FE860E"
|
||||
}
|
||||
val bankLabel = when (account.bank) {
|
||||
"BML" -> "BML"
|
||||
"FAHIPAY" -> "FP"
|
||||
"MIB" -> "MIB"
|
||||
"MFAISA" -> "M-Faisa"
|
||||
else -> null
|
||||
}
|
||||
val typeLabel = AccountListParser.from(account)?.typeLabel
|
||||
@@ -561,11 +639,18 @@ class TransferFragment : Fragment() {
|
||||
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivFromPhoto.setImageResource(R.drawable.fahipay_logo)
|
||||
}
|
||||
else -> {
|
||||
account.bank == "MFAISA" -> {
|
||||
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivFromPhoto.setImageResource(R.drawable.ooredoo_logo)
|
||||
}
|
||||
account.bank == "MIB" -> {
|
||||
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivFromPhoto.setImageResource(R.drawable.mib_logo)
|
||||
if (account.profileImageHash != null) loadFromPhoto(account.profileImageHash)
|
||||
}
|
||||
else -> {
|
||||
binding.ivFromPhoto.setImageDrawable(null)
|
||||
}
|
||||
}
|
||||
binding.tilFrom.visibility = View.GONE
|
||||
binding.cardFromInfo.visibility = View.VISIBLE
|
||||
@@ -618,11 +703,18 @@ class TransferFragment : Fragment() {
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivToPhoto.setImageResource(R.drawable.fahipay_logo)
|
||||
}
|
||||
else -> {
|
||||
account.bank == "MFAISA" -> {
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
|
||||
}
|
||||
account.bank == "MIB" -> {
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivToPhoto.setImageResource(R.drawable.mib_logo)
|
||||
if (account.profileImageHash != null) loadToPhoto(account.profileImageHash, isProfile = true)
|
||||
}
|
||||
else -> {
|
||||
binding.ivToPhoto.setImageDrawable(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,7 +741,14 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun setupAccountLookup() {
|
||||
binding.tilTo.setEndIconOnClickListener { lookupAccount() }
|
||||
binding.tilTo.setEndIconOnClickListener {
|
||||
// M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails)
|
||||
if (selectedAccount?.bank == "MFAISA") {
|
||||
mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty())
|
||||
} else {
|
||||
lookupAccount()
|
||||
}
|
||||
}
|
||||
|
||||
binding.btnClearToInfo.setOnClickListener {
|
||||
if (bmlQrInfo != null) {
|
||||
@@ -665,12 +764,15 @@ class TransferFragment : Fragment() {
|
||||
resolvedDestCurrency = ""
|
||||
resolvedToOwnAccount = null
|
||||
selectedFahipayService = null
|
||||
mfaisaHandler?.clearState()
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
binding.layoutServiceSelector.visibility = View.INVISIBLE
|
||||
binding.tilTo.visibility = View.VISIBLE
|
||||
binding.btnPickContact.visibility = View.VISIBLE
|
||||
binding.btnScanQr.visibility = View.VISIBLE
|
||||
binding.tilTo.error = null
|
||||
// Re-apply MFAISA-mode if needed (hides QR/contact picker + sets phone keyboard)
|
||||
applyMfaisaToFieldMode(selectedAccount?.bank == "MFAISA")
|
||||
updateTransferButton()
|
||||
}
|
||||
|
||||
@@ -681,10 +783,12 @@ class TransferFragment : Fragment() {
|
||||
resolvedRecipientName = ""
|
||||
resolvedDestCurrency = ""
|
||||
resolvedToOwnAccount = null
|
||||
mfaisaHandler?.clearState()
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
binding.tilTo.visibility = View.VISIBLE
|
||||
binding.btnPickContact.visibility = View.VISIBLE
|
||||
binding.btnScanQr.visibility = View.VISIBLE
|
||||
applyMfaisaToFieldMode(selectedAccount?.bank == "MFAISA")
|
||||
updateTransferButton()
|
||||
}
|
||||
}
|
||||
@@ -1083,6 +1187,12 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun initiateTransfer() {
|
||||
// M-Faisa source: the entire flow (initiate + OTP + confirm) lives in the handler.
|
||||
if (selectedAccount?.bank == "MFAISA") {
|
||||
mfaisaHandler().submit()
|
||||
return
|
||||
}
|
||||
|
||||
// BML QR merchant payment — uses shared confirm dialog, no receipt
|
||||
bmlQrInfo?.let { info ->
|
||||
val src = selectedAccount ?: run {
|
||||
@@ -2075,6 +2185,7 @@ class TransferFragment : Fragment() {
|
||||
|
||||
private fun clearForm() {
|
||||
resetBmlOtpState()
|
||||
mfaisaHandler?.clearState()
|
||||
selectedAccount = null
|
||||
binding.actvFrom.setText("", false)
|
||||
binding.cardFromInfo.visibility = View.GONE
|
||||
@@ -2195,6 +2306,9 @@ class TransferFragment : Fragment() {
|
||||
bmlOtpState = BmlOtpState.NONE
|
||||
pendingBmlTransfer = null
|
||||
bmlOtpChannel = null
|
||||
// The M-Faisa handler holds binding refs; drop it so the next view gets a fresh one.
|
||||
mfaisaHandler?.clearState()
|
||||
mfaisaHandler = null
|
||||
_binding = null
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
||||
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaHistoryClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||
@@ -61,12 +62,18 @@ class TransferHistoryFragment : Fragment() {
|
||||
var bmlTotalPages: Int = -1,
|
||||
var cardMonthOffset: Int = 0,
|
||||
var fahipayNextStart: Int = 0,
|
||||
var fahipayTotal: Int = -1
|
||||
var fahipayTotal: Int = -1,
|
||||
var mfaisaNextPage: Int = 1,
|
||||
var mfaisaHasMore: Boolean = true
|
||||
) {
|
||||
// PayPal pockets have no known history endpoint, so they don't paginate here.
|
||||
private val isMfaisaPaypal get() = account.profileType == "MFAISA_PAYPAL"
|
||||
|
||||
fun hasMore(): Boolean = when {
|
||||
account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT" -> cardMonthOffset < 2
|
||||
account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||
account.bank == "MFAISA" -> !isMfaisaPaypal && mfaisaHasMore
|
||||
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||
}
|
||||
}
|
||||
@@ -236,6 +243,36 @@ class TransferHistoryFragment : Fragment() {
|
||||
}
|
||||
}.awaitAll().flatten())
|
||||
|
||||
// M-Faisa accounts (PayPal pockets are skipped by hasMore())
|
||||
val mfaisaStates = activeStates.filter { it.account.bank == "MFAISA" }
|
||||
for (state in mfaisaStates) {
|
||||
var session = app.mfaisaSessionFor(state.account) ?: continue
|
||||
try {
|
||||
val page = try {
|
||||
MfaisaHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountNumber = state.account.accountNumber,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
pageNo = state.mfaisaNextPage,
|
||||
recordSize = 70
|
||||
)
|
||||
} catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) {
|
||||
val loginId = state.account.loginTag.removePrefix("mfaisa_")
|
||||
session = app.refreshMfaisaSession(loginId) ?: continue
|
||||
MfaisaHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountNumber = state.account.accountNumber,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
pageNo = state.mfaisaNextPage,
|
||||
recordSize = 70
|
||||
)
|
||||
}
|
||||
state.mfaisaHasMore = page.hasMore
|
||||
state.mfaisaNextPage++
|
||||
results.addAll(page.transactions)
|
||||
} catch (e: Exception) { trackError(e) }
|
||||
}
|
||||
|
||||
// Fahipay accounts
|
||||
val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" }
|
||||
for (state in fahipayStates) {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaInvalidOtpException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaRecipientNotFoundException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||
import sh.sar.basedbank.ui.home.HomeActivity
|
||||
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||
|
||||
/**
|
||||
* Owns the M-Faisa-only parts of the Transfer screen: phone-based recipient lookup,
|
||||
* initiate-with-OTP, and confirm-with-OTP. Lives alongside [sh.sar.basedbank.ui.home.TransferFragment]
|
||||
* which dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source.
|
||||
*
|
||||
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
|
||||
* [Fragment.viewLifecycleOwner] and Context) — and must be re-created when the view is recreated.
|
||||
*/
|
||||
class MfaisaTransferHandler(
|
||||
private val fragment: Fragment,
|
||||
private val binding: FragmentTransferBinding,
|
||||
private val viewModel: HomeViewModel,
|
||||
/** Hook called when M-Faisa successfully resolves or clears a recipient — fragment uses this to update Send-button state. */
|
||||
private val onRecipientChanged: () -> Unit,
|
||||
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes account balances. */
|
||||
private val onTransferSuccess: (TransferReceiptData, Bitmap?) -> Unit,
|
||||
) {
|
||||
|
||||
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||
private val ctx get() = fragment.requireContext()
|
||||
|
||||
/** Set to the resolved recipient after a successful search; null otherwise. */
|
||||
var recipient: MfaisaTransferClient.Recipient? = null
|
||||
private set
|
||||
|
||||
private var lookupInFlight = false
|
||||
|
||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||
|
||||
/** Whether the recipient lookup has resolved — gates the Send button. */
|
||||
fun isRecipientReady(): Boolean = recipient?.isMvr == true
|
||||
|
||||
/** Triggered when the user taps the search end-icon in `tilTo` (and source bank is MFAISA). */
|
||||
fun searchRecipient(rawInput: String) {
|
||||
if (lookupInFlight) return
|
||||
val phone = rawInput.trim().removePrefix("960")
|
||||
if (phone.isEmpty() || phone.length < 7) {
|
||||
binding.tilTo.error = "Enter a valid mobile number"
|
||||
return
|
||||
}
|
||||
binding.tilTo.error = null
|
||||
|
||||
val source = currentSource() ?: return
|
||||
val session = app.mfaisaSessionFor(source) ?: run {
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
lookupInFlight = true
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
MfaisaTransferClient.forContext(ctx).searchRecipient(session, phone)
|
||||
} catch (_: MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||
MfaisaTransferClient.forContext(ctx).searchRecipient(fresh, phone)
|
||||
}
|
||||
}
|
||||
if (!result.isMvr) {
|
||||
// Server returned the user but only with a PayPal pocket — not supported.
|
||||
binding.tilTo.error = "This number doesn't have an MVR M-Faisa pocket"
|
||||
} else {
|
||||
recipient = result
|
||||
showResolvedRecipient(result)
|
||||
}
|
||||
} catch (_: MfaisaRecipientNotFoundException) {
|
||||
binding.tilTo.error = "No M-Faisa wallet found for this number"
|
||||
} catch (e: java.io.IOException) {
|
||||
Toast.makeText(ctx, R.string.connectivity_no_internet, Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
binding.tilTo.error = e.message ?: "Lookup failed"
|
||||
} finally {
|
||||
lookupInFlight = false
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||
onRecipientChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Triggered when the user taps the Send button (and source bank is MFAISA). */
|
||||
fun submit() {
|
||||
val source = currentSource() ?: return
|
||||
val r = recipient ?: run {
|
||||
Toast.makeText(ctx, "Search for a recipient first", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val amountStr = binding.etAmount.text?.toString()?.trim().orEmpty()
|
||||
val amount = amountStr.toDoubleOrNull()
|
||||
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
|
||||
binding.tilAmount.error = null
|
||||
val remarks = binding.etRemarks.text?.toString()?.trim().orEmpty()
|
||||
|
||||
val sourcePocketId = source.accountNumber // pocketId IS the accountNumber for M-Faisa accounts
|
||||
|
||||
binding.btnTransfer.isEnabled = false
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val refId = try {
|
||||
withContext(Dispatchers.IO) { initiateWithRetry(source, sourcePocketId, r, amountStr, remarks) }
|
||||
} catch (e: Exception) {
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||
binding.btnTransfer.isEnabled = true
|
||||
showError(e)
|
||||
return@launch
|
||||
}
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||
// Server has now SMSed an OTP to the user's phone. Prompt them for it.
|
||||
promptForOtp(source, r, amountStr, remarks, refId, errorMsg = null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Called when the source account changes away from M-Faisa (or the view tears down). */
|
||||
fun clearState() {
|
||||
recipient = null
|
||||
lookupInFlight = false
|
||||
}
|
||||
|
||||
// ─── Internal ────────────────────────────────────────────────────────────
|
||||
|
||||
private fun currentSource(): BankAccount? =
|
||||
viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" && it.accountNumber == sourceAccountNumberFromCard() }
|
||||
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" } // fallback if from-card field isn't easily readable
|
||||
|
||||
private fun sourceAccountNumberFromCard(): String =
|
||||
binding.tvFromAccountNumber.text?.toString().orEmpty()
|
||||
|
||||
private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) {
|
||||
// Reuse the same recipient card the fragment uses for other banks. The fragment owns the
|
||||
// card view, so we just populate its text fields and toggle visibility.
|
||||
binding.tvToAccountName.text = r.name.ifBlank { r.msisdn }
|
||||
binding.tvToBankBic.text = r.msisdn
|
||||
binding.tvToAccountDetails.text = "Ooredoo M-Faisa · MVR"
|
||||
binding.tvToAccountDetails.visibility = View.VISIBLE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
|
||||
binding.tilTo.visibility = View.GONE
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
binding.cardToInfo.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun promptForOtp(
|
||||
source: BankAccount,
|
||||
r: MfaisaTransferClient.Recipient,
|
||||
amountStr: String,
|
||||
remarks: String,
|
||||
refId: String,
|
||||
errorMsg: String?
|
||||
) {
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val input = android.widget.EditText(ctx).apply {
|
||||
hint = "Enter SMS code"
|
||||
inputType = android.text.InputType.TYPE_CLASS_NUMBER
|
||||
filters = arrayOf(android.text.InputFilter.LengthFilter(6))
|
||||
setPadding((24 * dp).toInt(), (8 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
|
||||
}
|
||||
val message = buildString {
|
||||
append("A 6-digit verification code has been sent to ${r.msisdn.replaceBefore('-', "")}.")
|
||||
append("\n\nEnter it to complete the MVR $amountStr transfer to ${r.name}.")
|
||||
if (errorMsg != null) append("\n\n$errorMsg")
|
||||
}
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle("Enter verification code")
|
||||
.setMessage(message)
|
||||
.setView(input)
|
||||
.setPositiveButton(R.string.verify, null)
|
||||
.setNegativeButton(R.string.cancel) { d, _ ->
|
||||
d.dismiss()
|
||||
binding.btnTransfer.isEnabled = true
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||
val otp = input.text?.toString()?.trim().orEmpty()
|
||||
if (otp.length != 6) { input.error = "Enter 6 digits"; return@setOnClickListener }
|
||||
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 = "%.2f".format(amountStr.toDouble()),
|
||||
currency = "MVR",
|
||||
fromLabel = source.accountBriefName,
|
||||
fromColorHex = "#ED1C24",
|
||||
toLabel = r.name.ifBlank { r.msisdn },
|
||||
toAccount = r.msisdn,
|
||||
toBank = "Ooredoo M-Faisa",
|
||||
remarks = remarks,
|
||||
bmlFromName = source.accountBriefName,
|
||||
bmlReference = refId,
|
||||
bmlMessage = "Transfer Completed Successfully"
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showError(e: Exception) {
|
||||
val msg = when {
|
||||
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
|
||||
!e.message.isNullOrBlank() -> e.message!!
|
||||
else -> "Transfer failed"
|
||||
}
|
||||
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,12 @@ import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.fahipay.FahipayAccountClient
|
||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaAccountClient
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaInvalidPinException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaKycRequiredException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaNotRegisteredException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaWalletNotReadyException
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibProfileClient
|
||||
@@ -218,10 +224,7 @@ class CredentialsFragment : Fragment() {
|
||||
when (bankType) {
|
||||
"BML" -> { attemptBmlLogin(); return }
|
||||
"FAHIPAY" -> { attemptFahipayLogin(); return }
|
||||
"OOREDOO" -> {
|
||||
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
"OOREDOO" -> { attemptMfaisaLogin(); return }
|
||||
}
|
||||
|
||||
val username = binding.etUsername.text.toString().trim()
|
||||
@@ -429,6 +432,97 @@ class CredentialsFragment : Fragment() {
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun attemptMfaisaLogin() {
|
||||
val msisdn = binding.etUsername.text.toString().trim()
|
||||
val pin = binding.etPassword.text.toString()
|
||||
|
||||
if (msisdn.isEmpty() || pin.length != 4) {
|
||||
binding.tvError.text = "Please enter your phone number and 4-digit mPIN"
|
||||
binding.tvError.visibility = View.VISIBLE
|
||||
return
|
||||
}
|
||||
|
||||
binding.tvError.visibility = View.GONE
|
||||
binding.progressBar.visibility = View.VISIBLE
|
||||
binding.btnLogin.isEnabled = false
|
||||
binding.etUsername.isEnabled = false
|
||||
binding.etPassword.isEnabled = false
|
||||
|
||||
val store = CredentialStore(requireContext())
|
||||
val flow = MfaisaLoginFlow(requireContext())
|
||||
val loginTag = "mfaisa_$msisdn"
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { flow.fetchSubscriber(msisdn) }
|
||||
val result = withContext(Dispatchers.IO) { flow.doMobileLogin(msisdn, pin) }
|
||||
val accounts = MfaisaAccountClient.buildAccounts(result, loginTag)
|
||||
|
||||
store.saveMfaisaCredentials(msisdn, msisdn, pin)
|
||||
store.saveMfaisaUserProfile(
|
||||
msisdn,
|
||||
CredentialStore.MfaisaUserProfile(
|
||||
name = result.profile.name,
|
||||
email = result.profile.email,
|
||||
mdnId = result.profile.mdnId,
|
||||
subscriberId = result.profile.subscriberId,
|
||||
walletId = result.profile.walletId,
|
||||
roleId = result.profile.roleId,
|
||||
offerId = result.profile.offerId
|
||||
)
|
||||
)
|
||||
AccountCache.saveMfaisa(requireContext(), msisdn, accounts)
|
||||
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
app.mfaisaSessions[msisdn] = result.session
|
||||
app.mfaisaAccounts = app.mfaisaAccounts.filter { it.loginTag != loginTag } + accounts
|
||||
app.accounts = app.accounts.filter { it.loginTag != loginTag } + accounts
|
||||
app.isUnlocked = true
|
||||
|
||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
startActivity(intent)
|
||||
} catch (e: MfaisaNotRegisteredException) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("User not registered")
|
||||
.setMessage("Please use the Ooredoo SuperApp to register your M-Faisa wallet and complete KYC, then come back to Thijooree.")
|
||||
.setPositiveButton("OK", null)
|
||||
.show()
|
||||
binding.tvError.visibility = View.GONE
|
||||
} catch (e: MfaisaKycRequiredException) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("KYC incomplete")
|
||||
.setMessage("Your M-Faisa wallet needs Full KYC. Please complete KYC in the Ooredoo SuperApp, then come back to Thijooree.")
|
||||
.setPositiveButton("OK", null)
|
||||
.show()
|
||||
binding.tvError.visibility = View.GONE
|
||||
} catch (e: MfaisaWalletNotReadyException) {
|
||||
binding.tvError.text = e.message
|
||||
binding.tvError.visibility = View.VISIBLE
|
||||
} catch (e: MfaisaInvalidPinException) {
|
||||
val message = if (e.lastAttempt)
|
||||
"${e.message}\n\nOne more wrong mPIN will lock your account."
|
||||
else
|
||||
e.message
|
||||
binding.tvError.text = message ?: "Incorrect mPIN"
|
||||
binding.tvError.visibility = View.VISIBLE
|
||||
// Re-enable PIN input only — leave phone number locked
|
||||
binding.etPassword.isEnabled = true
|
||||
binding.etPassword.setText("")
|
||||
binding.etPassword.requestFocus()
|
||||
} catch (e: Exception) {
|
||||
binding.tvError.text = e.message ?: "Login failed"
|
||||
binding.tvError.visibility = View.VISIBLE
|
||||
binding.etUsername.isEnabled = true
|
||||
binding.etPassword.isEnabled = true
|
||||
} finally {
|
||||
binding.progressBar.visibility = View.GONE
|
||||
binding.btnLogin.isEnabled = true
|
||||
// After PIN error, keep phone disabled; for any other resolution above we already restored as needed.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun attemptFahipayLogin() {
|
||||
if (fahipayAwaitingTotp) {
|
||||
submitFahipayTotp()
|
||||
|
||||
@@ -11,6 +11,7 @@ object AccountCache {
|
||||
private const val KEY_MIB = "mib_accounts"
|
||||
private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
|
||||
private fun fahipayKey(loginId: String) = "fahipay_accounts_$loginId"
|
||||
private fun mfaisaKey(loginId: String) = "mfaisa_accounts_$loginId"
|
||||
|
||||
fun save(context: Context, accounts: List<BankAccount>) {
|
||||
val arr = JSONArray()
|
||||
@@ -150,6 +151,62 @@ object AccountCache {
|
||||
fun loadFahipay(context: Context, loginIds: List<String>): List<BankAccount> =
|
||||
loginIds.flatMap { loadFahipay(context, it) }
|
||||
|
||||
fun saveMfaisa(context: Context, loginId: String, accounts: List<BankAccount>) {
|
||||
val arr = JSONArray()
|
||||
for (acc in accounts) {
|
||||
arr.put(JSONObject().apply {
|
||||
put("profileName", acc.profileName)
|
||||
put("profileType", acc.profileType)
|
||||
put("accountNumber", acc.accountNumber)
|
||||
put("accountBriefName", acc.accountBriefName)
|
||||
put("currencyName", acc.currencyName)
|
||||
put("accountTypeName", acc.accountTypeName)
|
||||
put("availableBalance", acc.availableBalance)
|
||||
put("currentBalance", acc.currentBalance)
|
||||
put("blockedAmount", acc.blockedAmount)
|
||||
put("mvrBalance", acc.mvrBalance)
|
||||
put("statusDesc", acc.statusDesc)
|
||||
put("loginTag", acc.loginTag)
|
||||
put("profileId", acc.profileId)
|
||||
put("internalId", acc.internalId)
|
||||
})
|
||||
}
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit().putString(mfaisaKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
||||
}
|
||||
|
||||
fun loadMfaisa(context: Context, loginId: String): List<BankAccount> {
|
||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(mfaisaKey(loginId), null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
BankAccount(
|
||||
bank = "MFAISA",
|
||||
profileName = o.optString("profileName"),
|
||||
profileType = o.optString("profileType"),
|
||||
accountNumber = o.optString("accountNumber"),
|
||||
accountBriefName = o.optString("accountBriefName"),
|
||||
currencyName = o.optString("currencyName"),
|
||||
accountTypeName = o.optString("accountTypeName"),
|
||||
availableBalance = o.optString("availableBalance"),
|
||||
currentBalance = o.optString("currentBalance"),
|
||||
blockedAmount = o.optString("blockedAmount"),
|
||||
mvrBalance = o.optString("mvrBalance"),
|
||||
statusDesc = o.optString("statusDesc"),
|
||||
profileImageHash = null,
|
||||
loginTag = o.optString("loginTag"),
|
||||
profileId = o.optString("profileId", ""),
|
||||
internalId = o.optString("internalId", "")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun loadMfaisa(context: Context, loginIds: List<String>): List<BankAccount> =
|
||||
loginIds.flatMap { loadMfaisa(context, it) }
|
||||
|
||||
fun clear(context: Context) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package sh.sar.basedbank.util
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.bmlapi.BmlHistoryParser
|
||||
import sh.sar.basedbank.util.fahipayapi.FahipayHistoryParser
|
||||
import sh.sar.basedbank.util.mfaisaapi.MfaisaHistoryParser
|
||||
import sh.sar.basedbank.util.mibapi.MibHistoryParser
|
||||
|
||||
object AccountHistoryParser {
|
||||
@@ -11,6 +12,7 @@ object AccountHistoryParser {
|
||||
"BML" -> BmlHistoryParser.displayData(account)
|
||||
"FAHIPAY" -> FahipayHistoryParser.displayData(account)
|
||||
"MIB" -> MibHistoryParser.displayData(account)
|
||||
"MFAISA" -> MfaisaHistoryParser.displayData(account)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package sh.sar.basedbank.util
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||
import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser
|
||||
import sh.sar.basedbank.util.mfaisaapi.MfaisaAccountParser
|
||||
import sh.sar.basedbank.util.mibapi.MibAccountParser
|
||||
|
||||
object AccountListParser {
|
||||
@@ -11,6 +12,7 @@ object AccountListParser {
|
||||
"BML" -> BmlDashboardParser.displayData(account)
|
||||
"FAHIPAY" -> FahipayAccountParser.displayData(account)
|
||||
"MIB" -> MibAccountParser.displayData(account)
|
||||
"MFAISA" -> MfaisaAccountParser.displayData(account)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class CredentialStore(context: Context) {
|
||||
data class MibCredentials(val username: String, val passwordHash: String, val otpSeed: String)
|
||||
data class BmlCredentials(val username: String, val password: String, val otpSeed: String)
|
||||
data class FahipayCredentials(val idCard: String, val password: String)
|
||||
data class MfaisaCredentials(val msisdn: String, val pin: String)
|
||||
|
||||
// ── MIB login credentials (multi-login, keyed by loginId = username) ─────
|
||||
|
||||
@@ -460,6 +461,110 @@ class CredentialStore(context: Context) {
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
// ── M-Faisa login credentials (multi-login, keyed by loginId = msisdn) ───
|
||||
|
||||
fun getMfaisaLoginIds(): List<String> {
|
||||
val json = prefs.getString("mfaisa_login_ids", null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = org.json.JSONArray(json)
|
||||
(0 until arr.length()).map { arr.getString(it) }
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun hasMfaisaCredentials(): Boolean = getMfaisaLoginIds().isNotEmpty()
|
||||
|
||||
private fun addMfaisaLoginId(loginId: String) {
|
||||
val ids = getMfaisaLoginIds().toMutableList()
|
||||
if (loginId !in ids) {
|
||||
ids.add(loginId)
|
||||
prefs.edit().putString("mfaisa_login_ids", org.json.JSONArray(ids).toString()).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeMfaisaLoginId(loginId: String) {
|
||||
val ids = getMfaisaLoginIds().toMutableList()
|
||||
if (ids.remove(loginId))
|
||||
prefs.edit().putString("mfaisa_login_ids", org.json.JSONArray(ids).toString()).apply()
|
||||
}
|
||||
|
||||
fun saveMfaisaCredentials(loginId: String, msisdn: String, pin: String) {
|
||||
addMfaisaLoginId(loginId)
|
||||
val key = getOrCreateKey()
|
||||
prefs.edit()
|
||||
.putString("mfaisa_${loginId}_enc_msisdn", encrypt(msisdn, key))
|
||||
.putString("mfaisa_${loginId}_enc_pin", encrypt(pin, key))
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun loadMfaisaCredentials(loginId: String): MfaisaCredentials? {
|
||||
val key = getOrCreateKey()
|
||||
val encMsisdn = prefs.getString("mfaisa_${loginId}_enc_msisdn", null) ?: return null
|
||||
val encPin = prefs.getString("mfaisa_${loginId}_enc_pin", null) ?: return null
|
||||
return try {
|
||||
MfaisaCredentials(decrypt(encMsisdn, key), decrypt(encPin, key))
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun clearMfaisaCredentials(loginId: String) {
|
||||
removeMfaisaLoginId(loginId)
|
||||
prefs.edit()
|
||||
.remove("mfaisa_${loginId}_enc_msisdn")
|
||||
.remove("mfaisa_${loginId}_enc_pin")
|
||||
.remove("mfaisa_${loginId}_enc_profile")
|
||||
.remove("mfaisa_${loginId}_hidden_pockets")
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** Pocket-level hide flags (parallels [getHiddenBmlProfileIds]). Keyed by pocket ID. */
|
||||
fun getHiddenMfaisaPocketIds(loginId: String): Set<String> =
|
||||
prefs.getStringSet("mfaisa_${loginId}_hidden_pockets", emptySet()) ?: emptySet()
|
||||
|
||||
fun setHiddenMfaisaPocketIds(loginId: String, ids: Set<String>) =
|
||||
prefs.edit().putStringSet("mfaisa_${loginId}_hidden_pockets", ids).apply()
|
||||
|
||||
// ── M-Faisa user profile (per loginId) ────────────────────────────────────
|
||||
|
||||
data class MfaisaUserProfile(
|
||||
val name: String,
|
||||
val email: String,
|
||||
val mdnId: String,
|
||||
val subscriberId: String,
|
||||
val walletId: String,
|
||||
val roleId: String,
|
||||
val offerId: String
|
||||
)
|
||||
|
||||
fun saveMfaisaUserProfile(loginId: String, p: MfaisaUserProfile) {
|
||||
val json = org.json.JSONObject().apply {
|
||||
put("name", p.name)
|
||||
put("email", p.email)
|
||||
put("mdnId", p.mdnId)
|
||||
put("subscriberId", p.subscriberId)
|
||||
put("walletId", p.walletId)
|
||||
put("roleId", p.roleId)
|
||||
put("offerId", p.offerId)
|
||||
}.toString()
|
||||
val key = getOrCreateKey()
|
||||
prefs.edit().putString("mfaisa_${loginId}_enc_profile", encrypt(json, key)).apply()
|
||||
}
|
||||
|
||||
fun loadMfaisaUserProfile(loginId: String): MfaisaUserProfile? {
|
||||
val key = getOrCreateKey()
|
||||
val enc = prefs.getString("mfaisa_${loginId}_enc_profile", null) ?: return null
|
||||
return try {
|
||||
val o = org.json.JSONObject(decrypt(enc, key))
|
||||
MfaisaUserProfile(
|
||||
name = o.optString("name"),
|
||||
email = o.optString("email"),
|
||||
mdnId = o.optString("mdnId"),
|
||||
subscriberId = o.optString("subscriberId"),
|
||||
walletId = o.optString("walletId"),
|
||||
roleId = o.optString("roleId"),
|
||||
offerId = o.optString("offerId")
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
// ── Security credential (PIN / pattern hash) ──────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
||||
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaHistoryClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
@@ -24,6 +25,8 @@ class HistoryFetcher(private val account: BankAccount) {
|
||||
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
|
||||
private val isBmlLoan get() = account.profileType == "BML_LOAN"
|
||||
private val isFahipay get() = account.bank == "FAHIPAY"
|
||||
private val isMfaisa get() = account.bank == "MFAISA"
|
||||
private val isMfaisaPaypal get() = account.profileType == "MFAISA_PAYPAL"
|
||||
|
||||
// MIB pagination
|
||||
private var mibNextStart = 1
|
||||
@@ -55,12 +58,19 @@ class HistoryFetcher(private val account: BankAccount) {
|
||||
private var fahipayNextStart = 0
|
||||
private var fahipayTotal = -1
|
||||
|
||||
// M-Faisa pagination — the server doesn't return a "total" field, so we infer "more pages exist"
|
||||
// from whether the last page was full-sized.
|
||||
private var mfaisaNextPage = 1
|
||||
private var mfaisaHasMore = true
|
||||
|
||||
fun hasMore(): Boolean = when {
|
||||
isBmlLoan -> false
|
||||
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||
isBmlCard -> cardMonthOffset < 3
|
||||
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||
isBmlLoan -> false
|
||||
isMfaisaPaypal -> false // PayPal pockets have no known history endpoint
|
||||
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||
isBmlCard -> cardMonthOffset < 3
|
||||
isMfaisa -> mfaisaHasMore
|
||||
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||
}
|
||||
|
||||
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<BankTransaction> = when {
|
||||
@@ -68,9 +78,36 @@ class HistoryFetcher(private val account: BankAccount) {
|
||||
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
|
||||
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
|
||||
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
|
||||
isMfaisa -> withContext(Dispatchers.IO) { fetchMfaisa(app) }
|
||||
else -> withContext(Dispatchers.IO) { fetchBmlCasa(app) }
|
||||
}
|
||||
|
||||
private fun fetchMfaisa(app: BasedBankApp): List<BankTransaction> {
|
||||
val loginId = account.loginTag.removePrefix("mfaisa_")
|
||||
var session = app.mfaisaSessionFor(account) ?: return emptyList()
|
||||
val page = try {
|
||||
MfaisaHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountNumber = account.accountNumber,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
pageNo = mfaisaNextPage,
|
||||
recordSize = 70
|
||||
)
|
||||
} catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) {
|
||||
session = app.refreshMfaisaSession(loginId) ?: return emptyList()
|
||||
MfaisaHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountNumber = account.accountNumber,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
pageNo = mfaisaNextPage,
|
||||
recordSize = 70
|
||||
)
|
||||
}
|
||||
mfaisaHasMore = page.hasMore
|
||||
mfaisaNextPage++
|
||||
return page.transactions
|
||||
}
|
||||
|
||||
private fun fetchFahipay(app: BasedBankApp): List<BankTransaction> {
|
||||
val session = app.fahipaySessionFor(account) ?: return emptyList()
|
||||
val (list, total) = FahipayHistoryClient().fetchHistory(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -30,9 +30,9 @@
|
||||
<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 PIN.</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">PIN</string>
|
||||
<string name="ooredoo_pin">mPIN</string>
|
||||
<string name="sign_in">Sign In</string>
|
||||
<string name="sign_in_desc">Enter your Maldives Islamic Bank credentials.</string>
|
||||
<string name="bml_sign_in_desc">Enter your Bank of Maldives credentials.</string>
|
||||
|
||||
Reference in New Issue
Block a user