diff --git a/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt b/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt index ac01955..df84ce6 100644 --- a/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt +++ b/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt @@ -8,6 +8,7 @@ import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.bml.BmlProfile import sh.sar.basedbank.api.bml.BmlSession import sh.sar.basedbank.api.fahipay.FahipaySession +import sh.sar.basedbank.api.mfaisa.MfaisaSession import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.api.mib.MibProfile @@ -48,6 +49,10 @@ class BasedBankApp : Application() { val fahipaySessions: MutableMap = mutableMapOf() var fahipayAccounts: List = emptyList() + /** Active M-Faisa sessions keyed by loginId (= msisdn). */ + val mfaisaSessions: MutableMap = mutableMapOf() + var mfaisaAccounts: List = emptyList() + // ─── MIB helpers ────────────────────────────────────────────────────────── /** Returns the MIB session for the given account (matched via loginTag). */ @@ -110,6 +115,26 @@ class BasedBankApp : Application() { fun fahipaySessionFor(account: BankAccount): FahipaySession? = fahipaySessions[account.loginTag.removePrefix("fahipay_")] + // ─── M-Faisa helpers ────────────────────────────────────────────────────── + + /** Returns the M-Faisa session for the given account (matched via loginTag = "mfaisa_${msisdn}"). */ + fun mfaisaSessionFor(account: BankAccount): MfaisaSession? = + mfaisaSessions[account.loginTag.removePrefix("mfaisa_")] + + /** + * Re-runs `fetchSubscriber` + `doMobileLogin` using the saved credentials for [loginId] and + * replaces the cached session. Call this after catching [sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException]. + * Returns the fresh session, or null if no credentials are saved for that login. + */ + fun refreshMfaisaSession(loginId: String): MfaisaSession? { + val creds = CredentialStore(this).loadMfaisaCredentials(loginId) ?: return null + val flow = sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow(this) + flow.fetchSubscriber(creds.msisdn) + val result = flow.doMobileLogin(creds.msisdn, creds.pin) + mfaisaSessions[loginId] = result.session + return result.session + } + /** Serialises all MIB profile-switch + request sequences to prevent session corruption. */ val mibMutex = Mutex() diff --git a/app/src/main/java/sh/sar/basedbank/LockActivity.kt b/app/src/main/java/sh/sar/basedbank/LockActivity.kt index fab7b11..8946d76 100644 --- a/app/src/main/java/sh/sar/basedbank/LockActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/LockActivity.kt @@ -279,7 +279,8 @@ class LockActivity : AppCompatActivity() { finish() } else { val store = CredentialStore(this) - val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials() + val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || + store.hasFahipayCredentials() || store.hasMfaisaCredentials() if (!hasCredentials) { startActivity(Intent(this, sh.sar.basedbank.ui.login.LoginActivity::class.java)) finish() diff --git a/app/src/main/java/sh/sar/basedbank/MainActivity.kt b/app/src/main/java/sh/sar/basedbank/MainActivity.kt index 00b8320..d1e6a26 100644 --- a/app/src/main/java/sh/sar/basedbank/MainActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/MainActivity.kt @@ -41,7 +41,8 @@ class MainActivity : AppCompatActivity() { val onboardingDone = prefs.getBoolean("onboarding_done", false) val securitySet = prefs.getString("security_method", null) != null val store = CredentialStore(this) - val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials() + val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || + store.hasFahipayCredentials() || store.hasMfaisaCredentials() // Image shared via "Scan to Pay" — decode QR here while we still hold the URI permission val shareQrText: String? = if (intent?.action == Intent.ACTION_SEND && diff --git a/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaAccountClient.kt b/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaAccountClient.kt new file mode 100644 index 0000000..05405e2 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaAccountClient.kt @@ -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_" (one per M-Faisa account on the device). + */ + fun buildAccounts(result: MfaisaLoginResult, loginTag: String): List { + 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 + ) + } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaCrypto.kt b/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaCrypto.kt new file mode 100644 index 0000000..32d7f35 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaCrypto.kt @@ -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)) +} diff --git a/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaHistoryClient.kt b/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaHistoryClient.kt new file mode 100644 index 0000000..f25e994 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaHistoryClient.kt @@ -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, 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() + 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") +} diff --git a/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaLoginFlow.kt new file mode 100644 index 0000000..07d8450 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaLoginFlow.kt @@ -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() + 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") +} diff --git a/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaModels.kt b/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaModels.kt new file mode 100644 index 0000000..2ba8a96 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaModels.kt @@ -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 +) + +/** 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) diff --git a/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaTransferClient.kt b/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaTransferClient.kt new file mode 100644 index 0000000..c0543da --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/mfaisa/MfaisaTransferClient.kt @@ -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 { + 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) + } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryFragment.kt index 1613e3c..4719190 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryFragment.kt @@ -84,9 +84,10 @@ class AccountHistoryFragment : Fragment() { adapter.setHideAmounts(viewModel.hideAmounts.value ?: false) viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) } - // Show default account toggle only for non-card accounts + // Show default account toggle only for non-card, non-M-Faisa accounts. + // M-Faisa pockets (including PayPal) cannot be set as the default transfer/QR account. val isCard = AccountListParser.from(account)?.isCard ?: false - if (!isCard) { + if (!isCard && account.bank != "MFAISA") { val store = CredentialStore(requireContext()) adapter.showDefaultToggle = true adapter.isDefaultAccount = store.getDefaultAccountNumber() == account.accountNumber diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/AccountsAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/home/AccountsAdapter.kt index c9df329..4056338 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/AccountsAdapter.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/AccountsAdapter.kt @@ -75,10 +75,12 @@ class AccountsAdapter( "BML" -> "Bank of Maldives" "FAHIPAY" -> "Fahipay" "MIB" -> "Maldives Islamic Bank" + "MFAISA" -> if (account.profileType == "MFAISA_PAYPAL") "PayPal · M-Faisa" else "M-Faisa" else -> account.bank } val profileLabel = when (account.bank) { "MIB" -> account.productCode.ifBlank { account.profileName } + "MFAISA" -> "" // bank-level grouping is already specific (M-Faisa / PayPal · M-Faisa) else -> account.profileName } return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName @@ -144,6 +146,7 @@ class AccountsAdapter( "BML" -> R.drawable.bml_logo_vector "FAHIPAY" -> R.drawable.fahipay_logo "MIB" -> R.drawable.mib_logo + "MFAISA" -> R.drawable.ooredoo_logo else -> null } if (staticLogo != null) binding.ivBankLogo.setImageResource(staticLogo) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt index 233c5d3..e198aed 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/HomeActivity.kt @@ -202,9 +202,9 @@ class HomeActivity : AppCompatActivity() { } // Load data - if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) { + if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty() || app.mfaisaAccounts.isNotEmpty()) { // Came from fresh manual login — accounts ready, rest fetched in background - val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts + val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts + app.mfaisaAccounts viewModel.accounts.value = merged.filterVisibleAccounts() if (app.mibAccounts.isNotEmpty()) AccountCache.save(this, app.mibAccounts) if (app.bmlAccounts.isNotEmpty()) { @@ -215,6 +215,10 @@ class HomeActivity : AppCompatActivity() { val byLoginId = app.fahipayAccounts.groupBy { it.loginTag.removePrefix("fahipay_") } byLoginId.forEach { (loginId, accs) -> AccountCache.saveFahipay(this, loginId, accs) } } + if (app.mfaisaAccounts.isNotEmpty()) { + val byLoginId = app.mfaisaAccounts.groupBy { it.loginTag.removePrefix("mfaisa_") } + byLoginId.forEach { (loginId, accs) -> AccountCache.saveMfaisa(this, loginId, accs) } + } val cachedCards = CardsCache.load(this) if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards @@ -238,7 +242,8 @@ class HomeActivity : AppCompatActivity() { val cachedMib = AccountCache.load(this) val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds()) val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds()) - val merged = cachedMib + cachedBml + cachedFahipay + val cachedMfaisa = AccountCache.loadMfaisa(this, store.getMfaisaLoginIds()) + val merged = cachedMib + cachedBml + cachedFahipay + cachedMfaisa if (merged.isNotEmpty()) viewModel.accounts.value = merged val cachedCards = CardsCache.load(this) if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards @@ -695,17 +700,20 @@ fun applyNavLabelVisibility() { val mibLoginIds = store.getMibLoginIds() val bmlLoginIds = store.getBmlLoginIds() val fahipayLoginIds = store.getFahipayLoginIds() - if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) { + val mfaisaLoginIds = store.getMfaisaLoginIds() + if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty() && mfaisaLoginIds.isEmpty()) { startActivity(Intent(this, LoginActivity::class.java)) finish() return } // Immediately drop accounts for logged-out logins from the displayed list val current = viewModel.accounts.value ?: emptyList() + val mfaisaLoginIdsForFilter = store.getMfaisaLoginIds() viewModel.accounts.value = current.filter { acc -> if (acc.bank == "MIB") return@filter acc.loginTag.removePrefix("mib_") in mibLoginIds if (acc.bank == "BML") return@filter acc.loginTag.removePrefix("bml_") in bmlLoginIds if (acc.bank == "FAHIPAY") return@filter acc.loginTag.removePrefix("fahipay_") in fahipayLoginIds + if (acc.bank == "MFAISA") return@filter acc.loginTag.removePrefix("mfaisa_") in mfaisaLoginIdsForFilter true } autoRefresh(store) @@ -724,7 +732,8 @@ fun applyNavLabelVisibility() { val mibLoginIds = store.getMibLoginIds() val bmlLoginIds = store.getBmlLoginIds() val fahipayLoginIds = store.getFahipayLoginIds() - if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) return + val mfaisaLoginIds = store.getMfaisaLoginIds() + if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty() && mfaisaLoginIds.isEmpty()) return binding.refreshIndicator.visibility = View.VISIBLE hideConnectivityBanner() @@ -898,17 +907,58 @@ fun applyNavLabelVisibility() { } } + // One async job per M-Faisa login, all run in parallel. + // M-Faisa has no session refresh — sessions expire after ~240s — so we re-login each refresh. + val mfaisaJobs = mfaisaLoginIds.mapNotNull { loginId -> + val creds = store.loadMfaisaCredentials(loginId) ?: return@mapNotNull null + loginId to async(Dispatchers.IO) { + val loginTag = "mfaisa_$loginId" + val flow = sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow(this@HomeActivity) + try { + flow.fetchSubscriber(creds.msisdn) + val result = flow.doMobileLogin(creds.msisdn, creds.pin) + val accounts = sh.sar.basedbank.api.mfaisa.MfaisaAccountClient.buildAccounts(result, loginTag) + val app = application as BasedBankApp + app.mfaisaSessions[loginId] = result.session + store.saveMfaisaUserProfile( + loginId, + CredentialStore.MfaisaUserProfile( + name = result.profile.name, + email = result.profile.email, + mdnId = result.profile.mdnId, + subscriberId = result.profile.subscriberId, + walletId = result.profile.walletId, + roleId = result.profile.roleId, + offerId = result.profile.offerId + ) + ) + AccountCache.saveMfaisa(this@HomeActivity, loginId, accounts) + accounts + } catch (e: java.io.IOException) { + refreshErrors.add("NO_INTERNET") + AccountCache.loadMfaisa(this@HomeActivity, loginId) + } catch (e: BankServerException) { + refreshErrors.add("SERVER:${e.bankName}") + AccountCache.loadMfaisa(this@HomeActivity, loginId) + } catch (_: Exception) { + AccountCache.loadMfaisa(this@HomeActivity, loginId) + } + } + } + val mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() } val mibAccounts = mibResults.flatMap { it.second } val bmlAccounts = bmlJobs.flatMap { (_, job) -> job.await() } val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() } + val mfaisaAccounts = mfaisaJobs.flatMap { (_, job) -> job.await() } val app = application as BasedBankApp app.mibAccounts = mibAccounts AccountCache.save(this@HomeActivity, mibAccounts) app.bmlAccounts = bmlAccounts app.fahipayAccounts = fahipayAccounts - viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts()) + app.mfaisaAccounts = mfaisaAccounts + viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts + mfaisaAccounts).filterVisibleAccounts()) binding.refreshIndicator.visibility = View.GONE val noInternet = refreshErrors.any { it == "NO_INTERNET" } @@ -955,6 +1005,11 @@ fun applyNavLabelVisibility() { val hidden = store.getHiddenBmlProfileIds(loginId) hidden.isEmpty() || acc.profileId !in hidden } + "MFAISA" -> { + val loginId = acc.loginTag.removePrefix("mfaisa_") + val hidden = store.getHiddenMfaisaPocketIds(loginId) + hidden.isEmpty() || acc.accountNumber !in hidden + } else -> true } } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/SettingsLoginsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/SettingsLoginsFragment.kt index 70ce6ae..2109297 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/SettingsLoginsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/SettingsLoginsFragment.kt @@ -368,8 +368,9 @@ class SettingsLoginsFragment : Fragment() { val mibLoginIds = store.getMibLoginIds() val bmlLoginIds = store.getBmlLoginIds() val fahipayLoginIds = store.getFahipayLoginIds() + val mfaisaLoginIds = store.getMfaisaLoginIds() - binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty()) View.VISIBLE else View.GONE + binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty() || mfaisaLoginIds.isNotEmpty()) View.VISIBLE else View.GONE for (loginId in mibLoginIds) { val profile = store.loadMibUserProfile(loginId) @@ -396,6 +397,14 @@ class SettingsLoginsFragment : Fragment() { showFahipayLoginDetails(store, loginId, profile) } } + + for (loginId in mfaisaLoginIds) { + val profile = store.loadMfaisaUserProfile(loginId) + val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name) + addLoginRow(container, R.drawable.ooredoo_logo, displayName) { + showMfaisaLoginDetails(store, loginId, profile) + } + } } private fun addLoginRow(container: LinearLayout, logoRes: Int, displayName: String, onClick: () -> Unit) { @@ -1065,6 +1074,152 @@ class SettingsLoginsFragment : Fragment() { buildLoginsSection() } + private fun showMfaisaLoginDetails( + store: CredentialStore, + loginId: String, + profile: CredentialStore.MfaisaUserProfile? + ) { + val ctx = requireContext() + val dp = ctx.resources.displayMetrics.density + val hide = viewModel.hideAmounts.value ?: false + val masked = "••••••" + + val pockets = sh.sar.basedbank.util.AccountCache.loadMfaisa(ctx, loginId) + val hidden = store.getHiddenMfaisaPocketIds(loginId).toMutableSet() + val originalHidden = hidden.toSet() + + // The user-visible "profiles" are: M-Faisa (every non-PayPal pocket) and PayPal (if linked). + // Each toggle covers the set of pocket account numbers that belong to that profile. + data class MfaisaProfileRow(val label: String, val pocketIds: Set) + val mfaisaPockets = pockets.filter { it.profileType != "MFAISA_PAYPAL" } + val paypalPockets = pockets.filter { it.profileType == "MFAISA_PAYPAL" } + val profileRows = buildList { + if (mfaisaPockets.isNotEmpty()) { + add(MfaisaProfileRow("M-Faisa", mfaisaPockets.map { it.accountNumber }.toSet())) + } + if (paypalPockets.isNotEmpty()) { + add(MfaisaProfileRow("PayPal", paypalPockets.map { it.accountNumber }.toSet())) + } + } + + val scroll = android.widget.ScrollView(ctx) + val container = LinearLayout(ctx).apply { + orientation = LinearLayout.VERTICAL + val pad = (16 * dp).toInt() + setPadding(pad, (8 * dp).toInt(), pad, pad) + } + scroll.addView(container) + + listOfNotNull( + profile?.name?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" }, + profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: ${if (hide) masked else it}" }, + profile?.mdnId?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: ${if (hide) masked else it}" } + ).forEach { line -> + container.addView(TextView(ctx).apply { + text = line + setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium) + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { + bottomMargin = (4 * dp).toInt() + } + }) + } + + if (profileRows.isNotEmpty()) { + if (profile != null) { + container.addView(View(ctx).apply { + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also { + it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt() + } + setBackgroundColor(0x1F000000) + }) + } + container.addView(TextView(ctx).apply { + text = getString(R.string.login_detail_profiles) + setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium) + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also { + it.bottomMargin = (8 * dp).toInt() + } + }) + } + + val toggleRows = profileRows.map { row -> + val v = LinearLayout(ctx).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also { + it.bottomMargin = (4 * dp).toInt() + } + } + val label = TextView(ctx).apply { + text = row.label + setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium) + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + } + val toggle = MaterialSwitch(ctx).apply { + isChecked = row.pocketIds.any { it !in hidden } + layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply { + marginStart = (4 * dp).toInt() + } + } + v.addView(label) + v.addView(toggle) + container.addView(v) + row to toggle + } + + fun updateToggleStates(saveBtn: android.widget.Button) { + val visibleCount = toggleRows.count { (row, _) -> row.pocketIds.any { it !in hidden } } + toggleRows.forEach { (_, toggle) -> + toggle.isEnabled = !(toggle.isChecked && visibleCount == 1) + } + saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1 + } + + val dialog = MaterialAlertDialogBuilder(ctx) + .setTitle(getString(R.string.ooredoo_name)) + .setView(scroll) + .apply { + if (profileRows.isNotEmpty()) setPositiveButton(R.string.save, null) + setNeutralButton(R.string.close, null) + setNegativeButton(R.string.settings_logout) { _, _ -> + confirmLogout(getString(R.string.ooredoo_name)) { logoutMfaisa(store, loginId) } + } + } + .show() + + if (profileRows.isNotEmpty()) { + val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE) + saveBtn.isEnabled = false + updateToggleStates(saveBtn) + + toggleRows.forEach { (row, toggle) -> + toggle.setOnCheckedChangeListener { _, checked -> + if (checked) hidden.removeAll(row.pocketIds) else hidden.addAll(row.pocketIds) + updateToggleStates(saveBtn) + } + } + + saveBtn.setOnClickListener { + store.setHiddenMfaisaPocketIds(loginId, hidden) + clearAllCaches(ctx) + dialog.dismiss() + (activity as? HomeActivity)?.relogin() + } + } + } + + private fun logoutMfaisa(store: CredentialStore, loginId: String) { + val ctx = requireContext() + store.clearMfaisaCredentials(loginId) + val app = requireActivity().application as BasedBankApp + app.mfaisaSessions.remove(loginId) + app.mfaisaAccounts = app.mfaisaAccounts.filter { it.loginTag != "mfaisa_$loginId" } + app.accounts = app.accounts.filter { it.loginTag != "mfaisa_$loginId" } + clearAllCaches(ctx) + (activity as HomeActivity).relogin() + buildLoginsSection() + } + private fun clearAllCaches(ctx: Context) { AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx) ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt index 0913e9b..b9888ec 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferFragment.kt @@ -57,6 +57,7 @@ import sh.sar.basedbank.api.mib.MibTransferResult import sh.sar.basedbank.databinding.FragmentTransferBinding import sh.sar.basedbank.databinding.ItemAccountDropdownBinding import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding +import sh.sar.basedbank.ui.home.transfer.MfaisaTransferHandler import sh.sar.basedbank.util.AccountListParser import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.AccountInputParser @@ -130,6 +131,39 @@ class TransferFragment : Fragment() { private var pendingBmlTransfer: PendingBmlTransfer? = null private var accountDropdownAdapter: AccountDropdownAdapter? = null + /** Lazy: created the first time the user selects an MFAISA source account. */ + private var mfaisaHandler: MfaisaTransferHandler? = null + private fun mfaisaHandler(): MfaisaTransferHandler = + mfaisaHandler ?: MfaisaTransferHandler( + fragment = this, + binding = binding, + viewModel = viewModel, + onRecipientChanged = { + // The handler resolved or cleared a recipient — keep the resolvedAccountNumber + // mirror in sync so the shared `updateTransferButton()` / clearForm() logic works. + val r = mfaisaHandler?.recipient + if (r != null) { + resolvedAccountNumber = r.msisdn + resolvedRecipientName = r.name + resolvedDestCurrency = "MVR" + resolvedBankName = "Ooredoo M-Faisa" + } else { + resolvedAccountNumber = "" + resolvedRecipientName = "" + resolvedDestCurrency = "" + resolvedBankName = "" + } + updateTransferButton() + }, + onTransferSuccess = { receipt, avatar -> + ReceiptStore.save(requireContext(), receipt) + clearForm() + val activity = requireActivity() as HomeActivity + activity.triggerRefresh() + activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, avatar)) + } + ).also { mfaisaHandler = it } + private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult @@ -518,16 +552,60 @@ class TransferFragment : Fragment() { } } + /** Toggle the "To" row's affordances + keyboard hints depending on whether the source is MFAISA. */ + private fun applyMfaisaToFieldMode(mfaisa: Boolean) { + if (mfaisa) { + binding.tilTo.hint = getString(R.string.ooredoo_phone) + binding.etTo.inputType = android.text.InputType.TYPE_CLASS_PHONE + // Phone-only flow — hide the QR scanner + contact picker icons next to the To field + binding.btnPickContact.visibility = View.GONE + binding.btnScanQr.visibility = View.GONE + // Any previously-resolved non-MFAISA recipient (or stale state) is no longer valid + if (resolvedAccountNumber.isNotBlank() && mfaisaHandler?.recipient == null) { + resolvedAccountNumber = "" + resolvedRecipientName = "" + resolvedDestCurrency = "" + resolvedToOwnAccount = null + binding.cardToInfo.visibility = View.GONE + binding.tilTo.visibility = View.VISIBLE + binding.etTo.setText("") + } + } else { + binding.tilTo.hint = getString(R.string.transfer_to) + binding.etTo.inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS + binding.btnPickContact.visibility = View.VISIBLE + binding.btnScanQr.visibility = View.VISIBLE + // Drop any M-Faisa-resolved recipient when switching banks + if (mfaisaHandler?.recipient != null) { + mfaisaHandler?.clearState() + resolvedAccountNumber = "" + resolvedRecipientName = "" + resolvedDestCurrency = "" + resolvedToOwnAccount = null + binding.cardToInfo.visibility = View.GONE + binding.tilTo.visibility = View.VISIBLE + binding.etTo.setText("") + } + } + } + private fun showFromCard(account: BankAccount) { + // Apply per-source "To"-row configuration before painting the card. M-Faisa transfers + // only target phone numbers (looked up via basicBeneDetails), so the QR / contact-picker + // affordances are hidden and the keyboard is set to phone-numeric. + applyMfaisaToFieldMode(account.bank == "MFAISA") + val colorHex = when (account.bank) { "BML" -> "#0066A1" "FAHIPAY" -> "#15BEA7" + "MFAISA" -> "#ED1C24" else -> "#FE860E" } val bankLabel = when (account.bank) { "BML" -> "BML" "FAHIPAY" -> "FP" "MIB" -> "MIB" + "MFAISA" -> "M-Faisa" else -> null } val typeLabel = AccountListParser.from(account)?.typeLabel @@ -561,11 +639,18 @@ class TransferFragment : Fragment() { binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER binding.ivFromPhoto.setImageResource(R.drawable.fahipay_logo) } - else -> { + account.bank == "MFAISA" -> { + binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER + binding.ivFromPhoto.setImageResource(R.drawable.ooredoo_logo) + } + account.bank == "MIB" -> { binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER binding.ivFromPhoto.setImageResource(R.drawable.mib_logo) if (account.profileImageHash != null) loadFromPhoto(account.profileImageHash) } + else -> { + binding.ivFromPhoto.setImageDrawable(null) + } } binding.tilFrom.visibility = View.GONE binding.cardFromInfo.visibility = View.VISIBLE @@ -618,11 +703,18 @@ class TransferFragment : Fragment() { binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER binding.ivToPhoto.setImageResource(R.drawable.fahipay_logo) } - else -> { + account.bank == "MFAISA" -> { + binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER + binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo) + } + account.bank == "MIB" -> { binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER binding.ivToPhoto.setImageResource(R.drawable.mib_logo) if (account.profileImageHash != null) loadToPhoto(account.profileImageHash, isProfile = true) } + else -> { + binding.ivToPhoto.setImageDrawable(null) + } } } @@ -649,7 +741,14 @@ class TransferFragment : Fragment() { } private fun setupAccountLookup() { - binding.tilTo.setEndIconOnClickListener { lookupAccount() } + binding.tilTo.setEndIconOnClickListener { + // M-Faisa source uses an entirely different lookup path (phone → basicBeneDetails) + if (selectedAccount?.bank == "MFAISA") { + mfaisaHandler().searchRecipient(binding.etTo.text?.toString().orEmpty()) + } else { + lookupAccount() + } + } binding.btnClearToInfo.setOnClickListener { if (bmlQrInfo != null) { @@ -665,12 +764,15 @@ class TransferFragment : Fragment() { resolvedDestCurrency = "" resolvedToOwnAccount = null selectedFahipayService = null + mfaisaHandler?.clearState() binding.cardToInfo.visibility = View.GONE binding.layoutServiceSelector.visibility = View.INVISIBLE binding.tilTo.visibility = View.VISIBLE binding.btnPickContact.visibility = View.VISIBLE binding.btnScanQr.visibility = View.VISIBLE binding.tilTo.error = null + // Re-apply MFAISA-mode if needed (hides QR/contact picker + sets phone keyboard) + applyMfaisaToFieldMode(selectedAccount?.bank == "MFAISA") updateTransferButton() } @@ -681,10 +783,12 @@ class TransferFragment : Fragment() { resolvedRecipientName = "" resolvedDestCurrency = "" resolvedToOwnAccount = null + mfaisaHandler?.clearState() binding.cardToInfo.visibility = View.GONE binding.tilTo.visibility = View.VISIBLE binding.btnPickContact.visibility = View.VISIBLE binding.btnScanQr.visibility = View.VISIBLE + applyMfaisaToFieldMode(selectedAccount?.bank == "MFAISA") updateTransferButton() } } @@ -1083,6 +1187,12 @@ class TransferFragment : Fragment() { } private fun initiateTransfer() { + // M-Faisa source: the entire flow (initiate + OTP + confirm) lives in the handler. + if (selectedAccount?.bank == "MFAISA") { + mfaisaHandler().submit() + return + } + // BML QR merchant payment — uses shared confirm dialog, no receipt bmlQrInfo?.let { info -> val src = selectedAccount ?: run { @@ -2075,6 +2185,7 @@ class TransferFragment : Fragment() { private fun clearForm() { resetBmlOtpState() + mfaisaHandler?.clearState() selectedAccount = null binding.actvFrom.setText("", false) binding.cardFromInfo.visibility = View.GONE @@ -2195,6 +2306,9 @@ class TransferFragment : Fragment() { bmlOtpState = BmlOtpState.NONE pendingBmlTransfer = null bmlOtpChannel = null + // The M-Faisa handler holds binding refs; drop it so the next view gets a fresh one. + mfaisaHandler?.clearState() + mfaisaHandler = null _binding = null } diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/TransferHistoryFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/TransferHistoryFragment.kt index c6881e4..9067e67 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/TransferHistoryFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/TransferHistoryFragment.kt @@ -27,6 +27,7 @@ import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.R import sh.sar.basedbank.api.bml.BmlHistoryClient import sh.sar.basedbank.api.fahipay.FahipayHistoryClient +import sh.sar.basedbank.api.mfaisa.MfaisaHistoryClient import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibHistoryClient @@ -61,12 +62,18 @@ class TransferHistoryFragment : Fragment() { var bmlTotalPages: Int = -1, var cardMonthOffset: Int = 0, var fahipayNextStart: Int = 0, - var fahipayTotal: Int = -1 + var fahipayTotal: Int = -1, + var mfaisaNextPage: Int = 1, + var mfaisaHasMore: Boolean = true ) { + // PayPal pockets have no known history endpoint, so they don't paginate here. + private val isMfaisaPaypal get() = account.profileType == "MFAISA_PAYPAL" + fun hasMore(): Boolean = when { account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT" -> cardMonthOffset < 2 account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages + account.bank == "MFAISA" -> !isMfaisaPaypal && mfaisaHasMore else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount } } @@ -236,6 +243,36 @@ class TransferHistoryFragment : Fragment() { } }.awaitAll().flatten()) + // M-Faisa accounts (PayPal pockets are skipped by hasMore()) + val mfaisaStates = activeStates.filter { it.account.bank == "MFAISA" } + for (state in mfaisaStates) { + var session = app.mfaisaSessionFor(state.account) ?: continue + try { + val page = try { + MfaisaHistoryClient().fetchHistory( + session = session, + accountNumber = state.account.accountNumber, + accountDisplayName = state.account.accountBriefName, + pageNo = state.mfaisaNextPage, + recordSize = 70 + ) + } catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) { + val loginId = state.account.loginTag.removePrefix("mfaisa_") + session = app.refreshMfaisaSession(loginId) ?: continue + MfaisaHistoryClient().fetchHistory( + session = session, + accountNumber = state.account.accountNumber, + accountDisplayName = state.account.accountBriefName, + pageNo = state.mfaisaNextPage, + recordSize = 70 + ) + } + state.mfaisaHasMore = page.hasMore + state.mfaisaNextPage++ + results.addAll(page.transactions) + } catch (e: Exception) { trackError(e) } + } + // Fahipay accounts val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" } for (state in fahipayStates) { diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MfaisaTransferHandler.kt b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MfaisaTransferHandler.kt new file mode 100644 index 0000000..2554007 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/ui/home/transfer/MfaisaTransferHandler.kt @@ -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() + } +} diff --git a/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt index fa6f885..4a31161 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/login/CredentialsFragment.kt @@ -30,6 +30,12 @@ import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.fahipay.FahipayAccountClient import sh.sar.basedbank.api.fahipay.FahipayLoginFlow import sh.sar.basedbank.api.fahipay.FahipaySession +import sh.sar.basedbank.api.mfaisa.MfaisaAccountClient +import sh.sar.basedbank.api.mfaisa.MfaisaInvalidPinException +import sh.sar.basedbank.api.mfaisa.MfaisaKycRequiredException +import sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow +import sh.sar.basedbank.api.mfaisa.MfaisaNotRegisteredException +import sh.sar.basedbank.api.mfaisa.MfaisaWalletNotReadyException import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.api.mib.MibProfileClient @@ -218,10 +224,7 @@ class CredentialsFragment : Fragment() { when (bankType) { "BML" -> { attemptBmlLogin(); return } "FAHIPAY" -> { attemptFahipayLogin(); return } - "OOREDOO" -> { - Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show() - return - } + "OOREDOO" -> { attemptMfaisaLogin(); return } } val username = binding.etUsername.text.toString().trim() @@ -429,6 +432,97 @@ class CredentialsFragment : Fragment() { startActivity(intent) } + private fun attemptMfaisaLogin() { + val msisdn = binding.etUsername.text.toString().trim() + val pin = binding.etPassword.text.toString() + + if (msisdn.isEmpty() || pin.length != 4) { + binding.tvError.text = "Please enter your phone number and 4-digit mPIN" + binding.tvError.visibility = View.VISIBLE + return + } + + binding.tvError.visibility = View.GONE + binding.progressBar.visibility = View.VISIBLE + binding.btnLogin.isEnabled = false + binding.etUsername.isEnabled = false + binding.etPassword.isEnabled = false + + val store = CredentialStore(requireContext()) + val flow = MfaisaLoginFlow(requireContext()) + val loginTag = "mfaisa_$msisdn" + + viewLifecycleOwner.lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { flow.fetchSubscriber(msisdn) } + val result = withContext(Dispatchers.IO) { flow.doMobileLogin(msisdn, pin) } + val accounts = MfaisaAccountClient.buildAccounts(result, loginTag) + + store.saveMfaisaCredentials(msisdn, msisdn, pin) + store.saveMfaisaUserProfile( + msisdn, + CredentialStore.MfaisaUserProfile( + name = result.profile.name, + email = result.profile.email, + mdnId = result.profile.mdnId, + subscriberId = result.profile.subscriberId, + walletId = result.profile.walletId, + roleId = result.profile.roleId, + offerId = result.profile.offerId + ) + ) + AccountCache.saveMfaisa(requireContext(), msisdn, accounts) + + val app = requireActivity().application as BasedBankApp + app.mfaisaSessions[msisdn] = result.session + app.mfaisaAccounts = app.mfaisaAccounts.filter { it.loginTag != loginTag } + accounts + app.accounts = app.accounts.filter { it.loginTag != loginTag } + accounts + app.isUnlocked = true + + val intent = Intent(requireContext(), HomeActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK + startActivity(intent) + } catch (e: MfaisaNotRegisteredException) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle("User not registered") + .setMessage("Please use the Ooredoo SuperApp to register your M-Faisa wallet and complete KYC, then come back to Thijooree.") + .setPositiveButton("OK", null) + .show() + binding.tvError.visibility = View.GONE + } catch (e: MfaisaKycRequiredException) { + MaterialAlertDialogBuilder(requireContext()) + .setTitle("KYC incomplete") + .setMessage("Your M-Faisa wallet needs Full KYC. Please complete KYC in the Ooredoo SuperApp, then come back to Thijooree.") + .setPositiveButton("OK", null) + .show() + binding.tvError.visibility = View.GONE + } catch (e: MfaisaWalletNotReadyException) { + binding.tvError.text = e.message + binding.tvError.visibility = View.VISIBLE + } catch (e: MfaisaInvalidPinException) { + val message = if (e.lastAttempt) + "${e.message}\n\nOne more wrong mPIN will lock your account." + else + e.message + binding.tvError.text = message ?: "Incorrect mPIN" + binding.tvError.visibility = View.VISIBLE + // Re-enable PIN input only — leave phone number locked + binding.etPassword.isEnabled = true + binding.etPassword.setText("") + binding.etPassword.requestFocus() + } catch (e: Exception) { + binding.tvError.text = e.message ?: "Login failed" + binding.tvError.visibility = View.VISIBLE + binding.etUsername.isEnabled = true + binding.etPassword.isEnabled = true + } finally { + binding.progressBar.visibility = View.GONE + binding.btnLogin.isEnabled = true + // After PIN error, keep phone disabled; for any other resolution above we already restored as needed. + } + } + } + private fun attemptFahipayLogin() { if (fahipayAwaitingTotp) { submitFahipayTotp() diff --git a/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt b/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt index 2407235..451fdd1 100644 --- a/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt +++ b/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt @@ -11,6 +11,7 @@ object AccountCache { private const val KEY_MIB = "mib_accounts" private fun bmlKey(loginId: String) = "bml_accounts_$loginId" private fun fahipayKey(loginId: String) = "fahipay_accounts_$loginId" + private fun mfaisaKey(loginId: String) = "mfaisa_accounts_$loginId" fun save(context: Context, accounts: List) { val arr = JSONArray() @@ -150,6 +151,62 @@ object AccountCache { fun loadFahipay(context: Context, loginIds: List): List = loginIds.flatMap { loadFahipay(context, it) } + fun saveMfaisa(context: Context, loginId: String, accounts: List) { + 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 { + 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): List = + loginIds.flatMap { loadMfaisa(context, it) } + fun clear(context: Context) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply() } diff --git a/app/src/main/java/sh/sar/basedbank/util/AccountHistoryParser.kt b/app/src/main/java/sh/sar/basedbank/util/AccountHistoryParser.kt index f30e12d..655c302 100644 --- a/app/src/main/java/sh/sar/basedbank/util/AccountHistoryParser.kt +++ b/app/src/main/java/sh/sar/basedbank/util/AccountHistoryParser.kt @@ -3,6 +3,7 @@ package sh.sar.basedbank.util import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.util.bmlapi.BmlHistoryParser import sh.sar.basedbank.util.fahipayapi.FahipayHistoryParser +import sh.sar.basedbank.util.mfaisaapi.MfaisaHistoryParser import sh.sar.basedbank.util.mibapi.MibHistoryParser object AccountHistoryParser { @@ -11,6 +12,7 @@ object AccountHistoryParser { "BML" -> BmlHistoryParser.displayData(account) "FAHIPAY" -> FahipayHistoryParser.displayData(account) "MIB" -> MibHistoryParser.displayData(account) + "MFAISA" -> MfaisaHistoryParser.displayData(account) else -> null } } diff --git a/app/src/main/java/sh/sar/basedbank/util/AccountListParser.kt b/app/src/main/java/sh/sar/basedbank/util/AccountListParser.kt index 6283a51..1998ff3 100644 --- a/app/src/main/java/sh/sar/basedbank/util/AccountListParser.kt +++ b/app/src/main/java/sh/sar/basedbank/util/AccountListParser.kt @@ -3,6 +3,7 @@ package sh.sar.basedbank.util import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.util.bmlapi.BmlDashboardParser import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser +import sh.sar.basedbank.util.mfaisaapi.MfaisaAccountParser import sh.sar.basedbank.util.mibapi.MibAccountParser object AccountListParser { @@ -11,6 +12,7 @@ object AccountListParser { "BML" -> BmlDashboardParser.displayData(account) "FAHIPAY" -> FahipayAccountParser.displayData(account) "MIB" -> MibAccountParser.displayData(account) + "MFAISA" -> MfaisaAccountParser.displayData(account) else -> null } } diff --git a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt index 095c07e..b5fcce8 100644 --- a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt +++ b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt @@ -21,6 +21,7 @@ class CredentialStore(context: Context) { data class MibCredentials(val username: String, val passwordHash: String, val otpSeed: String) data class BmlCredentials(val username: String, val password: String, val otpSeed: String) data class FahipayCredentials(val idCard: String, val password: String) + data class MfaisaCredentials(val msisdn: String, val pin: String) // ── MIB login credentials (multi-login, keyed by loginId = username) ───── @@ -460,6 +461,110 @@ class CredentialStore(context: Context) { editor.apply() } + // ── M-Faisa login credentials (multi-login, keyed by loginId = msisdn) ─── + + fun getMfaisaLoginIds(): List { + 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 = + prefs.getStringSet("mfaisa_${loginId}_hidden_pockets", emptySet()) ?: emptySet() + + fun setHiddenMfaisaPocketIds(loginId: String, ids: Set) = + 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) ────────────────────────────── /** diff --git a/app/src/main/java/sh/sar/basedbank/util/HistoryFetcher.kt b/app/src/main/java/sh/sar/basedbank/util/HistoryFetcher.kt index 4ef24a3..38f9d4b 100644 --- a/app/src/main/java/sh/sar/basedbank/util/HistoryFetcher.kt +++ b/app/src/main/java/sh/sar/basedbank/util/HistoryFetcher.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.withContext import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.api.bml.BmlHistoryClient import sh.sar.basedbank.api.fahipay.FahipayHistoryClient +import sh.sar.basedbank.api.mfaisa.MfaisaHistoryClient import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.mib.MibHistoryClient import sh.sar.basedbank.api.models.BankTransaction @@ -24,6 +25,8 @@ class HistoryFetcher(private val account: BankAccount) { private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT" private val isBmlLoan get() = account.profileType == "BML_LOAN" private val isFahipay get() = account.bank == "FAHIPAY" + private val isMfaisa get() = account.bank == "MFAISA" + private val isMfaisaPaypal get() = account.profileType == "MFAISA_PAYPAL" // MIB pagination private var mibNextStart = 1 @@ -55,12 +58,19 @@ class HistoryFetcher(private val account: BankAccount) { private var fahipayNextStart = 0 private var fahipayTotal = -1 + // M-Faisa pagination — the server doesn't return a "total" field, so we infer "more pages exist" + // from whether the last page was full-sized. + private var mfaisaNextPage = 1 + private var mfaisaHasMore = true + fun hasMore(): Boolean = when { - isBmlLoan -> false - isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal - isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount - isBmlCard -> cardMonthOffset < 3 - else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages + isBmlLoan -> false + isMfaisaPaypal -> false // PayPal pockets have no known history endpoint + isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal + isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount + isBmlCard -> cardMonthOffset < 3 + isMfaisa -> mfaisaHasMore + else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages } suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List = when { @@ -68,9 +78,36 @@ class HistoryFetcher(private val account: BankAccount) { isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) } isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } } isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) } + isMfaisa -> withContext(Dispatchers.IO) { fetchMfaisa(app) } else -> withContext(Dispatchers.IO) { fetchBmlCasa(app) } } + private fun fetchMfaisa(app: BasedBankApp): List { + 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 { val session = app.fahipaySessionFor(account) ?: return emptyList() val (list, total) = FahipayHistoryClient().fetchHistory( diff --git a/app/src/main/java/sh/sar/basedbank/util/mfaisaapi/MfaisaAccountParser.kt b/app/src/main/java/sh/sar/basedbank/util/mfaisaapi/MfaisaAccountParser.kt new file mode 100644 index 0000000..01e662c --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/util/mfaisaapi/MfaisaAccountParser.kt @@ -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}" + ) +} diff --git a/app/src/main/java/sh/sar/basedbank/util/mfaisaapi/MfaisaHistoryParser.kt b/app/src/main/java/sh/sar/basedbank/util/mfaisaapi/MfaisaHistoryParser.kt new file mode 100644 index 0000000..90a33ba --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/util/mfaisaapi/MfaisaHistoryParser.kt @@ -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 + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index def6d53..b1d2177 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,9 +30,9 @@ Verify Ooredoo M-Faisa Mobile Wallet - Enter your mobile number and 4-digit PIN. + Enter your mobile number and 4-digit mPIN. Mobile Number - PIN + mPIN Sign In Enter your Maldives Islamic Bank credentials. Enter your Bank of Maldives credentials.