forked from shihaam/thijooree
ooredoo mfaisa
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.BmlProfile
|
||||||
import sh.sar.basedbank.api.bml.BmlSession
|
import sh.sar.basedbank.api.bml.BmlSession
|
||||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaSession
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||||
import sh.sar.basedbank.api.mib.MibProfile
|
import sh.sar.basedbank.api.mib.MibProfile
|
||||||
@@ -48,6 +49,10 @@ class BasedBankApp : Application() {
|
|||||||
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
|
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
|
||||||
var fahipayAccounts: List<BankAccount> = emptyList()
|
var fahipayAccounts: List<BankAccount> = emptyList()
|
||||||
|
|
||||||
|
/** Active M-Faisa sessions keyed by loginId (= msisdn). */
|
||||||
|
val mfaisaSessions: MutableMap<String, MfaisaSession> = mutableMapOf()
|
||||||
|
var mfaisaAccounts: List<BankAccount> = emptyList()
|
||||||
|
|
||||||
// ─── MIB helpers ──────────────────────────────────────────────────────────
|
// ─── MIB helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Returns the MIB session for the given account (matched via loginTag). */
|
/** Returns the MIB session for the given account (matched via loginTag). */
|
||||||
@@ -110,6 +115,26 @@ class BasedBankApp : Application() {
|
|||||||
fun fahipaySessionFor(account: BankAccount): FahipaySession? =
|
fun fahipaySessionFor(account: BankAccount): FahipaySession? =
|
||||||
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
|
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
|
||||||
|
|
||||||
|
// ─── M-Faisa helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns the M-Faisa session for the given account (matched via loginTag = "mfaisa_${msisdn}"). */
|
||||||
|
fun mfaisaSessionFor(account: BankAccount): MfaisaSession? =
|
||||||
|
mfaisaSessions[account.loginTag.removePrefix("mfaisa_")]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-runs `fetchSubscriber` + `doMobileLogin` using the saved credentials for [loginId] and
|
||||||
|
* replaces the cached session. Call this after catching [sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException].
|
||||||
|
* Returns the fresh session, or null if no credentials are saved for that login.
|
||||||
|
*/
|
||||||
|
fun refreshMfaisaSession(loginId: String): MfaisaSession? {
|
||||||
|
val creds = CredentialStore(this).loadMfaisaCredentials(loginId) ?: return null
|
||||||
|
val flow = sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow(this)
|
||||||
|
flow.fetchSubscriber(creds.msisdn)
|
||||||
|
val result = flow.doMobileLogin(creds.msisdn, creds.pin)
|
||||||
|
mfaisaSessions[loginId] = result.session
|
||||||
|
return result.session
|
||||||
|
}
|
||||||
|
|
||||||
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
|
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
|
||||||
val mibMutex = Mutex()
|
val mibMutex = Mutex()
|
||||||
|
|
||||||
|
|||||||
@@ -279,7 +279,8 @@ class LockActivity : AppCompatActivity() {
|
|||||||
finish()
|
finish()
|
||||||
} else {
|
} else {
|
||||||
val store = CredentialStore(this)
|
val store = CredentialStore(this)
|
||||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() ||
|
||||||
|
store.hasFahipayCredentials() || store.hasMfaisaCredentials()
|
||||||
if (!hasCredentials) {
|
if (!hasCredentials) {
|
||||||
startActivity(Intent(this, sh.sar.basedbank.ui.login.LoginActivity::class.java))
|
startActivity(Intent(this, sh.sar.basedbank.ui.login.LoginActivity::class.java))
|
||||||
finish()
|
finish()
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
val onboardingDone = prefs.getBoolean("onboarding_done", false)
|
val onboardingDone = prefs.getBoolean("onboarding_done", false)
|
||||||
val securitySet = prefs.getString("security_method", null) != null
|
val securitySet = prefs.getString("security_method", null) != null
|
||||||
val store = CredentialStore(this)
|
val store = CredentialStore(this)
|
||||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() ||
|
||||||
|
store.hasFahipayCredentials() || store.hasMfaisaCredentials()
|
||||||
|
|
||||||
// Image shared via "Scan to Pay" — decode QR here while we still hold the URI permission
|
// Image shared via "Scan to Pay" — decode QR here while we still hold the URI permission
|
||||||
val shareQrText: String? = if (intent?.action == Intent.ACTION_SEND &&
|
val shareQrText: String? = if (intent?.action == Intent.ACTION_SEND &&
|
||||||
|
|||||||
@@ -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)
|
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
|
|
||||||
// Show default account toggle only for non-card accounts
|
// Show default account toggle only for non-card, non-M-Faisa accounts.
|
||||||
|
// M-Faisa pockets (including PayPal) cannot be set as the default transfer/QR account.
|
||||||
val isCard = AccountListParser.from(account)?.isCard ?: false
|
val isCard = AccountListParser.from(account)?.isCard ?: false
|
||||||
if (!isCard) {
|
if (!isCard && account.bank != "MFAISA") {
|
||||||
val store = CredentialStore(requireContext())
|
val store = CredentialStore(requireContext())
|
||||||
adapter.showDefaultToggle = true
|
adapter.showDefaultToggle = true
|
||||||
adapter.isDefaultAccount = store.getDefaultAccountNumber() == account.accountNumber
|
adapter.isDefaultAccount = store.getDefaultAccountNumber() == account.accountNumber
|
||||||
|
|||||||
@@ -75,10 +75,12 @@ class AccountsAdapter(
|
|||||||
"BML" -> "Bank of Maldives"
|
"BML" -> "Bank of Maldives"
|
||||||
"FAHIPAY" -> "Fahipay"
|
"FAHIPAY" -> "Fahipay"
|
||||||
"MIB" -> "Maldives Islamic Bank"
|
"MIB" -> "Maldives Islamic Bank"
|
||||||
|
"MFAISA" -> if (account.profileType == "MFAISA_PAYPAL") "PayPal · M-Faisa" else "M-Faisa"
|
||||||
else -> account.bank
|
else -> account.bank
|
||||||
}
|
}
|
||||||
val profileLabel = when (account.bank) {
|
val profileLabel = when (account.bank) {
|
||||||
"MIB" -> account.productCode.ifBlank { account.profileName }
|
"MIB" -> account.productCode.ifBlank { account.profileName }
|
||||||
|
"MFAISA" -> "" // bank-level grouping is already specific (M-Faisa / PayPal · M-Faisa)
|
||||||
else -> account.profileName
|
else -> account.profileName
|
||||||
}
|
}
|
||||||
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
|
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
|
||||||
@@ -144,6 +146,7 @@ class AccountsAdapter(
|
|||||||
"BML" -> R.drawable.bml_logo_vector
|
"BML" -> R.drawable.bml_logo_vector
|
||||||
"FAHIPAY" -> R.drawable.fahipay_logo
|
"FAHIPAY" -> R.drawable.fahipay_logo
|
||||||
"MIB" -> R.drawable.mib_logo
|
"MIB" -> R.drawable.mib_logo
|
||||||
|
"MFAISA" -> R.drawable.ooredoo_logo
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (staticLogo != null) binding.ivBankLogo.setImageResource(staticLogo)
|
if (staticLogo != null) binding.ivBankLogo.setImageResource(staticLogo)
|
||||||
|
|||||||
@@ -202,9 +202,9 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load data
|
// Load data
|
||||||
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
|
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty() || app.mfaisaAccounts.isNotEmpty()) {
|
||||||
// Came from fresh manual login — accounts ready, rest fetched in background
|
// Came from fresh manual login — accounts ready, rest fetched in background
|
||||||
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts
|
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts + app.mfaisaAccounts
|
||||||
viewModel.accounts.value = merged.filterVisibleAccounts()
|
viewModel.accounts.value = merged.filterVisibleAccounts()
|
||||||
if (app.mibAccounts.isNotEmpty()) AccountCache.save(this, app.mibAccounts)
|
if (app.mibAccounts.isNotEmpty()) AccountCache.save(this, app.mibAccounts)
|
||||||
if (app.bmlAccounts.isNotEmpty()) {
|
if (app.bmlAccounts.isNotEmpty()) {
|
||||||
@@ -215,6 +215,10 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
val byLoginId = app.fahipayAccounts.groupBy { it.loginTag.removePrefix("fahipay_") }
|
val byLoginId = app.fahipayAccounts.groupBy { it.loginTag.removePrefix("fahipay_") }
|
||||||
byLoginId.forEach { (loginId, accs) -> AccountCache.saveFahipay(this, loginId, accs) }
|
byLoginId.forEach { (loginId, accs) -> AccountCache.saveFahipay(this, loginId, accs) }
|
||||||
}
|
}
|
||||||
|
if (app.mfaisaAccounts.isNotEmpty()) {
|
||||||
|
val byLoginId = app.mfaisaAccounts.groupBy { it.loginTag.removePrefix("mfaisa_") }
|
||||||
|
byLoginId.forEach { (loginId, accs) -> AccountCache.saveMfaisa(this, loginId, accs) }
|
||||||
|
}
|
||||||
|
|
||||||
val cachedCards = CardsCache.load(this)
|
val cachedCards = CardsCache.load(this)
|
||||||
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
||||||
@@ -238,7 +242,8 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
val cachedMib = AccountCache.load(this)
|
val cachedMib = AccountCache.load(this)
|
||||||
val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds())
|
val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds())
|
||||||
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
|
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
|
||||||
val merged = cachedMib + cachedBml + cachedFahipay
|
val cachedMfaisa = AccountCache.loadMfaisa(this, store.getMfaisaLoginIds())
|
||||||
|
val merged = cachedMib + cachedBml + cachedFahipay + cachedMfaisa
|
||||||
if (merged.isNotEmpty()) viewModel.accounts.value = merged
|
if (merged.isNotEmpty()) viewModel.accounts.value = merged
|
||||||
val cachedCards = CardsCache.load(this)
|
val cachedCards = CardsCache.load(this)
|
||||||
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
||||||
@@ -695,17 +700,20 @@ fun applyNavLabelVisibility() {
|
|||||||
val mibLoginIds = store.getMibLoginIds()
|
val mibLoginIds = store.getMibLoginIds()
|
||||||
val bmlLoginIds = store.getBmlLoginIds()
|
val bmlLoginIds = store.getBmlLoginIds()
|
||||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||||
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) {
|
val mfaisaLoginIds = store.getMfaisaLoginIds()
|
||||||
|
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty() && mfaisaLoginIds.isEmpty()) {
|
||||||
startActivity(Intent(this, LoginActivity::class.java))
|
startActivity(Intent(this, LoginActivity::class.java))
|
||||||
finish()
|
finish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Immediately drop accounts for logged-out logins from the displayed list
|
// Immediately drop accounts for logged-out logins from the displayed list
|
||||||
val current = viewModel.accounts.value ?: emptyList()
|
val current = viewModel.accounts.value ?: emptyList()
|
||||||
|
val mfaisaLoginIdsForFilter = store.getMfaisaLoginIds()
|
||||||
viewModel.accounts.value = current.filter { acc ->
|
viewModel.accounts.value = current.filter { acc ->
|
||||||
if (acc.bank == "MIB") return@filter acc.loginTag.removePrefix("mib_") in mibLoginIds
|
if (acc.bank == "MIB") return@filter acc.loginTag.removePrefix("mib_") in mibLoginIds
|
||||||
if (acc.bank == "BML") return@filter acc.loginTag.removePrefix("bml_") in bmlLoginIds
|
if (acc.bank == "BML") return@filter acc.loginTag.removePrefix("bml_") in bmlLoginIds
|
||||||
if (acc.bank == "FAHIPAY") return@filter acc.loginTag.removePrefix("fahipay_") in fahipayLoginIds
|
if (acc.bank == "FAHIPAY") return@filter acc.loginTag.removePrefix("fahipay_") in fahipayLoginIds
|
||||||
|
if (acc.bank == "MFAISA") return@filter acc.loginTag.removePrefix("mfaisa_") in mfaisaLoginIdsForFilter
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
autoRefresh(store)
|
autoRefresh(store)
|
||||||
@@ -724,7 +732,8 @@ fun applyNavLabelVisibility() {
|
|||||||
val mibLoginIds = store.getMibLoginIds()
|
val mibLoginIds = store.getMibLoginIds()
|
||||||
val bmlLoginIds = store.getBmlLoginIds()
|
val bmlLoginIds = store.getBmlLoginIds()
|
||||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||||
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) return
|
val mfaisaLoginIds = store.getMfaisaLoginIds()
|
||||||
|
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty() && mfaisaLoginIds.isEmpty()) return
|
||||||
binding.refreshIndicator.visibility = View.VISIBLE
|
binding.refreshIndicator.visibility = View.VISIBLE
|
||||||
hideConnectivityBanner()
|
hideConnectivityBanner()
|
||||||
|
|
||||||
@@ -898,17 +907,58 @@ fun applyNavLabelVisibility() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One async job per M-Faisa login, all run in parallel.
|
||||||
|
// M-Faisa has no session refresh — sessions expire after ~240s — so we re-login each refresh.
|
||||||
|
val mfaisaJobs = mfaisaLoginIds.mapNotNull { loginId ->
|
||||||
|
val creds = store.loadMfaisaCredentials(loginId) ?: return@mapNotNull null
|
||||||
|
loginId to async(Dispatchers.IO) {
|
||||||
|
val loginTag = "mfaisa_$loginId"
|
||||||
|
val flow = sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow(this@HomeActivity)
|
||||||
|
try {
|
||||||
|
flow.fetchSubscriber(creds.msisdn)
|
||||||
|
val result = flow.doMobileLogin(creds.msisdn, creds.pin)
|
||||||
|
val accounts = sh.sar.basedbank.api.mfaisa.MfaisaAccountClient.buildAccounts(result, loginTag)
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
app.mfaisaSessions[loginId] = result.session
|
||||||
|
store.saveMfaisaUserProfile(
|
||||||
|
loginId,
|
||||||
|
CredentialStore.MfaisaUserProfile(
|
||||||
|
name = result.profile.name,
|
||||||
|
email = result.profile.email,
|
||||||
|
mdnId = result.profile.mdnId,
|
||||||
|
subscriberId = result.profile.subscriberId,
|
||||||
|
walletId = result.profile.walletId,
|
||||||
|
roleId = result.profile.roleId,
|
||||||
|
offerId = result.profile.offerId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AccountCache.saveMfaisa(this@HomeActivity, loginId, accounts)
|
||||||
|
accounts
|
||||||
|
} catch (e: java.io.IOException) {
|
||||||
|
refreshErrors.add("NO_INTERNET")
|
||||||
|
AccountCache.loadMfaisa(this@HomeActivity, loginId)
|
||||||
|
} catch (e: BankServerException) {
|
||||||
|
refreshErrors.add("SERVER:${e.bankName}")
|
||||||
|
AccountCache.loadMfaisa(this@HomeActivity, loginId)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
AccountCache.loadMfaisa(this@HomeActivity, loginId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() }
|
val mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() }
|
||||||
val mibAccounts = mibResults.flatMap { it.second }
|
val mibAccounts = mibResults.flatMap { it.second }
|
||||||
val bmlAccounts = bmlJobs.flatMap { (_, job) -> job.await() }
|
val bmlAccounts = bmlJobs.flatMap { (_, job) -> job.await() }
|
||||||
val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() }
|
val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() }
|
||||||
|
val mfaisaAccounts = mfaisaJobs.flatMap { (_, job) -> job.await() }
|
||||||
|
|
||||||
val app = application as BasedBankApp
|
val app = application as BasedBankApp
|
||||||
app.mibAccounts = mibAccounts
|
app.mibAccounts = mibAccounts
|
||||||
AccountCache.save(this@HomeActivity, mibAccounts)
|
AccountCache.save(this@HomeActivity, mibAccounts)
|
||||||
app.bmlAccounts = bmlAccounts
|
app.bmlAccounts = bmlAccounts
|
||||||
app.fahipayAccounts = fahipayAccounts
|
app.fahipayAccounts = fahipayAccounts
|
||||||
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts())
|
app.mfaisaAccounts = mfaisaAccounts
|
||||||
|
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts + mfaisaAccounts).filterVisibleAccounts())
|
||||||
binding.refreshIndicator.visibility = View.GONE
|
binding.refreshIndicator.visibility = View.GONE
|
||||||
|
|
||||||
val noInternet = refreshErrors.any { it == "NO_INTERNET" }
|
val noInternet = refreshErrors.any { it == "NO_INTERNET" }
|
||||||
@@ -955,6 +1005,11 @@ fun applyNavLabelVisibility() {
|
|||||||
val hidden = store.getHiddenBmlProfileIds(loginId)
|
val hidden = store.getHiddenBmlProfileIds(loginId)
|
||||||
hidden.isEmpty() || acc.profileId !in hidden
|
hidden.isEmpty() || acc.profileId !in hidden
|
||||||
}
|
}
|
||||||
|
"MFAISA" -> {
|
||||||
|
val loginId = acc.loginTag.removePrefix("mfaisa_")
|
||||||
|
val hidden = store.getHiddenMfaisaPocketIds(loginId)
|
||||||
|
hidden.isEmpty() || acc.accountNumber !in hidden
|
||||||
|
}
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -368,8 +368,9 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
val mibLoginIds = store.getMibLoginIds()
|
val mibLoginIds = store.getMibLoginIds()
|
||||||
val bmlLoginIds = store.getBmlLoginIds()
|
val bmlLoginIds = store.getBmlLoginIds()
|
||||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
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) {
|
for (loginId in mibLoginIds) {
|
||||||
val profile = store.loadMibUserProfile(loginId)
|
val profile = store.loadMibUserProfile(loginId)
|
||||||
@@ -396,6 +397,14 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
showFahipayLoginDetails(store, loginId, profile)
|
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) {
|
private fun addLoginRow(container: LinearLayout, logoRes: Int, displayName: String, onClick: () -> Unit) {
|
||||||
@@ -1065,6 +1074,152 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
buildLoginsSection()
|
buildLoginsSection()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showMfaisaLoginDetails(
|
||||||
|
store: CredentialStore,
|
||||||
|
loginId: String,
|
||||||
|
profile: CredentialStore.MfaisaUserProfile?
|
||||||
|
) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
val masked = "••••••"
|
||||||
|
|
||||||
|
val pockets = sh.sar.basedbank.util.AccountCache.loadMfaisa(ctx, loginId)
|
||||||
|
val hidden = store.getHiddenMfaisaPocketIds(loginId).toMutableSet()
|
||||||
|
val originalHidden = hidden.toSet()
|
||||||
|
|
||||||
|
// The user-visible "profiles" are: M-Faisa (every non-PayPal pocket) and PayPal (if linked).
|
||||||
|
// Each toggle covers the set of pocket account numbers that belong to that profile.
|
||||||
|
data class MfaisaProfileRow(val label: String, val pocketIds: Set<String>)
|
||||||
|
val mfaisaPockets = pockets.filter { it.profileType != "MFAISA_PAYPAL" }
|
||||||
|
val paypalPockets = pockets.filter { it.profileType == "MFAISA_PAYPAL" }
|
||||||
|
val profileRows = buildList {
|
||||||
|
if (mfaisaPockets.isNotEmpty()) {
|
||||||
|
add(MfaisaProfileRow("M-Faisa", mfaisaPockets.map { it.accountNumber }.toSet()))
|
||||||
|
}
|
||||||
|
if (paypalPockets.isNotEmpty()) {
|
||||||
|
add(MfaisaProfileRow("PayPal", paypalPockets.map { it.accountNumber }.toSet()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val scroll = android.widget.ScrollView(ctx)
|
||||||
|
val container = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val pad = (16 * dp).toInt()
|
||||||
|
setPadding(pad, (8 * dp).toInt(), pad, pad)
|
||||||
|
}
|
||||||
|
scroll.addView(container)
|
||||||
|
|
||||||
|
listOfNotNull(
|
||||||
|
profile?.name?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
|
||||||
|
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: ${if (hide) masked else it}" },
|
||||||
|
profile?.mdnId?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: ${if (hide) masked else it}" }
|
||||||
|
).forEach { line ->
|
||||||
|
container.addView(TextView(ctx).apply {
|
||||||
|
text = line
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
bottomMargin = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileRows.isNotEmpty()) {
|
||||||
|
if (profile != null) {
|
||||||
|
container.addView(View(ctx).apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
|
||||||
|
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
setBackgroundColor(0x1F000000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
container.addView(TextView(ctx).apply {
|
||||||
|
text = getString(R.string.login_detail_profiles)
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||||
|
it.bottomMargin = (8 * dp).toInt()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
val toggleRows = profileRows.map { row ->
|
||||||
|
val v = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||||
|
it.bottomMargin = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val label = TextView(ctx).apply {
|
||||||
|
text = row.label
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
|
}
|
||||||
|
val toggle = MaterialSwitch(ctx).apply {
|
||||||
|
isChecked = row.pocketIds.any { it !in hidden }
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
marginStart = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.addView(label)
|
||||||
|
v.addView(toggle)
|
||||||
|
container.addView(v)
|
||||||
|
row to toggle
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||||
|
val visibleCount = toggleRows.count { (row, _) -> row.pocketIds.any { it !in hidden } }
|
||||||
|
toggleRows.forEach { (_, toggle) ->
|
||||||
|
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||||
|
}
|
||||||
|
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(getString(R.string.ooredoo_name))
|
||||||
|
.setView(scroll)
|
||||||
|
.apply {
|
||||||
|
if (profileRows.isNotEmpty()) setPositiveButton(R.string.save, null)
|
||||||
|
setNeutralButton(R.string.close, null)
|
||||||
|
setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||||
|
confirmLogout(getString(R.string.ooredoo_name)) { logoutMfaisa(store, loginId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
|
||||||
|
if (profileRows.isNotEmpty()) {
|
||||||
|
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||||
|
saveBtn.isEnabled = false
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
|
||||||
|
toggleRows.forEach { (row, toggle) ->
|
||||||
|
toggle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (checked) hidden.removeAll(row.pocketIds) else hidden.addAll(row.pocketIds)
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveBtn.setOnClickListener {
|
||||||
|
store.setHiddenMfaisaPocketIds(loginId, hidden)
|
||||||
|
clearAllCaches(ctx)
|
||||||
|
dialog.dismiss()
|
||||||
|
(activity as? HomeActivity)?.relogin()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logoutMfaisa(store: CredentialStore, loginId: String) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
store.clearMfaisaCredentials(loginId)
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
app.mfaisaSessions.remove(loginId)
|
||||||
|
app.mfaisaAccounts = app.mfaisaAccounts.filter { it.loginTag != "mfaisa_$loginId" }
|
||||||
|
app.accounts = app.accounts.filter { it.loginTag != "mfaisa_$loginId" }
|
||||||
|
clearAllCaches(ctx)
|
||||||
|
(activity as HomeActivity).relogin()
|
||||||
|
buildLoginsSection()
|
||||||
|
}
|
||||||
|
|
||||||
private fun clearAllCaches(ctx: Context) {
|
private fun clearAllCaches(ctx: Context) {
|
||||||
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
||||||
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import sh.sar.basedbank.api.mib.MibTransferResult
|
|||||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||||
import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
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.AccountListParser
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.util.AccountInputParser
|
import sh.sar.basedbank.util.AccountInputParser
|
||||||
@@ -130,6 +131,39 @@ class TransferFragment : Fragment() {
|
|||||||
private var pendingBmlTransfer: PendingBmlTransfer? = null
|
private var pendingBmlTransfer: PendingBmlTransfer? = null
|
||||||
private var accountDropdownAdapter: AccountDropdownAdapter? = 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 ->
|
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
@@ -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) {
|
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) {
|
val colorHex = when (account.bank) {
|
||||||
"BML" -> "#0066A1"
|
"BML" -> "#0066A1"
|
||||||
"FAHIPAY" -> "#15BEA7"
|
"FAHIPAY" -> "#15BEA7"
|
||||||
|
"MFAISA" -> "#ED1C24"
|
||||||
else -> "#FE860E"
|
else -> "#FE860E"
|
||||||
}
|
}
|
||||||
val bankLabel = when (account.bank) {
|
val bankLabel = when (account.bank) {
|
||||||
"BML" -> "BML"
|
"BML" -> "BML"
|
||||||
"FAHIPAY" -> "FP"
|
"FAHIPAY" -> "FP"
|
||||||
"MIB" -> "MIB"
|
"MIB" -> "MIB"
|
||||||
|
"MFAISA" -> "M-Faisa"
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
val typeLabel = AccountListParser.from(account)?.typeLabel
|
val typeLabel = AccountListParser.from(account)?.typeLabel
|
||||||
@@ -561,11 +639,18 @@ class TransferFragment : Fragment() {
|
|||||||
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
binding.ivFromPhoto.setImageResource(R.drawable.fahipay_logo)
|
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.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
binding.ivFromPhoto.setImageResource(R.drawable.mib_logo)
|
binding.ivFromPhoto.setImageResource(R.drawable.mib_logo)
|
||||||
if (account.profileImageHash != null) loadFromPhoto(account.profileImageHash)
|
if (account.profileImageHash != null) loadFromPhoto(account.profileImageHash)
|
||||||
}
|
}
|
||||||
|
else -> {
|
||||||
|
binding.ivFromPhoto.setImageDrawable(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
binding.tilFrom.visibility = View.GONE
|
binding.tilFrom.visibility = View.GONE
|
||||||
binding.cardFromInfo.visibility = View.VISIBLE
|
binding.cardFromInfo.visibility = View.VISIBLE
|
||||||
@@ -618,11 +703,18 @@ class TransferFragment : Fragment() {
|
|||||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
binding.ivToPhoto.setImageResource(R.drawable.fahipay_logo)
|
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.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
binding.ivToPhoto.setImageResource(R.drawable.mib_logo)
|
binding.ivToPhoto.setImageResource(R.drawable.mib_logo)
|
||||||
if (account.profileImageHash != null) loadToPhoto(account.profileImageHash, isProfile = true)
|
if (account.profileImageHash != null) loadToPhoto(account.profileImageHash, isProfile = true)
|
||||||
}
|
}
|
||||||
|
else -> {
|
||||||
|
binding.ivToPhoto.setImageDrawable(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,7 +741,14 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun setupAccountLookup() {
|
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 {
|
binding.btnClearToInfo.setOnClickListener {
|
||||||
if (bmlQrInfo != null) {
|
if (bmlQrInfo != null) {
|
||||||
@@ -665,12 +764,15 @@ class TransferFragment : Fragment() {
|
|||||||
resolvedDestCurrency = ""
|
resolvedDestCurrency = ""
|
||||||
resolvedToOwnAccount = null
|
resolvedToOwnAccount = null
|
||||||
selectedFahipayService = null
|
selectedFahipayService = null
|
||||||
|
mfaisaHandler?.clearState()
|
||||||
binding.cardToInfo.visibility = View.GONE
|
binding.cardToInfo.visibility = View.GONE
|
||||||
binding.layoutServiceSelector.visibility = View.INVISIBLE
|
binding.layoutServiceSelector.visibility = View.INVISIBLE
|
||||||
binding.tilTo.visibility = View.VISIBLE
|
binding.tilTo.visibility = View.VISIBLE
|
||||||
binding.btnPickContact.visibility = View.VISIBLE
|
binding.btnPickContact.visibility = View.VISIBLE
|
||||||
binding.btnScanQr.visibility = View.VISIBLE
|
binding.btnScanQr.visibility = View.VISIBLE
|
||||||
binding.tilTo.error = null
|
binding.tilTo.error = null
|
||||||
|
// Re-apply MFAISA-mode if needed (hides QR/contact picker + sets phone keyboard)
|
||||||
|
applyMfaisaToFieldMode(selectedAccount?.bank == "MFAISA")
|
||||||
updateTransferButton()
|
updateTransferButton()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -681,10 +783,12 @@ class TransferFragment : Fragment() {
|
|||||||
resolvedRecipientName = ""
|
resolvedRecipientName = ""
|
||||||
resolvedDestCurrency = ""
|
resolvedDestCurrency = ""
|
||||||
resolvedToOwnAccount = null
|
resolvedToOwnAccount = null
|
||||||
|
mfaisaHandler?.clearState()
|
||||||
binding.cardToInfo.visibility = View.GONE
|
binding.cardToInfo.visibility = View.GONE
|
||||||
binding.tilTo.visibility = View.VISIBLE
|
binding.tilTo.visibility = View.VISIBLE
|
||||||
binding.btnPickContact.visibility = View.VISIBLE
|
binding.btnPickContact.visibility = View.VISIBLE
|
||||||
binding.btnScanQr.visibility = View.VISIBLE
|
binding.btnScanQr.visibility = View.VISIBLE
|
||||||
|
applyMfaisaToFieldMode(selectedAccount?.bank == "MFAISA")
|
||||||
updateTransferButton()
|
updateTransferButton()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1083,6 +1187,12 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun initiateTransfer() {
|
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
|
// BML QR merchant payment — uses shared confirm dialog, no receipt
|
||||||
bmlQrInfo?.let { info ->
|
bmlQrInfo?.let { info ->
|
||||||
val src = selectedAccount ?: run {
|
val src = selectedAccount ?: run {
|
||||||
@@ -2075,6 +2185,7 @@ class TransferFragment : Fragment() {
|
|||||||
|
|
||||||
private fun clearForm() {
|
private fun clearForm() {
|
||||||
resetBmlOtpState()
|
resetBmlOtpState()
|
||||||
|
mfaisaHandler?.clearState()
|
||||||
selectedAccount = null
|
selectedAccount = null
|
||||||
binding.actvFrom.setText("", false)
|
binding.actvFrom.setText("", false)
|
||||||
binding.cardFromInfo.visibility = View.GONE
|
binding.cardFromInfo.visibility = View.GONE
|
||||||
@@ -2195,6 +2306,9 @@ class TransferFragment : Fragment() {
|
|||||||
bmlOtpState = BmlOtpState.NONE
|
bmlOtpState = BmlOtpState.NONE
|
||||||
pendingBmlTransfer = null
|
pendingBmlTransfer = null
|
||||||
bmlOtpChannel = 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
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import sh.sar.basedbank.BasedBankApp
|
|||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
||||||
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaHistoryClient
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||||
@@ -61,12 +62,18 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
var bmlTotalPages: Int = -1,
|
var bmlTotalPages: Int = -1,
|
||||||
var cardMonthOffset: Int = 0,
|
var cardMonthOffset: Int = 0,
|
||||||
var fahipayNextStart: Int = 0,
|
var fahipayNextStart: Int = 0,
|
||||||
var fahipayTotal: Int = -1
|
var fahipayTotal: Int = -1,
|
||||||
|
var mfaisaNextPage: Int = 1,
|
||||||
|
var mfaisaHasMore: Boolean = true
|
||||||
) {
|
) {
|
||||||
|
// PayPal pockets have no known history endpoint, so they don't paginate here.
|
||||||
|
private val isMfaisaPaypal get() = account.profileType == "MFAISA_PAYPAL"
|
||||||
|
|
||||||
fun hasMore(): Boolean = when {
|
fun hasMore(): Boolean = when {
|
||||||
account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||||
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT" -> cardMonthOffset < 2
|
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT" -> cardMonthOffset < 2
|
||||||
account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||||
|
account.bank == "MFAISA" -> !isMfaisaPaypal && mfaisaHasMore
|
||||||
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,6 +243,36 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}.awaitAll().flatten())
|
}.awaitAll().flatten())
|
||||||
|
|
||||||
|
// M-Faisa accounts (PayPal pockets are skipped by hasMore())
|
||||||
|
val mfaisaStates = activeStates.filter { it.account.bank == "MFAISA" }
|
||||||
|
for (state in mfaisaStates) {
|
||||||
|
var session = app.mfaisaSessionFor(state.account) ?: continue
|
||||||
|
try {
|
||||||
|
val page = try {
|
||||||
|
MfaisaHistoryClient().fetchHistory(
|
||||||
|
session = session,
|
||||||
|
accountNumber = state.account.accountNumber,
|
||||||
|
accountDisplayName = state.account.accountBriefName,
|
||||||
|
pageNo = state.mfaisaNextPage,
|
||||||
|
recordSize = 70
|
||||||
|
)
|
||||||
|
} catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) {
|
||||||
|
val loginId = state.account.loginTag.removePrefix("mfaisa_")
|
||||||
|
session = app.refreshMfaisaSession(loginId) ?: continue
|
||||||
|
MfaisaHistoryClient().fetchHistory(
|
||||||
|
session = session,
|
||||||
|
accountNumber = state.account.accountNumber,
|
||||||
|
accountDisplayName = state.account.accountBriefName,
|
||||||
|
pageNo = state.mfaisaNextPage,
|
||||||
|
recordSize = 70
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state.mfaisaHasMore = page.hasMore
|
||||||
|
state.mfaisaNextPage++
|
||||||
|
results.addAll(page.transactions)
|
||||||
|
} catch (e: Exception) { trackError(e) }
|
||||||
|
}
|
||||||
|
|
||||||
// Fahipay accounts
|
// Fahipay accounts
|
||||||
val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" }
|
val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" }
|
||||||
for (state in fahipayStates) {
|
for (state in fahipayStates) {
|
||||||
|
|||||||
@@ -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.FahipayAccountClient
|
||||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaAccountClient
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaInvalidPinException
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaKycRequiredException
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaNotRegisteredException
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaWalletNotReadyException
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||||
import sh.sar.basedbank.api.mib.MibProfileClient
|
import sh.sar.basedbank.api.mib.MibProfileClient
|
||||||
@@ -218,10 +224,7 @@ class CredentialsFragment : Fragment() {
|
|||||||
when (bankType) {
|
when (bankType) {
|
||||||
"BML" -> { attemptBmlLogin(); return }
|
"BML" -> { attemptBmlLogin(); return }
|
||||||
"FAHIPAY" -> { attemptFahipayLogin(); return }
|
"FAHIPAY" -> { attemptFahipayLogin(); return }
|
||||||
"OOREDOO" -> {
|
"OOREDOO" -> { attemptMfaisaLogin(); return }
|
||||||
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val username = binding.etUsername.text.toString().trim()
|
val username = binding.etUsername.text.toString().trim()
|
||||||
@@ -429,6 +432,97 @@ class CredentialsFragment : Fragment() {
|
|||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun attemptMfaisaLogin() {
|
||||||
|
val msisdn = binding.etUsername.text.toString().trim()
|
||||||
|
val pin = binding.etPassword.text.toString()
|
||||||
|
|
||||||
|
if (msisdn.isEmpty() || pin.length != 4) {
|
||||||
|
binding.tvError.text = "Please enter your phone number and 4-digit mPIN"
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.tvError.visibility = View.GONE
|
||||||
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
|
binding.btnLogin.isEnabled = false
|
||||||
|
binding.etUsername.isEnabled = false
|
||||||
|
binding.etPassword.isEnabled = false
|
||||||
|
|
||||||
|
val store = CredentialStore(requireContext())
|
||||||
|
val flow = MfaisaLoginFlow(requireContext())
|
||||||
|
val loginTag = "mfaisa_$msisdn"
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) { flow.fetchSubscriber(msisdn) }
|
||||||
|
val result = withContext(Dispatchers.IO) { flow.doMobileLogin(msisdn, pin) }
|
||||||
|
val accounts = MfaisaAccountClient.buildAccounts(result, loginTag)
|
||||||
|
|
||||||
|
store.saveMfaisaCredentials(msisdn, msisdn, pin)
|
||||||
|
store.saveMfaisaUserProfile(
|
||||||
|
msisdn,
|
||||||
|
CredentialStore.MfaisaUserProfile(
|
||||||
|
name = result.profile.name,
|
||||||
|
email = result.profile.email,
|
||||||
|
mdnId = result.profile.mdnId,
|
||||||
|
subscriberId = result.profile.subscriberId,
|
||||||
|
walletId = result.profile.walletId,
|
||||||
|
roleId = result.profile.roleId,
|
||||||
|
offerId = result.profile.offerId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AccountCache.saveMfaisa(requireContext(), msisdn, accounts)
|
||||||
|
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
app.mfaisaSessions[msisdn] = result.session
|
||||||
|
app.mfaisaAccounts = app.mfaisaAccounts.filter { it.loginTag != loginTag } + accounts
|
||||||
|
app.accounts = app.accounts.filter { it.loginTag != loginTag } + accounts
|
||||||
|
app.isUnlocked = true
|
||||||
|
|
||||||
|
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
startActivity(intent)
|
||||||
|
} catch (e: MfaisaNotRegisteredException) {
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle("User not registered")
|
||||||
|
.setMessage("Please use the Ooredoo SuperApp to register your M-Faisa wallet and complete KYC, then come back to Thijooree.")
|
||||||
|
.setPositiveButton("OK", null)
|
||||||
|
.show()
|
||||||
|
binding.tvError.visibility = View.GONE
|
||||||
|
} catch (e: MfaisaKycRequiredException) {
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle("KYC incomplete")
|
||||||
|
.setMessage("Your M-Faisa wallet needs Full KYC. Please complete KYC in the Ooredoo SuperApp, then come back to Thijooree.")
|
||||||
|
.setPositiveButton("OK", null)
|
||||||
|
.show()
|
||||||
|
binding.tvError.visibility = View.GONE
|
||||||
|
} catch (e: MfaisaWalletNotReadyException) {
|
||||||
|
binding.tvError.text = e.message
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
} catch (e: MfaisaInvalidPinException) {
|
||||||
|
val message = if (e.lastAttempt)
|
||||||
|
"${e.message}\n\nOne more wrong mPIN will lock your account."
|
||||||
|
else
|
||||||
|
e.message
|
||||||
|
binding.tvError.text = message ?: "Incorrect mPIN"
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
// Re-enable PIN input only — leave phone number locked
|
||||||
|
binding.etPassword.isEnabled = true
|
||||||
|
binding.etPassword.setText("")
|
||||||
|
binding.etPassword.requestFocus()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
binding.tvError.text = e.message ?: "Login failed"
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
binding.etUsername.isEnabled = true
|
||||||
|
binding.etPassword.isEnabled = true
|
||||||
|
} finally {
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.btnLogin.isEnabled = true
|
||||||
|
// After PIN error, keep phone disabled; for any other resolution above we already restored as needed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun attemptFahipayLogin() {
|
private fun attemptFahipayLogin() {
|
||||||
if (fahipayAwaitingTotp) {
|
if (fahipayAwaitingTotp) {
|
||||||
submitFahipayTotp()
|
submitFahipayTotp()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ object AccountCache {
|
|||||||
private const val KEY_MIB = "mib_accounts"
|
private const val KEY_MIB = "mib_accounts"
|
||||||
private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
|
private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
|
||||||
private fun fahipayKey(loginId: String) = "fahipay_accounts_$loginId"
|
private fun fahipayKey(loginId: String) = "fahipay_accounts_$loginId"
|
||||||
|
private fun mfaisaKey(loginId: String) = "mfaisa_accounts_$loginId"
|
||||||
|
|
||||||
fun save(context: Context, accounts: List<BankAccount>) {
|
fun save(context: Context, accounts: List<BankAccount>) {
|
||||||
val arr = JSONArray()
|
val arr = JSONArray()
|
||||||
@@ -150,6 +151,62 @@ object AccountCache {
|
|||||||
fun loadFahipay(context: Context, loginIds: List<String>): List<BankAccount> =
|
fun loadFahipay(context: Context, loginIds: List<String>): List<BankAccount> =
|
||||||
loginIds.flatMap { loadFahipay(context, it) }
|
loginIds.flatMap { loadFahipay(context, it) }
|
||||||
|
|
||||||
|
fun saveMfaisa(context: Context, loginId: String, accounts: List<BankAccount>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
for (acc in accounts) {
|
||||||
|
arr.put(JSONObject().apply {
|
||||||
|
put("profileName", acc.profileName)
|
||||||
|
put("profileType", acc.profileType)
|
||||||
|
put("accountNumber", acc.accountNumber)
|
||||||
|
put("accountBriefName", acc.accountBriefName)
|
||||||
|
put("currencyName", acc.currencyName)
|
||||||
|
put("accountTypeName", acc.accountTypeName)
|
||||||
|
put("availableBalance", acc.availableBalance)
|
||||||
|
put("currentBalance", acc.currentBalance)
|
||||||
|
put("blockedAmount", acc.blockedAmount)
|
||||||
|
put("mvrBalance", acc.mvrBalance)
|
||||||
|
put("statusDesc", acc.statusDesc)
|
||||||
|
put("loginTag", acc.loginTag)
|
||||||
|
put("profileId", acc.profileId)
|
||||||
|
put("internalId", acc.internalId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit().putString(mfaisaKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMfaisa(context: Context, loginId: String): List<BankAccount> {
|
||||||
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.getString(mfaisaKey(loginId), null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
BankAccount(
|
||||||
|
bank = "MFAISA",
|
||||||
|
profileName = o.optString("profileName"),
|
||||||
|
profileType = o.optString("profileType"),
|
||||||
|
accountNumber = o.optString("accountNumber"),
|
||||||
|
accountBriefName = o.optString("accountBriefName"),
|
||||||
|
currencyName = o.optString("currencyName"),
|
||||||
|
accountTypeName = o.optString("accountTypeName"),
|
||||||
|
availableBalance = o.optString("availableBalance"),
|
||||||
|
currentBalance = o.optString("currentBalance"),
|
||||||
|
blockedAmount = o.optString("blockedAmount"),
|
||||||
|
mvrBalance = o.optString("mvrBalance"),
|
||||||
|
statusDesc = o.optString("statusDesc"),
|
||||||
|
profileImageHash = null,
|
||||||
|
loginTag = o.optString("loginTag"),
|
||||||
|
profileId = o.optString("profileId", ""),
|
||||||
|
internalId = o.optString("internalId", "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMfaisa(context: Context, loginIds: List<String>): List<BankAccount> =
|
||||||
|
loginIds.flatMap { loadMfaisa(context, it) }
|
||||||
|
|
||||||
fun clear(context: Context) {
|
fun clear(context: Context) {
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package sh.sar.basedbank.util
|
|||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlHistoryParser
|
import sh.sar.basedbank.util.bmlapi.BmlHistoryParser
|
||||||
import sh.sar.basedbank.util.fahipayapi.FahipayHistoryParser
|
import sh.sar.basedbank.util.fahipayapi.FahipayHistoryParser
|
||||||
|
import sh.sar.basedbank.util.mfaisaapi.MfaisaHistoryParser
|
||||||
import sh.sar.basedbank.util.mibapi.MibHistoryParser
|
import sh.sar.basedbank.util.mibapi.MibHistoryParser
|
||||||
|
|
||||||
object AccountHistoryParser {
|
object AccountHistoryParser {
|
||||||
@@ -11,6 +12,7 @@ object AccountHistoryParser {
|
|||||||
"BML" -> BmlHistoryParser.displayData(account)
|
"BML" -> BmlHistoryParser.displayData(account)
|
||||||
"FAHIPAY" -> FahipayHistoryParser.displayData(account)
|
"FAHIPAY" -> FahipayHistoryParser.displayData(account)
|
||||||
"MIB" -> MibHistoryParser.displayData(account)
|
"MIB" -> MibHistoryParser.displayData(account)
|
||||||
|
"MFAISA" -> MfaisaHistoryParser.displayData(account)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package sh.sar.basedbank.util
|
|||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||||
import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser
|
import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser
|
||||||
|
import sh.sar.basedbank.util.mfaisaapi.MfaisaAccountParser
|
||||||
import sh.sar.basedbank.util.mibapi.MibAccountParser
|
import sh.sar.basedbank.util.mibapi.MibAccountParser
|
||||||
|
|
||||||
object AccountListParser {
|
object AccountListParser {
|
||||||
@@ -11,6 +12,7 @@ object AccountListParser {
|
|||||||
"BML" -> BmlDashboardParser.displayData(account)
|
"BML" -> BmlDashboardParser.displayData(account)
|
||||||
"FAHIPAY" -> FahipayAccountParser.displayData(account)
|
"FAHIPAY" -> FahipayAccountParser.displayData(account)
|
||||||
"MIB" -> MibAccountParser.displayData(account)
|
"MIB" -> MibAccountParser.displayData(account)
|
||||||
|
"MFAISA" -> MfaisaAccountParser.displayData(account)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class CredentialStore(context: Context) {
|
|||||||
data class MibCredentials(val username: String, val passwordHash: String, val otpSeed: String)
|
data class MibCredentials(val username: String, val passwordHash: String, val otpSeed: String)
|
||||||
data class BmlCredentials(val username: String, val password: String, val otpSeed: String)
|
data class BmlCredentials(val username: String, val password: String, val otpSeed: String)
|
||||||
data class FahipayCredentials(val idCard: String, val password: String)
|
data class FahipayCredentials(val idCard: String, val password: String)
|
||||||
|
data class MfaisaCredentials(val msisdn: String, val pin: String)
|
||||||
|
|
||||||
// ── MIB login credentials (multi-login, keyed by loginId = username) ─────
|
// ── MIB login credentials (multi-login, keyed by loginId = username) ─────
|
||||||
|
|
||||||
@@ -460,6 +461,110 @@ class CredentialStore(context: Context) {
|
|||||||
editor.apply()
|
editor.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── M-Faisa login credentials (multi-login, keyed by loginId = msisdn) ───
|
||||||
|
|
||||||
|
fun getMfaisaLoginIds(): List<String> {
|
||||||
|
val json = prefs.getString("mfaisa_login_ids", null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = org.json.JSONArray(json)
|
||||||
|
(0 until arr.length()).map { arr.getString(it) }
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hasMfaisaCredentials(): Boolean = getMfaisaLoginIds().isNotEmpty()
|
||||||
|
|
||||||
|
private fun addMfaisaLoginId(loginId: String) {
|
||||||
|
val ids = getMfaisaLoginIds().toMutableList()
|
||||||
|
if (loginId !in ids) {
|
||||||
|
ids.add(loginId)
|
||||||
|
prefs.edit().putString("mfaisa_login_ids", org.json.JSONArray(ids).toString()).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeMfaisaLoginId(loginId: String) {
|
||||||
|
val ids = getMfaisaLoginIds().toMutableList()
|
||||||
|
if (ids.remove(loginId))
|
||||||
|
prefs.edit().putString("mfaisa_login_ids", org.json.JSONArray(ids).toString()).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveMfaisaCredentials(loginId: String, msisdn: String, pin: String) {
|
||||||
|
addMfaisaLoginId(loginId)
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
prefs.edit()
|
||||||
|
.putString("mfaisa_${loginId}_enc_msisdn", encrypt(msisdn, key))
|
||||||
|
.putString("mfaisa_${loginId}_enc_pin", encrypt(pin, key))
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMfaisaCredentials(loginId: String): MfaisaCredentials? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val encMsisdn = prefs.getString("mfaisa_${loginId}_enc_msisdn", null) ?: return null
|
||||||
|
val encPin = prefs.getString("mfaisa_${loginId}_enc_pin", null) ?: return null
|
||||||
|
return try {
|
||||||
|
MfaisaCredentials(decrypt(encMsisdn, key), decrypt(encPin, key))
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearMfaisaCredentials(loginId: String) {
|
||||||
|
removeMfaisaLoginId(loginId)
|
||||||
|
prefs.edit()
|
||||||
|
.remove("mfaisa_${loginId}_enc_msisdn")
|
||||||
|
.remove("mfaisa_${loginId}_enc_pin")
|
||||||
|
.remove("mfaisa_${loginId}_enc_profile")
|
||||||
|
.remove("mfaisa_${loginId}_hidden_pockets")
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pocket-level hide flags (parallels [getHiddenBmlProfileIds]). Keyed by pocket ID. */
|
||||||
|
fun getHiddenMfaisaPocketIds(loginId: String): Set<String> =
|
||||||
|
prefs.getStringSet("mfaisa_${loginId}_hidden_pockets", emptySet()) ?: emptySet()
|
||||||
|
|
||||||
|
fun setHiddenMfaisaPocketIds(loginId: String, ids: Set<String>) =
|
||||||
|
prefs.edit().putStringSet("mfaisa_${loginId}_hidden_pockets", ids).apply()
|
||||||
|
|
||||||
|
// ── M-Faisa user profile (per loginId) ────────────────────────────────────
|
||||||
|
|
||||||
|
data class MfaisaUserProfile(
|
||||||
|
val name: String,
|
||||||
|
val email: String,
|
||||||
|
val mdnId: String,
|
||||||
|
val subscriberId: String,
|
||||||
|
val walletId: String,
|
||||||
|
val roleId: String,
|
||||||
|
val offerId: String
|
||||||
|
)
|
||||||
|
|
||||||
|
fun saveMfaisaUserProfile(loginId: String, p: MfaisaUserProfile) {
|
||||||
|
val json = org.json.JSONObject().apply {
|
||||||
|
put("name", p.name)
|
||||||
|
put("email", p.email)
|
||||||
|
put("mdnId", p.mdnId)
|
||||||
|
put("subscriberId", p.subscriberId)
|
||||||
|
put("walletId", p.walletId)
|
||||||
|
put("roleId", p.roleId)
|
||||||
|
put("offerId", p.offerId)
|
||||||
|
}.toString()
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
prefs.edit().putString("mfaisa_${loginId}_enc_profile", encrypt(json, key)).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMfaisaUserProfile(loginId: String): MfaisaUserProfile? {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val enc = prefs.getString("mfaisa_${loginId}_enc_profile", null) ?: return null
|
||||||
|
return try {
|
||||||
|
val o = org.json.JSONObject(decrypt(enc, key))
|
||||||
|
MfaisaUserProfile(
|
||||||
|
name = o.optString("name"),
|
||||||
|
email = o.optString("email"),
|
||||||
|
mdnId = o.optString("mdnId"),
|
||||||
|
subscriberId = o.optString("subscriberId"),
|
||||||
|
walletId = o.optString("walletId"),
|
||||||
|
roleId = o.optString("roleId"),
|
||||||
|
offerId = o.optString("offerId")
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Security credential (PIN / pattern hash) ──────────────────────────────
|
// ── Security credential (PIN / pattern hash) ──────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
||||||
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaHistoryClient
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||||
import sh.sar.basedbank.api.models.BankTransaction
|
import sh.sar.basedbank.api.models.BankTransaction
|
||||||
@@ -24,6 +25,8 @@ class HistoryFetcher(private val account: BankAccount) {
|
|||||||
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
|
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
|
||||||
private val isBmlLoan get() = account.profileType == "BML_LOAN"
|
private val isBmlLoan get() = account.profileType == "BML_LOAN"
|
||||||
private val isFahipay get() = account.bank == "FAHIPAY"
|
private val isFahipay get() = account.bank == "FAHIPAY"
|
||||||
|
private val isMfaisa get() = account.bank == "MFAISA"
|
||||||
|
private val isMfaisaPaypal get() = account.profileType == "MFAISA_PAYPAL"
|
||||||
|
|
||||||
// MIB pagination
|
// MIB pagination
|
||||||
private var mibNextStart = 1
|
private var mibNextStart = 1
|
||||||
@@ -55,12 +58,19 @@ class HistoryFetcher(private val account: BankAccount) {
|
|||||||
private var fahipayNextStart = 0
|
private var fahipayNextStart = 0
|
||||||
private var fahipayTotal = -1
|
private var fahipayTotal = -1
|
||||||
|
|
||||||
|
// M-Faisa pagination — the server doesn't return a "total" field, so we infer "more pages exist"
|
||||||
|
// from whether the last page was full-sized.
|
||||||
|
private var mfaisaNextPage = 1
|
||||||
|
private var mfaisaHasMore = true
|
||||||
|
|
||||||
fun hasMore(): Boolean = when {
|
fun hasMore(): Boolean = when {
|
||||||
isBmlLoan -> false
|
isBmlLoan -> false
|
||||||
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
isMfaisaPaypal -> false // PayPal pockets have no known history endpoint
|
||||||
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||||
isBmlCard -> cardMonthOffset < 3
|
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||||
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
isBmlCard -> cardMonthOffset < 3
|
||||||
|
isMfaisa -> mfaisaHasMore
|
||||||
|
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<BankTransaction> = when {
|
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<BankTransaction> = when {
|
||||||
@@ -68,9 +78,36 @@ class HistoryFetcher(private val account: BankAccount) {
|
|||||||
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
|
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
|
||||||
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
|
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
|
||||||
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
|
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
|
||||||
|
isMfaisa -> withContext(Dispatchers.IO) { fetchMfaisa(app) }
|
||||||
else -> withContext(Dispatchers.IO) { fetchBmlCasa(app) }
|
else -> withContext(Dispatchers.IO) { fetchBmlCasa(app) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun fetchMfaisa(app: BasedBankApp): List<BankTransaction> {
|
||||||
|
val loginId = account.loginTag.removePrefix("mfaisa_")
|
||||||
|
var session = app.mfaisaSessionFor(account) ?: return emptyList()
|
||||||
|
val page = try {
|
||||||
|
MfaisaHistoryClient().fetchHistory(
|
||||||
|
session = session,
|
||||||
|
accountNumber = account.accountNumber,
|
||||||
|
accountDisplayName = account.accountBriefName,
|
||||||
|
pageNo = mfaisaNextPage,
|
||||||
|
recordSize = 70
|
||||||
|
)
|
||||||
|
} catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) {
|
||||||
|
session = app.refreshMfaisaSession(loginId) ?: return emptyList()
|
||||||
|
MfaisaHistoryClient().fetchHistory(
|
||||||
|
session = session,
|
||||||
|
accountNumber = account.accountNumber,
|
||||||
|
accountDisplayName = account.accountBriefName,
|
||||||
|
pageNo = mfaisaNextPage,
|
||||||
|
recordSize = 70
|
||||||
|
)
|
||||||
|
}
|
||||||
|
mfaisaHasMore = page.hasMore
|
||||||
|
mfaisaNextPage++
|
||||||
|
return page.transactions
|
||||||
|
}
|
||||||
|
|
||||||
private fun fetchFahipay(app: BasedBankApp): List<BankTransaction> {
|
private fun fetchFahipay(app: BasedBankApp): List<BankTransaction> {
|
||||||
val session = app.fahipaySessionFor(account) ?: return emptyList()
|
val session = app.fahipaySessionFor(account) ?: return emptyList()
|
||||||
val (list, total) = FahipayHistoryClient().fetchHistory(
|
val (list, total) = FahipayHistoryClient().fetchHistory(
|
||||||
|
|||||||
@@ -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="fahipay_verify">Verify</string>
|
||||||
<string name="ooredoo_name">Ooredoo M-Faisa</string>
|
<string name="ooredoo_name">Ooredoo M-Faisa</string>
|
||||||
<string name="ooredoo_desc">Mobile Wallet</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_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">Sign In</string>
|
||||||
<string name="sign_in_desc">Enter your Maldives Islamic Bank credentials.</string>
|
<string name="sign_in_desc">Enter your Maldives Islamic Bank credentials.</string>
|
||||||
<string name="bml_sign_in_desc">Enter your Bank of Maldives credentials.</string>
|
<string name="bml_sign_in_desc">Enter your Bank of Maldives credentials.</string>
|
||||||
|
|||||||
Reference in New Issue
Block a user