diff --git a/.gitignore b/.gitignore index 22b40fc..534e820 100644 --- a/.gitignore +++ b/.gitignore @@ -15,5 +15,6 @@ local.properties docs/mibapi/tmp docs/bmlapi/tmp +docs/fahipayapi/tmp tmp app/key.jks diff --git a/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt b/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt index 7f2cf5c..28bb402 100644 --- a/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt +++ b/app/src/main/java/sh/sar/basedbank/BasedBankApp.kt @@ -5,6 +5,7 @@ import androidx.appcompat.app.AppCompatDelegate import com.google.android.material.color.DynamicColors import kotlinx.coroutines.sync.Mutex import sh.sar.basedbank.api.bml.BmlSession +import sh.sar.basedbank.api.fahipay.FahipaySession import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.api.mib.MibProfile @@ -20,6 +21,8 @@ class BasedBankApp : Application() { var mibProfiles: List = emptyList() var bmlSession: BmlSession? = null var bmlAccounts: List = emptyList() + var fahipaySession: FahipaySession? = null + var fahipayAccounts: List = emptyList() /** Serialises all MIB profile-switch + request sequences to prevent session corruption. */ val mibMutex = Mutex() diff --git a/app/src/main/java/sh/sar/basedbank/MainActivity.kt b/app/src/main/java/sh/sar/basedbank/MainActivity.kt index f0b1327..9c1d59a 100644 --- a/app/src/main/java/sh/sar/basedbank/MainActivity.kt +++ b/app/src/main/java/sh/sar/basedbank/MainActivity.kt @@ -16,7 +16,7 @@ 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() + val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials() val target = when { !onboardingDone -> OnboardingActivity::class.java diff --git a/app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayLoginFlow.kt b/app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayLoginFlow.kt new file mode 100644 index 0000000..d53fec0 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayLoginFlow.kt @@ -0,0 +1,290 @@ +package sh.sar.basedbank.api.fahipay + +import android.os.Build +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okio.Buffer +import org.json.JSONObject +import sh.sar.basedbank.api.mib.MibAccount +import sh.sar.basedbank.api.mib.Transaction +import java.security.SecureRandom +import java.util.concurrent.TimeUnit + +class FahipayLoginFlow { + + private val BASE_URL = "https://fahipay.mv" + private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android 14; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36" + private val UA_OKHTTP = "okhttp/4.12.0" + private val PAGE_SIZE = 15 + + private val cookieStore = mutableMapOf>() + private val cookieJar = object : CookieJar { + override fun saveFromResponse(url: HttpUrl, cookies: List) { + val list = cookieStore.getOrPut(url.host) { mutableListOf() } + for (c in cookies) { + list.removeAll { it.name == c.name } + list.add(c) + } + } + override fun loadForRequest(url: HttpUrl): List = + cookieStore[url.host] ?: emptyList() + } + + private val client = OkHttpClient.Builder() + .cookieJar(cookieJar) + .followRedirects(false) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + /** Seed the cookie jar with a stored session cookie before using a persisted session. */ + fun setSessionCookie(value: String) { + val host = "fahipay.mv" + val list = cookieStore.getOrPut(host) { mutableListOf() } + list.removeAll { it.name == "__Secure-sess" } + list.add( + Cookie.Builder() + .domain(host) + .name("__Secure-sess") + .value(value) + .secure() + .build() + ) + } + + fun getSessionCookieValue(): String? = + cookieStore["fahipay.mv"]?.firstOrNull { it.name == "__Secure-sess" }?.value + + // Establishes the __Secure-sess cookie required for the login+OTP flow. + private fun initSession() { + client.newCall( + Request.Builder().url("$BASE_URL/api/app/lang/data/") + .get() + .header("User-Agent", UA_WEBVIEW) + .build() + ).execute().close() + } + + /** + * Step 1: POST /api/app/login/ + * Returns FahipayLoginStep: + * twoFactorRequired=false + authId set → login complete, proceed + * twoFactorRequired=true + authId=null → call verifyTotp() next + */ + fun login(idCard: String, password: String, deviceUuid: String): FahipayLoginStep { + initSession() + val body = buildFormBody( + "email" to idCard, + "password" to password, + "grant_type" to "auth_id", + "lang" to "en", + "version" to "2.0.0", + "platform" to "app", + *deviceParts(deviceUuid) + ) + + val resp = client.newCall( + Request.Builder().url("$BASE_URL/api/app/login/") + .post(body) + .header("User-Agent", UA_WEBVIEW) + .header("accept", "application/json") + .build() + ).execute() + val json = resp.body?.string() ?: throw Exception("Empty login response") + resp.close() + + val obj = JSONObject(json) + if (obj.optString("type") != "success") { + throw Exception(obj.optString("msg", "Login failed — check your ID card and password")) + } + + val authId = obj.optString("authID", "").takeIf { it.isNotBlank() } + val twoFa = obj.optBoolean("two_factor_required", false) + return FahipayLoginStep(twoFactorRequired = twoFa, authId = authId) + } + + /** + * Step 2 (if 2FA required): POST /api/app/otp/ + * Returns authId. + */ + fun verifyTotp(code: String, deviceUuid: String): String { + val body = buildFormBody( + "code" to code, + "channel" to "totp", + "action" to "login", + "grant_type" to "auth_id", + "lang" to "en", + "version" to "2.0.0", + "platform" to "app", + *deviceParts(deviceUuid) + ) + + val resp = client.newCall( + Request.Builder().url("$BASE_URL/api/app/otp/") + .post(body) + .header("User-Agent", UA_WEBVIEW) + .header("accept", "application/json") + .build() + ).execute() + val json = resp.body?.string() ?: throw Exception("Empty OTP response") + resp.close() + + val obj = JSONObject(json) + if (obj.optString("type") != "success") { + throw Exception(obj.optString("msg", "OTP verification failed")) + } + return obj.optString("authID").takeIf { it.isNotBlank() } + ?: throw Exception("No authID in OTP response") + } + + fun fetchProfile(session: FahipaySession): FahipayUserProfile { + val resp = client.newCall( + Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en") + .header("authid", session.authId) + .header("content-type", "multipart/form-data") + .header("User-Agent", UA_OKHTTP) + .build() + ).execute() + val json = resp.body?.string() ?: throw Exception("Empty profile response") + resp.close() + + val obj = JSONObject(json) + val props = obj.optJSONObject("props") ?: JSONObject() + return FahipayUserProfile( + fullName = obj.optString("fullname").trim(), + email = obj.optString("email").trim(), + mobile = obj.optString("mobile").trim(), + nid = obj.optString("nid").trim(), + profileId = obj.optString("profileID").trim(), + walletAccount = props.optString("acc", ""), + linkedAccounts = props.optJSONObject("accs")?.toString() ?: "{}" + ) + } + + fun fetchBalance(session: FahipaySession): Double { + val resp = client.newCall( + Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en") + .header("authid", session.authId) + .header("content-type", "multipart/form-data") + .header("User-Agent", UA_OKHTTP) + .build() + ).execute() + val json = resp.body?.string() ?: return 0.0 + resp.close() + return try { + val obj = JSONObject(json) + if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0) + } catch (_: Exception) { 0.0 } + } + + fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): MibAccount = + MibAccount( + profileName = profile.fullName.ifBlank { "Fahipay" }, + profileType = "FAHIPAY", + accountNumber = profile.walletAccount, + accountBriefName = "Fahipay Wallet", + currencyName = "MVR", + accountTypeName = "Digital Wallet", + availableBalance = "%.2f".format(balance), + currentBalance = "%.2f".format(balance), + blockedAmount = "0.00", + mvrBalance = "%.2f".format(balance), + statusDesc = "Active", + profileImageHash = null, + loginTag = loginTag, + internalId = profile.profileId + ) + + /** + * Fetches paginated activity history. + * @param start offset (0-based) + * @return Pair of (transactions, total count) + */ + fun fetchHistory( + session: FahipaySession, + accountDisplayName: String, + accountNumber: String, + start: Int + ): Pair, Int> { + val resp = client.newCall( + Request.Builder() + .url("$BASE_URL/actions/activity/?s=$start&l=$PAGE_SIZE&lang=en") + .header("authid", session.authId) + .header("content-type", "multipart/form-data") + .header("User-Agent", UA_OKHTTP) + .build() + ).execute() + val json = resp.body?.string() ?: return Pair(emptyList(), 0) + resp.close() + return try { + val obj = JSONObject(json) + val total = obj.optInt("total", 0) + val entries = obj.optJSONArray("entries") ?: return Pair(emptyList(), total) + val list = (0 until entries.length()).map { i -> + val e = entries.getJSONObject(i) + Transaction( + id = e.optString("transaction"), + date = e.optString("date"), + description = e.optString("name").trim(), + amount = e.optDouble("amount", 0.0), + currency = "MVR", + counterpartyName = e.optString("details").takeIf { it.isNotBlank() }, + reference = e.optString("transaction").takeIf { it.isNotBlank() }, + accountNumber = accountNumber, + accountDisplayName = accountDisplayName, + source = "FAHIPAY" + ) + } + Pair(list, total) + } catch (_: Exception) { Pair(emptyList(), 0) } + } + + private fun deviceParts(deviceUuid: String): Array> = arrayOf( + "device[available]" to "true", + "device[platform]" to "Android", + "device[uuid]" to deviceUuid, + "device[model]" to Build.MODEL, + "device[manufacturer]" to Build.MANUFACTURER, + "device[isVirtual]" to "false", + "device[serial]" to "unknown" + ) + + /** + * Builds a multipart/form-data body with lowercase "content-disposition" headers, + * which is what the Fahipay server requires. + */ + private fun buildFormBody(vararg parts: Pair): RequestBody { + val boundary = java.util.UUID.randomUUID().toString() + val buf = Buffer() + for ((name, value) in parts) { + val valueBytes = value.toByteArray(Charsets.UTF_8) + buf.writeUtf8("--$boundary\r\n") + buf.writeUtf8("content-disposition: form-data; name=\"$name\"\r\n") + buf.writeUtf8("Content-Length: ${valueBytes.size}\r\n") + buf.writeUtf8("\r\n") + buf.write(valueBytes) + buf.writeUtf8("\r\n") + } + buf.writeUtf8("--$boundary--\r\n") + val snapshot = buf.readByteString() + val mediaType = "multipart/form-data; boundary=$boundary".toMediaType() + return object : RequestBody() { + override fun contentType() = mediaType + override fun contentLength() = snapshot.size.toLong() + override fun writeTo(sink: okio.BufferedSink) { sink.write(snapshot) } + } + } + + companion object { + fun generateDeviceUuid(): String { + val bytes = ByteArray(8) + SecureRandom().nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it) } + } + } +} diff --git a/app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayModels.kt b/app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayModels.kt new file mode 100644 index 0000000..1471d83 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/api/fahipay/FahipayModels.kt @@ -0,0 +1,21 @@ +package sh.sar.basedbank.api.fahipay + +data class FahipaySession( + val authId: String, + val sessionCookie: String +) + +data class FahipayUserProfile( + val fullName: String, + val email: String, + val mobile: String, + val nid: String, + val profileId: String, + val walletAccount: String, + val linkedAccounts: String // raw JSON of props.accs, for transfer use +) + +data class FahipayLoginStep( + val twoFactorRequired: Boolean, + val authId: String? = null // non-null when 2FA not required +) diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryAdapter.kt b/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryAdapter.kt index 08660af..74db563 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryAdapter.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/AccountHistoryAdapter.kt @@ -139,7 +139,11 @@ class AccountHistoryAdapter( fun bind(acc: MibAccount) { b.tvHeaderAccountName.text = acc.accountBriefName b.tvHeaderAccountNumber.text = acc.accountNumber - b.tvHeaderPillBank.text = if (acc.profileType.startsWith("BML")) "BML" else "MIB" + b.tvHeaderPillBank.text = when { + acc.profileType.startsWith("BML") -> "BML" + acc.profileType == "FAHIPAY" -> "FP" + else -> null + } b.tvHeaderPillType.text = friendlyType(acc.accountTypeName) b.tvHeaderAvailable.text = "${acc.currencyName} ${acc.availableBalance}" b.tvHeaderBalance.text = "${acc.currencyName} ${acc.currentBalance}" 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 3108fe5..80324b4 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 @@ -19,6 +19,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.api.bml.BmlLoginFlow +import sh.sar.basedbank.api.fahipay.FahipayLoginFlow import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibHistoryClient @@ -50,6 +51,8 @@ class AccountHistoryFragment : Fragment() { private var bmlNextPage = 1 private var bmlTotalPages = -1 private var cardMonthOffset = 0 // 0 = current month, 1 = prev, etc. + private var fahipayNextStart = 0 + private var fahipayTotal = -1 private var isLoading = false private val pageSize = 10 @@ -121,10 +124,12 @@ class AccountHistoryFragment : Fragment() { binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE } - private fun isMib() = !account.profileType.startsWith("BML") + private fun isMib() = !account.profileType.startsWith("BML") && account.profileType != "FAHIPAY" private fun isBmlCard() = account.profileType == "BML_PREPAID" + private fun isFahipay() = account.profileType == "FAHIPAY" private fun hasMore(): Boolean = when { + isFahipay() -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal isMib() -> mibTotalCount < 0 || mibNextStart <= mibTotalCount isBmlCard() -> cardMonthOffset < 3 // load up to 3 months else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages @@ -143,6 +148,20 @@ class AccountHistoryFragment : Fragment() { lifecycleScope.launch { val transactions: List = withContext(Dispatchers.IO) { when { + isFahipay() -> { + val session = app.fahipaySession ?: return@withContext emptyList() + val flow = FahipayLoginFlow() + flow.setSessionCookie(session.sessionCookie) + val (list, total) = flow.fetchHistory( + session = session, + accountDisplayName = account.accountBriefName, + accountNumber = account.accountNumber, + start = fahipayNextStart + ) + if (total > 0) fahipayTotal = total + fahipayNextStart += list.size + list + } isMib() -> { val session = app.mibSession ?: return@withContext emptyList() app.mibMutex.withLock { 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 5125266..1eaa108 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 @@ -91,7 +91,11 @@ class AccountsAdapter( fun bind(account: MibAccount) { binding.tvAccountName.text = account.accountBriefName binding.tvAccountNumber.text = account.accountNumber - binding.tvPillBank.text = if (account.profileType.startsWith("BML")) "BML" else "MIB" + binding.tvPillBank.text = when { + account.profileType.startsWith("BML") -> "BML" + account.profileType == "FAHIPAY" -> "FP" + else -> null + } binding.tvPillType.text = friendlyAccountType(account.accountTypeName) binding.tvPillProfile.text = when (account.profileType) { "0" -> "Personal" 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 7e99a0d..038020e 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 @@ -31,6 +31,8 @@ import sh.sar.basedbank.R import sh.sar.basedbank.api.bml.AuthExpiredException import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.bml.BmlSession +import sh.sar.basedbank.api.fahipay.FahipayLoginFlow +import sh.sar.basedbank.api.fahipay.FahipaySession import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.databinding.ActivityHomeBinding @@ -104,13 +106,14 @@ class HomeActivity : AppCompatActivity() { // Load data val app = application as BasedBankApp - if (app.accounts.isNotEmpty() || app.bmlAccounts.isNotEmpty()) { + if (app.accounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) { // Came from fresh manual login — accounts ready, rest fetched in background - val mibAccounts = app.accounts.filter { it.profileType != "BML" } - val merged = mibAccounts + app.bmlAccounts + val mibAccounts = app.accounts.filter { !it.profileType.startsWith("BML") && it.profileType != "FAHIPAY" } + val merged = mibAccounts + app.bmlAccounts + app.fahipayAccounts viewModel.accounts.value = merged if (mibAccounts.isNotEmpty()) AccountCache.save(this, mibAccounts) if (app.bmlAccounts.isNotEmpty()) AccountCache.saveBml(this, app.bmlAccounts) + if (app.fahipayAccounts.isNotEmpty()) AccountCache.saveFahipay(this, app.fahipayAccounts) val cachedFinancing = FinancingCache.load(this) if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing @@ -131,7 +134,8 @@ class HomeActivity : AppCompatActivity() { // Came from lock screen — show caches immediately, refresh everything in background val cachedMib = AccountCache.load(this) val cachedBml = AccountCache.loadBml(this) - val merged = cachedMib + cachedBml + val cachedFahipay = AccountCache.loadFahipay(this) + val merged = cachedMib + cachedBml + cachedFahipay if (merged.isNotEmpty()) viewModel.accounts.value = merged val cachedFinancing = FinancingCache.load(this) if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing @@ -143,7 +147,7 @@ class HomeActivity : AppCompatActivity() { if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits val store = CredentialStore(this) - autoRefresh(store.loadMibCredentials(), store.loadBmlCredentials(), store) + autoRefresh(store.loadMibCredentials(), store.loadBmlCredentials(), store.loadFahipayCredentials(), store) } // Show dashboard on first create @@ -289,7 +293,8 @@ class HomeActivity : AppCompatActivity() { val store = CredentialStore(this) val hasMib = store.hasMibCredentials() val hasBml = store.hasBmlCredentials() - if (!hasMib && !hasBml) { + val hasFahipay = store.hasFahipayCredentials() + if (!hasMib && !hasBml && !hasFahipay) { startActivity(Intent(this, LoginActivity::class.java)) finish() return @@ -297,19 +302,21 @@ class HomeActivity : AppCompatActivity() { // Immediately drop accounts for logged-out banks from the displayed list val current = viewModel.accounts.value ?: emptyList() viewModel.accounts.value = current.filter { acc -> - if (!hasMib && !acc.profileType.startsWith("BML")) return@filter false + if (!hasMib && !acc.profileType.startsWith("BML") && acc.profileType != "FAHIPAY") return@filter false if (!hasBml && acc.profileType.startsWith("BML")) return@filter false + if (!hasFahipay && acc.profileType == "FAHIPAY") return@filter false true } - autoRefresh(store.loadMibCredentials(), store.loadBmlCredentials(), store) + autoRefresh(store.loadMibCredentials(), store.loadBmlCredentials(), store.loadFahipayCredentials(), store) } private fun autoRefresh( mibCreds: CredentialStore.MibCredentials?, bmlCreds: CredentialStore.BmlCredentials?, + fahipayCreds: CredentialStore.FahipayCredentials?, store: CredentialStore ) { - if (mibCreds == null && bmlCreds == null) return + if (mibCreds == null && bmlCreds == null && fahipayCreds == null) return binding.refreshIndicator.visibility = View.VISIBLE lifecycleScope.launch { @@ -366,10 +373,62 @@ class HomeActivity : AppCompatActivity() { } } + val fahipayJob = fahipayCreds?.let { creds -> + async(Dispatchers.IO) { + val fahipayFlow = FahipayLoginFlow() + val deviceUuid = store.getOrCreateFahipayDeviceUuid() + + // Try cached session first + val savedSession = store.loadFahipaySession() + if (savedSession != null) { + try { + val session = FahipaySession(savedSession.first, savedSession.second) + fahipayFlow.setSessionCookie(session.sessionCookie) + val balance = fahipayFlow.fetchBalance(session) + val profile = fahipayFlow.fetchProfile(session) + val loginTag = "fahipay_${profile.profileId}" + val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag)) + val app = application as BasedBankApp + app.fahipaySession = session + app.fahipayAccounts = accounts + AccountCache.saveFahipay(this@HomeActivity, accounts) + return@async Pair(session, accounts) + } catch (_: Exception) { + // Session expired — fall through to full login + } + } + + // Full re-login (only works if user has no 2FA, or 2FA was skipped) + try { + val step = fahipayFlow.login(creds.idCard, creds.password, deviceUuid) + if (step.twoFactorRequired) { + // Can't auto-complete 2FA — use cached data + return@async Pair(null, AccountCache.loadFahipay(this@HomeActivity)) + } + val authId = step.authId ?: return@async Pair(null, AccountCache.loadFahipay(this@HomeActivity)) + val cookieValue = fahipayFlow.getSessionCookieValue() ?: "" + val session = FahipaySession(authId, cookieValue) + store.saveFahipaySession(authId, cookieValue) + val profile = fahipayFlow.fetchProfile(session) + val balance = fahipayFlow.fetchBalance(session) + val loginTag = "fahipay_${profile.profileId}" + val accounts = listOf(fahipayFlow.buildAccount(profile, balance, loginTag)) + val app = application as BasedBankApp + app.fahipaySession = session + app.fahipayAccounts = accounts + AccountCache.saveFahipay(this@HomeActivity, accounts) + Pair(session, accounts) + } catch (_: Exception) { + Pair(null, AccountCache.loadFahipay(this@HomeActivity)) + } + } + } + val mibAccounts = mibJob?.await() ?: AccountCache.load(this@HomeActivity) val (bmlSession, bmlAccounts) = bmlJob?.await() ?: Pair(null, AccountCache.loadBml(this@HomeActivity)) + val (_, fahipayAccounts) = fahipayJob?.await() ?: Pair(null, AccountCache.loadFahipay(this@HomeActivity)) - viewModel.accounts.postValue(mibAccounts + bmlAccounts) + viewModel.accounts.postValue(mibAccounts + bmlAccounts + fahipayAccounts) binding.refreshIndicator.visibility = View.GONE val app = application as BasedBankApp @@ -464,7 +523,24 @@ class HomeActivity : AppCompatActivity() { val app = application as BasedBankApp lifecycleScope.launch { val current = viewModel.accounts.value ?: emptyList() - if (src.profileType.startsWith("BML")) { + if (src.profileType == "FAHIPAY") { + val fresh = withContext(Dispatchers.IO) { + val sess = app.fahipaySession ?: return@withContext null + try { + val flow = FahipayLoginFlow() + flow.setSessionCookie(sess.sessionCookie) + val balance = flow.fetchBalance(sess) + val profile = flow.fetchProfile(sess) + val loginTag = "fahipay_${profile.profileId}" + val accounts = listOf(flow.buildAccount(profile, balance, loginTag)) + AccountCache.saveFahipay(this@HomeActivity, accounts) + app.fahipayAccounts = accounts + accounts + } catch (_: Exception) { null } + } ?: return@launch + val others = current.filter { it.profileType != "FAHIPAY" } + viewModel.accounts.postValue(others + fresh) + } else if (src.profileType.startsWith("BML")) { val fresh = withContext(Dispatchers.IO) { val sess = app.bmlSession ?: return@withContext null try { diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/SettingsFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/SettingsFragment.kt index ab8443e..b7104d3 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/home/SettingsFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/home/SettingsFragment.kt @@ -163,15 +163,16 @@ class SettingsFragment : Fragment() { val hasMib = store.hasMibCredentials() val hasBml = store.hasBmlCredentials() + val hasFahipay = store.hasFahipayCredentials() - binding.tvLoginsTitle.visibility = if (hasMib || hasBml) View.VISIBLE else View.GONE + binding.tvLoginsTitle.visibility = if (hasMib || hasBml || hasFahipay) View.VISIBLE else View.GONE if (hasMib) { val profile = store.loadMibUserProfile() val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name) val profileNames = AccountCache.load(ctx) .map { it.profileName }.filter { it.isNotBlank() }.distinct() - addLoginRow(container, R.drawable.mib_faisanet_logo, displayName) { + addLoginRow(container, R.drawable.mib_logo, displayName) { showLoginDetails( title = getString(R.string.mib_name), details = buildString { @@ -213,6 +214,23 @@ class SettingsFragment : Fragment() { ) } } + + if (hasFahipay) { + val profile = store.loadFahipayUserProfile() + val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name) + addLoginRow(container, R.drawable.fahipay_logo, displayName) { + showLoginDetails( + title = getString(R.string.fahipay_name), + details = buildString { + if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}") + if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${profile!!.email}") + if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${profile!!.mobile}") + if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${profile!!.nid}") + }.trim(), + onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store) } } + ) + } + } } private fun addLoginRow( @@ -298,6 +316,18 @@ class SettingsFragment : Fragment() { buildLoginsSection() } + private fun logoutFahipay(store: CredentialStore) { + val ctx = requireContext() + store.clearFahipayCredentials() + store.clearFahipaySession() + val app = requireActivity().application as BasedBankApp + app.fahipaySession = null + app.fahipayAccounts = emptyList() + clearAllCaches(ctx) + (activity as HomeActivity).relogin() + buildLoginsSection() + } + private fun applyFlagSecure(enabled: Boolean) { val win = activity?.window ?: return if (enabled) { 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 c337916..94b0431 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 @@ -167,8 +167,16 @@ class TransferFragment : Fragment() { private fun showFromCard(account: MibAccount) { val isBml = account.profileType.startsWith("BML") - val colorHex = if (isBml) "#0066A1" else "#FE860E" - val bankLabel = if (isBml) "BML" else "MIB" + val colorHex = when { + isBml -> "#0066A1" + account.profileType == "FAHIPAY" -> "#15BEA7" + else -> "#FE860E" + } + val bankLabel = when { + isBml -> "BML" + account.profileType == "FAHIPAY" -> "FP" + else -> null + } val typeLabel = when { account.profileType == "BML_PREPAID" -> "Prepaid Card" account.accountTypeName.isNotBlank() -> account.accountTypeName @@ -177,7 +185,7 @@ class TransferFragment : Fragment() { binding.tvFromAccountName.text = account.accountBriefName binding.tvFromAccountNumber.text = account.accountNumber - binding.tvFromAccountDetails.text = "$bankLabel · $typeLabel · ${account.currencyName} ${account.availableBalance}" + binding.tvFromAccountDetails.text = listOfNotNull(bankLabel, typeLabel, "${account.currencyName} ${account.availableBalance}").joinToString(" · ") binding.ivFromPhoto.setImageBitmap(makeInitialsBitmap(account.accountBriefName, colorHex)) binding.tilFrom.visibility = View.GONE binding.cardFromInfo.visibility = View.VISIBLE diff --git a/app/src/main/java/sh/sar/basedbank/ui/login/BankSelectionFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/login/BankSelectionFragment.kt index eb910c1..c3ea386 100644 --- a/app/src/main/java/sh/sar/basedbank/ui/login/BankSelectionFragment.kt +++ b/app/src/main/java/sh/sar/basedbank/ui/login/BankSelectionFragment.kt @@ -27,6 +27,10 @@ class BankSelectionFragment : Fragment() { val args = android.os.Bundle().apply { putString("bankType", "BML") } findNavController().navigate(R.id.action_bankSelection_to_credentials_bml, args) } + binding.cardFahipay.setOnClickListener { + val args = android.os.Bundle().apply { putString("bankType", "FAHIPAY") } + findNavController().navigate(R.id.action_bankSelection_to_credentials_fahipay, args) + } } override fun onDestroyView() { 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 060a3f3..37ffa53 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 @@ -19,6 +19,8 @@ import sh.sar.basedbank.util.Totp import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.R import sh.sar.basedbank.api.bml.BmlLoginFlow +import sh.sar.basedbank.api.fahipay.FahipayLoginFlow +import sh.sar.basedbank.api.fahipay.FahipaySession import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.util.AccountCache import sh.sar.basedbank.util.CredentialStore @@ -40,29 +42,45 @@ class CredentialsFragment : Fragment() { private var _binding: FragmentCredentialsBinding? = null private val binding get() = _binding!! + // Fahipay two-step state + private var fahipayFlow: FahipayLoginFlow? = null + private var fahipayAwaitingTotp = false + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = FragmentCredentialsBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - if (bankType == "BML") { - binding.ivBankLogo.setImageResource(R.drawable.bml_logo_vector) - binding.tvSignInDesc.setText(R.string.bml_sign_in_desc) + when (bankType) { + "BML" -> { + binding.ivBankLogo.setImageResource(R.drawable.bml_logo_vector) + binding.tvSignInDesc.setText(R.string.bml_sign_in_desc) + } + "FAHIPAY" -> { + binding.ivBankLogo.setImageResource(R.drawable.fahipay_logo_long) + binding.tvSignInDesc.setText(R.string.fahipay_sign_in_desc) + binding.tilUsername.hint = getString(R.string.fahipay_id_card) + binding.tilOtpSeed.visibility = android.view.View.GONE + binding.etOtpSeed.isEnabled = false + binding.etOtpSeed.isFocusable = false + } } binding.btnLogin.setOnClickListener { attemptLogin() } - binding.etOtpSeed.addTextChangedListener(object : TextWatcher { - override fun afterTextChanged(s: Editable?) { updateOtpDisplay() } - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} - }) + if (bankType != "FAHIPAY") { + binding.etOtpSeed.addTextChangedListener(object : TextWatcher { + override fun afterTextChanged(s: Editable?) { updateOtpDisplay() } + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + }) + } } override fun onResume() { super.onResume() - otpHandler.post(otpRunnable) + if (bankType != "FAHIPAY") otpHandler.post(otpRunnable) } override fun onPause() { @@ -91,9 +109,9 @@ class CredentialsFragment : Fragment() { } private fun attemptLogin() { - if (bankType == "BML") { - attemptBmlLogin() - return + when (bankType) { + "BML" -> { attemptBmlLogin(); return } + "FAHIPAY" -> { attemptFahipayLogin(); return } } val username = binding.etUsername.text.toString().trim() @@ -208,6 +226,136 @@ class CredentialsFragment : Fragment() { } } + private fun attemptFahipayLogin() { + if (fahipayAwaitingTotp) { + submitFahipayTotp() + return + } + + val idCard = binding.etUsername.text.toString().trim() + val password = binding.etPassword.text.toString() + + if (idCard.isEmpty() || password.isEmpty()) { + binding.tvError.text = "Please fill in all fields" + binding.tvError.visibility = View.VISIBLE + return + } + + binding.tvError.visibility = View.GONE + binding.progressBar.visibility = View.VISIBLE + binding.btnLogin.isEnabled = false + + val store = CredentialStore(requireContext()) + val deviceUuid = store.getOrCreateFahipayDeviceUuid() + val flow = FahipayLoginFlow().also { fahipayFlow = it } + + viewLifecycleOwner.lifecycleScope.launch { + try { + val step = withContext(Dispatchers.IO) { + flow.login(idCard, password, deviceUuid) + } + + if (step.twoFactorRequired) { + // Show TOTP input, disable ID + password fields + fahipayAwaitingTotp = true + binding.etUsername.isEnabled = false + binding.etPassword.isEnabled = false + binding.tilTotpCode.visibility = View.VISIBLE + binding.btnLogin.text = getString(R.string.fahipay_verify) + binding.tvError.visibility = View.GONE + } else { + // No 2FA — finish login with the authId from the login response + val authId = step.authId ?: throw Exception("No authID received") + val cookieValue = flow.getSessionCookieValue() ?: "" + finishFahipayLogin(flow, FahipaySession(authId, cookieValue), idCard, password, store) + } + } catch (e: Exception) { + binding.tvError.text = e.message ?: "Login failed" + binding.tvError.visibility = View.VISIBLE + } finally { + binding.progressBar.visibility = View.GONE + binding.btnLogin.isEnabled = true + } + } + } + + private fun submitFahipayTotp() { + val code = binding.etTotpCode.text.toString().trim() + if (code.length != 6) { + binding.tvError.text = "Enter the 6-digit code from your authenticator app" + binding.tvError.visibility = View.VISIBLE + return + } + + binding.tvError.visibility = View.GONE + binding.progressBar.visibility = View.VISIBLE + binding.btnLogin.isEnabled = false + + val flow = fahipayFlow ?: run { + binding.tvError.text = "Session lost — please restart login" + binding.tvError.visibility = View.VISIBLE + binding.progressBar.visibility = View.GONE + binding.btnLogin.isEnabled = true + return + } + + val store = CredentialStore(requireContext()) + val deviceUuid = store.getOrCreateFahipayDeviceUuid() + val idCard = binding.etUsername.text.toString().trim() + val password = binding.etPassword.text.toString() + + viewLifecycleOwner.lifecycleScope.launch { + try { + val authId = withContext(Dispatchers.IO) { flow.verifyTotp(code, deviceUuid) } + val cookieValue = flow.getSessionCookieValue() ?: "" + finishFahipayLogin(flow, FahipaySession(authId, cookieValue), idCard, password, store) + } catch (e: Exception) { + binding.tvError.text = e.message ?: "OTP verification failed" + binding.tvError.visibility = View.VISIBLE + } finally { + binding.progressBar.visibility = View.GONE + binding.btnLogin.isEnabled = true + } + } + } + + private suspend fun finishFahipayLogin( + flow: FahipayLoginFlow, + session: FahipaySession, + idCard: String, + password: String, + store: CredentialStore + ) { + val (profile, balance) = withContext(Dispatchers.IO) { + val p = flow.fetchProfile(session) + val b = flow.fetchBalance(session) + Pair(p, b) + } + val loginTag = "fahipay_${profile.profileId}" + val account = flow.buildAccount(profile, balance, loginTag) + store.saveFahipayCredentials(idCard, password) + store.saveFahipaySession(session.authId, session.sessionCookie) + store.saveFahipayUserProfile( + CredentialStore.FahipayUserProfile( + fullName = profile.fullName, + email = profile.email, + mobile = profile.mobile, + nid = profile.nid, + profileId = profile.profileId, + walletAccount = profile.walletAccount, + linkedAccounts = profile.linkedAccounts + ) + ) + AccountCache.saveFahipay(requireContext(), listOf(account)) + val app = requireActivity().application as BasedBankApp + app.fahipaySession = session + app.fahipayAccounts = listOf(account) + app.accounts = app.accounts + listOf(account) + val intent = Intent(requireContext(), HomeActivity::class.java) + intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK + startActivity(intent) + } + override fun onDestroyView() { super.onDestroyView() _binding = null 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 42350a2..222153c 100644 --- a/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt +++ b/app/src/main/java/sh/sar/basedbank/util/AccountCache.kt @@ -10,6 +10,7 @@ object AccountCache { private const val PREFS = "account_cache" private const val KEY_MIB = "mib_accounts" private const val KEY_BML = "bml_accounts" + private const val KEY_FAHIPAY = "fahipay_accounts" fun save(context: Context, accounts: List) { val arr = JSONArray() @@ -86,6 +87,56 @@ object AccountCache { } catch (e: Exception) { emptyList() } } + fun saveFahipay(context: Context, 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("internalId", acc.internalId) + }) + } + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit().putString(KEY_FAHIPAY, CacheEncryption.encrypt(arr.toString())).apply() + } + + fun loadFahipay(context: Context): List { + val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getString(KEY_FAHIPAY, null) ?: return emptyList() + return try { + val arr = JSONArray(CacheEncryption.decrypt(raw)) + (0 until arr.length()).map { i -> + val o = arr.getJSONObject(i) + MibAccount( + 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"), + internalId = o.optString("internalId", "") + ) + } + } catch (_: Exception) { emptyList() } + } + fun clear(context: Context) { context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply() } 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 7b048d2..8457c69 100644 --- a/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt +++ b/app/src/main/java/sh/sar/basedbank/util/CredentialStore.kt @@ -19,11 +19,13 @@ 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) // ── MIB login credentials ───────────────────────────────────────────────── fun hasMibCredentials(): Boolean = prefs.contains("mib_enc_username") fun hasBmlCredentials(): Boolean = prefs.contains("bml_enc_username") + fun hasFahipayCredentials(): Boolean = prefs.contains("fahipay_enc_id_card") fun saveMibCredentials(username: String, passwordHash: String, otpSeed: String) { val key = getOrCreateKey() @@ -144,6 +146,114 @@ class CredentialStore(context: Context) { .apply() } + // ── Fahipay login credentials ───────────────────────────────────────────── + + fun saveFahipayCredentials(idCard: String, password: String) { + val key = getOrCreateKey() + prefs.edit() + .putString("fahipay_enc_id_card", encrypt(idCard, key)) + .putString("fahipay_enc_password", encrypt(password, key)) + .apply() + } + + fun loadFahipayCredentials(): FahipayCredentials? { + val key = getOrCreateKey() + val encId = prefs.getString("fahipay_enc_id_card", null) ?: return null + val encPw = prefs.getString("fahipay_enc_password", null) ?: return null + return try { + FahipayCredentials(decrypt(encId, key), decrypt(encPw, key)) + } catch (_: Exception) { null } + } + + fun clearFahipayCredentials() { + prefs.edit() + .remove("fahipay_enc_id_card") + .remove("fahipay_enc_password") + .apply() + } + + // ── Fahipay session (authId + __Secure-sess cookie) ─────────────────────── + + fun saveFahipaySession(authId: String, sessionCookie: String) { + val key = getOrCreateKey() + prefs.edit() + .putString("fahipay_enc_auth_id", encrypt(authId, key)) + .putString("fahipay_enc_session_cookie", encrypt(sessionCookie, key)) + .apply() + } + + fun loadFahipaySession(): Pair? { + val key = getOrCreateKey() + val encAuth = prefs.getString("fahipay_enc_auth_id", null) ?: return null + val encCookie = prefs.getString("fahipay_enc_session_cookie", null) ?: return null + return try { + Pair(decrypt(encAuth, key), decrypt(encCookie, key)) + } catch (_: Exception) { null } + } + + fun clearFahipaySession() { + prefs.edit() + .remove("fahipay_enc_auth_id") + .remove("fahipay_enc_session_cookie") + .apply() + } + + // ── Fahipay device UUID (generated once, shared across all Fahipay accounts) ─ + + fun getOrCreateFahipayDeviceUuid(): String { + val key = getOrCreateKey() + val enc = prefs.getString("fahipay_enc_device_uuid", null) + if (enc != null) { + try { return decrypt(enc, key) } catch (_: Exception) {} + } + val uuid = sh.sar.basedbank.api.fahipay.FahipayLoginFlow.generateDeviceUuid() + prefs.edit().putString("fahipay_enc_device_uuid", encrypt(uuid, key)).apply() + return uuid + } + + // ── Fahipay user profile ────────────────────────────────────────────────── + + data class FahipayUserProfile( + val fullName: String, + val email: String, + val mobile: String, + val nid: String, + val profileId: String, + val walletAccount: String, + val linkedAccounts: String + ) + + fun saveFahipayUserProfile(p: FahipayUserProfile) { + val json = org.json.JSONObject().apply { + put("fullName", p.fullName) + put("email", p.email) + put("mobile", p.mobile) + put("nid", p.nid) + put("profileId", p.profileId) + put("walletAccount", p.walletAccount) + put("linkedAccounts", p.linkedAccounts) + }.toString() + val key = getOrCreateKey() + prefs.edit().putString("fahipay_enc_profile", encrypt(json, key)).apply() + } + + fun loadFahipayUserProfile(): FahipayUserProfile? { + val key = getOrCreateKey() + val enc = prefs.getString("fahipay_enc_profile", null) ?: return null + return try { + val o = org.json.JSONObject(decrypt(enc, key)) + FahipayUserProfile( + fullName = o.optString("fullName"), + email = o.optString("email"), + mobile = o.optString("mobile"), + nid = o.optString("nid"), + profileId = o.optString("profileId"), + walletAccount = o.optString("walletAccount"), + linkedAccounts = o.optString("linkedAccounts", "{}") + ) + } catch (_: Exception) { null } + } + // ── Security credential (PIN / pattern hash) ────────────────────────────── /** diff --git a/app/src/main/res/drawable/fahipay_logo.png b/app/src/main/res/drawable/fahipay_logo.png new file mode 100644 index 0000000..5538549 Binary files /dev/null and b/app/src/main/res/drawable/fahipay_logo.png differ diff --git a/app/src/main/res/drawable/fahipay_logo_long.xml b/app/src/main/res/drawable/fahipay_logo_long.xml new file mode 100644 index 0000000..c1ed775 --- /dev/null +++ b/app/src/main/res/drawable/fahipay_logo_long.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_bank_selection.xml b/app/src/main/res/layout/fragment_bank_selection.xml index 714c688..cf7ea5c 100644 --- a/app/src/main/res/layout/fragment_bank_selection.xml +++ b/app/src/main/res/layout/fragment_bank_selection.xml @@ -117,5 +117,49 @@ + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_credentials.xml b/app/src/main/res/layout/fragment_credentials.xml index d6ac577..21224c3 100644 --- a/app/src/main/res/layout/fragment_credentials.xml +++ b/app/src/main/res/layout/fragment_credentials.xml @@ -42,6 +42,7 @@ android:layout_marginBottom="32dp" /> + + + + + + + + Faisanet Mobile Banking Bank of Maldives BML Internet Banking + Fahipay + Digital Wallet + Enter your Fahipay ID card number and password. + ID Card Number + Authenticator Code (6 digits) + Enter the code from your authenticator app + Verify Sign In Enter your Maldives Islamic Bank credentials. Enter your Bank of Maldives credentials. diff --git a/docs/fahipayapi/01-login.md b/docs/fahipayapi/01-login.md new file mode 100644 index 0000000..63c0010 --- /dev/null +++ b/docs/fahipayapi/01-login.md @@ -0,0 +1,152 @@ +# Login + +Authenticate a user with their Fahipay ID card number and password. + +--- + +## Endpoint + +``` +POST https://fahipay.mv/api/app/login/ +``` + +--- + +## Request + +**Content-Type:** `multipart/form-data` + +### Form Fields + +| Field | Value | Notes | +|---|---|---| +| `email` | `A123456` | The user's national ID card number (e.g. `A123456`) | +| `password` | `••••••••••••••` | The user's Fahipay password | +| `grant_type` | `auth_id` | Always `auth_id` | +| `lang` | `en` | Always `en` | +| `version` | `2.0.0` | App version string | +| `platform` | `BasedBank` | Client identifier (original app sends `app`) | +| `device[available]` | `true` | See [common device fields](README.md#common-form-fields-device-info) | +| `device[platform]` | `Android` | | +| `device[uuid]` | `a1b2c3d4e5f60718` | Persistent 16-char hex UUID, generated once per install | +| `device[model]` | `22101320I` | `Build.MODEL` | +| `device[manufacturer]` | `Xiaomi` | `Build.MANUFACTURER` | +| `device[isVirtual]` | `false` | | +| `device[serial]` | `unknown` | | + +> **Note:** The field name is `email` but the value is the ID card number, not an email address. + +--- + +## curl Example + +```bash +curl --request POST \ + --url https://fahipay.mv/api/app/login/ \ + --compressed \ + --header 'accept: application/json' \ + --header 'accept-encoding: gzip, deflate, br' \ + --header 'connection: keep-alive' \ + --header 'user-agent: Mozilla/5.0 (Linux; Android 14; 22101320I Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36' \ + --form 'email=A123456' \ + --form 'password=your_password' \ + --form 'grant_type=auth_id' \ + --form 'lang=en' \ + --form 'version=2.0.0' \ + --form 'platform=BasedBank' \ + --form 'device[available]=true' \ + --form 'device[platform]=Android' \ + --form 'device[uuid]=a1b2c3d4e5f60718' \ + --form 'device[model]=22101320I' \ + --form 'device[manufacturer]=Xiaomi' \ + --form 'device[isVirtual]=false' \ + --form 'device[serial]=unknown' +``` + +--- + +## Responses + +### Success — 2FA required + +The user has TOTP two-factor authentication enabled. Proceed to the [OTP step](02-otp.md). + +```json +{ + "two_factor_required": true, + "two_factor_method": "totp", + "title": "Success", + "msg": "You are now logged in.", + "type": "success" +} +``` + +| Field | Type | Description | +|---|---|---| +| `two_factor_required` | `bool` | `true` — must call `/api/app/otp/` next | +| `two_factor_method` | `string` | `"totp"` — standard TOTP (RFC 6238) | +| `type` | `string` | `"success"` on success, `"error"` on failure | + +The server sets the `__Secure-sess` session cookie on this response. It must be included in all subsequent requests. + +--- + +### Success — No 2FA + +The user does not have 2FA enabled. The `authID` is returned directly — no OTP step needed. + +```json +{ + "two_factor_required": false, + "authID": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "title": "Success", + "msg": "You are now logged in.", + "type": "success" +} +``` + +| Field | Type | Description | +|---|---|---| +| `two_factor_required` | `bool` | `false` — login is complete | +| `authID` | `string` | 40-char hex token; use as `authid` header for all subsequent requests | + +--- + +### Failure + +```json +{ + "title": "Error", + "msg": "Invalid credentials", + "type": "error" +} +``` + +`type` is `"error"` and `msg` contains a human-readable reason. + +--- + +## Session Cookie + +The `__Secure-sess` cookie is set by the server on the first response and must be sent on every subsequent request. It is a standard HTTP cookie with the `Secure` flag. + +``` +Set-Cookie: __Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx; Path=/; Secure; HttpOnly; SameSite=Strict +``` + +Store both the cookie value and the `authID` together to represent a persisted session. + +--- + +## Next Steps + +- If `two_factor_required` is `true` → proceed to **[OTP / 2FA](02-otp.md)** +- If `two_factor_required` is `false` → skip to **[Profile](03-profile.md)** + +--- + +  + +--- + +[← README](README.md)     **Next →** [OTP / 2FA](02-otp.md) diff --git a/docs/fahipayapi/02-otp.md b/docs/fahipayapi/02-otp.md new file mode 100644 index 0000000..3ac81e9 --- /dev/null +++ b/docs/fahipayapi/02-otp.md @@ -0,0 +1,158 @@ +# OTP / 2FA Verification + +Submit a TOTP code to complete login when `two_factor_required` was `true` in the [login response](01-login.md). + +--- + +## Endpoint + +``` +POST https://fahipay.mv/api/app/otp/ +``` + +--- + +## Prerequisites + +- Completed the [login step](01-login.md) and received `two_factor_required: true` +- The `__Secure-sess` session cookie from the login response must be present +- A valid TOTP code from the user's authenticator app + +--- + +## Request + +**Content-Type:** `multipart/form-data` + +### Form Fields + +| Field | Value | Notes | +|---|---|---| +| `code` | `123456` | 6-digit TOTP code from the user's authenticator app | +| `channel` | `totp` | Always `totp` | +| `action` | `login` | Always `login` for the login flow | +| `grant_type` | `auth_id` | Always `auth_id` | +| `lang` | `en` | Always `en` | +| `version` | `2.0.0` | App version string | +| `platform` | `BasedBank` | Client identifier | +| `device[available]` | `true` | Same device fields as login — must match | +| `device[platform]` | `Android` | | +| `device[uuid]` | `a1b2c3d4e5f60718` | Must be the **same UUID** used in the login request | +| `device[model]` | `22101320I` | | +| `device[manufacturer]` | `Xiaomi` | | +| `device[isVirtual]` | `false` | | +| `device[serial]` | `unknown` | | + +> The `device[uuid]` must be identical to the one sent in the login request. The server uses this to tie the OTP challenge to the login attempt. + +--- + +## curl Example + +```bash +curl --request POST \ + --url https://fahipay.mv/api/app/otp/ \ + --compressed \ + --header 'Cookie: __Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \ + --header 'accept: application/json' \ + --header 'accept-encoding: gzip, deflate, br' \ + --header 'connection: keep-alive' \ + --header 'user-agent: Mozilla/5.0 (Linux; Android 14; 22101320I Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36' \ + --form 'code=123456' \ + --form 'channel=totp' \ + --form 'action=login' \ + --form 'grant_type=auth_id' \ + --form 'lang=en' \ + --form 'version=2.0.0' \ + --form 'platform=BasedBank' \ + --form 'device[available]=true' \ + --form 'device[platform]=Android' \ + --form 'device[uuid]=a1b2c3d4e5f60718' \ + --form 'device[model]=22101320I' \ + --form 'device[manufacturer]=Xiaomi' \ + --form 'device[isVirtual]=false' \ + --form 'device[serial]=unknown' +``` + +--- + +## Responses + +### Success + +```json +{ + "title": "Success", + "authID": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + "msg": "Code verification successful", + "type": "success" +} +``` + +| Field | Type | Description | +|---|---|---| +| `authID` | `string` | 40-char hex token — use as `authid` header for all subsequent requests | +| `type` | `string` | `"success"` | +| `msg` | `string` | Human-readable confirmation | + +--- + +### Failure — Wrong code + +```json +{ + "title": "Error", + "msg": "Invalid OTP code", + "type": "error" +} +``` + +--- + +### Failure — Expired / session mismatch + +```json +{ + "title": "Error", + "msg": "Session expired. Please login again.", + "type": "error" +} +``` + +If the session cookie has expired or the UUID does not match, re-run the full login flow from [Step 1](01-login.md). + +--- + +## TOTP Details + +Fahipay uses standard RFC 6238 TOTP: + +| Parameter | Value | +|---|---| +| Algorithm | HMAC-SHA1 | +| Period | 30 seconds | +| Digits | 6 | +| Encoding | Base32 secret | + +The user's TOTP seed is set up during initial Fahipay account creation and is the same secret used in any standard authenticator app (Google Authenticator, Aegis, etc.). + +--- + +## Storing the Session + +After receiving `authID`, persist both values for future sessions: + +| Value | Description | +|---|---| +| `authID` | 40-char hex token — send as `authid` header | +| `__Secure-sess` | Cookie value — send as `Cookie: __Secure-sess=` | + +On app restart, attempt requests with the stored session before falling back to a full re-login. + +--- + +  + +--- + +[← Login](01-login.md)     **Next →** [Profile](03-profile.md) diff --git a/docs/fahipayapi/03-profile.md b/docs/fahipayapi/03-profile.md new file mode 100644 index 0000000..5800765 --- /dev/null +++ b/docs/fahipayapi/03-profile.md @@ -0,0 +1,229 @@ +# User Profile + +Fetch the authenticated user's full profile, including personal details, linked bank accounts, wallet settings, and permissions. + +--- + +## Endpoint + +``` +GET https://fahipay.mv/actions/getprofile/?lang=en +``` + +--- + +## Prerequisites + +- Valid `authID` from [login](01-login.md) or [OTP](02-otp.md) +- Valid `__Secure-sess` session cookie + +--- + +## Request + +### Headers + +| Header | Value | +|---|---| +| `authid` | `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` | +| `content-type` | `multipart/form-data` | +| `User-Agent` | `okhttp/4.12.0` | +| `Accept-Encoding` | `gzip` | +| `Connection` | `Keep-Alive` | +| `Cookie` | `__Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` | + +--- + +## curl Example + +```bash +curl --request GET \ + --url 'https://fahipay.mv/actions/getprofile/?lang=en' \ + --compressed \ + --header 'Accept-Encoding: gzip' \ + --header 'Connection: Keep-Alive' \ + --header 'Cookie: __Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \ + --header 'User-Agent: okhttp/4.12.0' \ + --header 'authid: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \ + --header 'content-type: multipart/form-data' +``` + +--- + +## Response + +```json +{ + "nidexpiry": "2027-06-01", + "about": "", + "email": "user@example.com", + "country": "Maldives", + "fullname": "Mohamed Ali", + "postcode": "", + "props": { + "accs": { + "bml": { + "mvr": "7730000000001", + "usd": "7730000000002", + "mvr2": "7770000000003" + }, + "cbm": { + "mvr": "1000000001" + }, + "mib": { + "mvr": "90101000000001000", + "usd": "90101000000002000" + }, + "sbi": { + "mvr": "12600000000001" + } + }, + "lang": "en", + "wsize": 5000, + "withdraw": { + "fee": "3", + "unit": "%", + "freelimit": 0 + }, + "IDverified": 1, + "thresholds": { + "amount": 0, + "countries": { + "list": [], + "mode": "deny" + }, + "frequency": 0 + }, + "amountTopup": 1, + "receiptTopup": 1, + "verifiedWith": "efaas", + "notifications": { + "txn": ["push"], + "login": ["push", "email"], + "balance": ["push"] + }, + "allowedActions": [ + "withdraw", + "payment", + "topup", + "transfer" + ], + "two_factor_enabled": 1, + "efaas_login_enabled": 0, + "acc": "500000000001", + "walletType": "basic" + }, + "mobile": "9600000001", + "city": "101", + "nid": "A123456", + "level": "1", + "address": "Example Address", + "accs": { + "bml": [ + { "name": "MOHAMED ALI" } + ] + }, + "invitecode": "XXXXX", + "profileID": "0000", + "verificationUploadMethods": ["camera", "file"], + "faceVerificationRequired": true, + "p2pqr": "https://fahipay.mv/api/qrcode/?data=9600000001", + "smsAuth": "xxxxxxxxxxxxxxxxxxxx", + "type": "success" +} +``` + +--- + +## Key Fields + +### Top-level + +| Field | Type | Description | +|---|---|---| +| `fullname` | `string` | User's full name | +| `email` | `string` | Registered email address | +| `mobile` | `string` | Registered mobile number | +| `nid` | `string` | National ID card number (e.g. `A239225`) | +| `nidexpiry` | `string` | NID expiry date (`YYYY-MM-DD`) | +| `profileID` | `string` | Fahipay internal numeric user ID — use in `loginTag` | +| `level` | `string` | Account verification level | +| `country` | `string` | Registered country | +| `city` | `string` | City code | +| `address` | `string` | Street address | +| `invitecode` | `string` | Referral invite code | +| `p2pqr` | `string` | URL to this user's P2P QR code image | +| `type` | `string` | `"success"` or `"error"` | + +--- + +### `props` Object + +| Field | Type | Description | +|---|---|---| +| `acc` | `string` | The user's Fahipay wallet account number | +| `walletType` | `string` | `"basic"` or `"premium"` | +| `wsize` | `number` | Wallet size / transaction limit | +| `two_factor_enabled` | `number` | `1` if TOTP 2FA is active | +| `efaas_login_enabled` | `number` | `1` if eFaas login is enabled | +| `IDverified` | `number` | `1` if identity is verified | +| `allowedActions` | `string[]` | Permitted operations: `withdraw`, `payment`, `topup`, `transfer` | + +> `props.acc` is the wallet account number shown in the app and used as the primary account identifier. + +--- + +### `props.accs` — Linked Bank Accounts + +Contains the user's bank accounts linked to Fahipay, organised by bank code. Used when topping up or withdrawing via linked banks. + +| Key | Bank | +|---|---| +| `bml` | Bank of Maldives | +| `mib` | Maldives Islamic Bank | +| `cbm` | Central Bank of Maldives | +| `sbi` | State Bank of India | + +Each bank entry is an object of named account numbers: + +```json +"bml": { + "mvr": "7730000145458", + "usd": "7730000199959", + "mvr2": "7770000045775" +} +``` + +Store the raw JSON of `props.accs` — it is needed to determine the source account when initiating top-ups or withdrawals. + +--- + +### `props.withdraw` + +| Field | Description | +|---|---| +| `fee` | Withdrawal fee amount | +| `unit` | Fee unit — `%` = percentage, otherwise fixed | +| `freelimit` | Free withdrawal limit (0 = no free limit) | + +--- + +## Error Response + +```json +{ + "title": "Error", + "msg": "Unauthorized", + "type": "error" +} +``` + +If the `authID` is invalid or expired, re-run the full [login flow](01-login.md). + +--- + +  + +--- + +[← OTP / 2FA](02-otp.md)     **Next →** [Balance](04-balance.md) diff --git a/docs/fahipayapi/04-balance.md b/docs/fahipayapi/04-balance.md new file mode 100644 index 0000000..2543cca --- /dev/null +++ b/docs/fahipayapi/04-balance.md @@ -0,0 +1,109 @@ +# Wallet Balance + +Fetch the current balance of the authenticated user's Fahipay wallet. + +--- + +## Endpoint + +``` +GET https://fahipay.mv/actions/getbalance/?lang=en +``` + +--- + +## Prerequisites + +- Valid `authID` from [login](01-login.md) or [OTP](02-otp.md) +- Valid `__Secure-sess` session cookie + +--- + +## Request + +### Headers + +| Header | Value | +|---|---| +| `authid` | `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` | +| `content-type` | `multipart/form-data` | +| `User-Agent` | `okhttp/4.12.0` | +| `Accept-Encoding` | `gzip` | +| `Connection` | `Keep-Alive` | +| `Cookie` | `__Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` | + +### Query Parameters + +| Parameter | Value | Description | +|---|---|---| +| `lang` | `en` | Language — always `en` | + +--- + +## curl Example + +```bash +curl --request GET \ + --url 'https://fahipay.mv/actions/getbalance/?lang=en' \ + --compressed \ + --header 'Accept-Encoding: gzip' \ + --header 'Connection: Keep-Alive' \ + --header 'Cookie: __Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \ + --header 'User-Agent: okhttp/4.12.0' \ + --header 'authid: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \ + --header 'content-type: multipart/form-data' +``` + +--- + +## Response + +### Success + +```json +{ + "balance": 1.01, + "rewards": "0", + "error": false, + "type": "success" +} +``` + +| Field | Type | Description | +|---|---|---| +| `balance` | `number` | Current wallet balance in MVR | +| `rewards` | `string` | Rewards/cashback points balance | +| `error` | `bool` | `false` on success | +| `type` | `string` | `"success"` | + +> All Fahipay wallet balances are in **MVR** (Maldivian Rufiyaa). There is no multi-currency wallet. + +--- + +### Error + +```json +{ + "error": true, + "type": "error", + "msg": "Unauthorized" +} +``` + +If `error` is `true` or `type` is `"error"`, the session is invalid. Re-run the [login flow](01-login.md). + +--- + +## Notes + +- This endpoint only returns the Fahipay wallet balance, not any linked bank account balances. +- The `rewards` field is returned as a string even though it represents a numeric value. Parse it with `toDoubleOrNull()`. +- Call this endpoint after [fetching the profile](03-profile.md) to construct the full account object with both account number and balance. + +--- + +  + +--- + +[← Profile](03-profile.md)     **Next →** [Transaction History](05-history.md) diff --git a/docs/fahipayapi/05-history.md b/docs/fahipayapi/05-history.md new file mode 100644 index 0000000..4968b1f --- /dev/null +++ b/docs/fahipayapi/05-history.md @@ -0,0 +1,250 @@ +# Transaction History + +Fetch the user's paginated wallet activity log. Each entry represents a single transaction: top-up, payment, transfer, or withdrawal. + +--- + +## Endpoint + +``` +GET https://fahipay.mv/actions/activity/?s={start}&l={limit}&lang=en +``` + +--- + +## Prerequisites + +- Valid `authID` from [login](01-login.md) or [OTP](02-otp.md) +- Valid `__Secure-sess` session cookie + +--- + +## Request + +### Headers + +| Header | Value | +|---|---| +| `authid` | `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` | +| `content-type` | `multipart/form-data` | +| `User-Agent` | `okhttp/4.12.0` | +| `Accept-Encoding` | `gzip` | +| `Connection` | `Keep-Alive` | +| `Cookie` | `__Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` | + +### Query Parameters + +| Parameter | Description | Example | +|---|---|---| +| `s` | Start offset (0-based) | `0`, `15`, `30` | +| `l` | Number of entries to return per page | `15` | +| `lang` | Language | `en` | + +--- + +## Pagination + +The API uses offset-based pagination via the `s` (start) and `l` (limit) parameters. + +| Page | URL | +|---|---| +| First | `/actions/activity/?s=0&l=15&lang=en` | +| Second | `/actions/activity/?s=15&l=15&lang=en` | +| Third | `/actions/activity/?s=30&l=15&lang=en` | +| N-th | `/actions/activity/?s={(N-1)*15}&l=15&lang=en` | + +The response includes a `total` count and a `next` URL. Stop fetching when: +- The returned `entries` array is empty, **or** +- `s + entries.length >= total` + +--- + +## curl Examples + +### Page 1 + +```bash +curl --request GET \ + --url 'https://fahipay.mv/actions/activity/?s=0&l=15&lang=en' \ + --compressed \ + --header 'Accept-Encoding: gzip' \ + --header 'Connection: Keep-Alive' \ + --header 'Cookie: __Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \ + --header 'User-Agent: okhttp/4.12.0' \ + --header 'authid: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \ + --header 'content-type: multipart/form-data' +``` + +### Page 2 + +```bash +curl --request GET \ + --url 'https://fahipay.mv/actions/activity/?s=15&l=15&lang=en' \ + --compressed \ + --header 'Accept-Encoding: gzip' \ + --header 'Connection: Keep-Alive' \ + --header 'Cookie: __Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \ + --header 'User-Agent: okhttp/4.12.0' \ + --header 'authid: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \ + --header 'content-type: multipart/form-data' +``` + +--- + +## Response + +```json +{ + "entries": [ + { + "date": "2026-05-16 15:10:25", + "name": "Cash Deposit", + "details": "Transferred Via BML ebanking", + "icon": "https://fahipay.mv/images/app/bml.png", + "transaction": "FP20260101120000XXXX", + "type": "topup", + "amount": 0.01, + "success": 1, + "status": "Success" + }, + { + "date": "2026-03-01 10:00:00", + "name": "Fitr Zakat Payment", + "details": "Payment for Fitr Zakat - 1447", + "icon": "https://fahipay.mv/images/app/zakat_service.png", + "transaction": "FP20260301100000XXXX", + "type": "payment", + "subtype": "FTZKT", + "data": { + "nid": "A123456", + "name": "Mohamed Ali", + "categories": [ + { + "type": 7, + "count": 1, + "price": "10.00", + "name": "Normal Wheet" + } + ], + "sadaqat": "0.00" + }, + "amount": -10, + "success": 1, + "status": "Success" + }, + { + "date": "2026-02-01 09:00:00", + "name": "Ooredoo Raastas", + "details": "Mobile Recharge - 9600000001", + "icon": "https://fahipay.mv/images/app/ooredoo.png", + "transaction": "FP20260201090000XXXX", + "type": "payment", + "subtype": "OORCH", + "amount": -100, + "success": 1, + "status": "Success" + } + ], + "total": 42, + "next": "https://fahipay.mv/actions/activity/?s=15&l=15", + "type": "success" +} +``` + +--- + +## Response Fields + +### Top-level + +| Field | Type | Description | +|---|---|---| +| `entries` | `array` | List of transaction entries for this page | +| `total` | `number` | Total number of transactions across all pages | +| `next` | `string` | URL of the next page (`null` or absent on last page) | +| `type` | `string` | `"success"` | + +--- + +### Entry Object + +| Field | Type | Description | +|---|---|---| +| `date` | `string` | Transaction date/time — format: `YYYY-MM-DD HH:mm:ss` | +| `name` | `string` | Human-readable transaction name (e.g. `"Cash Deposit"`, `"Ooredoo Raastas"`) | +| `details` | `string` | Secondary description (e.g. `"Transferred Via BML ebanking"`, `"Mobile Recharge - 9198026"`) | +| `icon` | `string` | URL of the merchant/bank icon image | +| `transaction` | `string` | Unique transaction reference ID (e.g. `FP20260516151002ZXGD`) | +| `type` | `string` | Transaction category — see table below | +| `subtype` | `string` | Optional service-specific subtype code (e.g. `OORCH`, `DHBPY`) | +| `amount` | `number` | Transaction amount in MVR — **negative = debit, positive = credit** | +| `success` | `number` | `1` = successful, `0` = failed | +| `status` | `string` | Human-readable status string (e.g. `"Success"`, `"Failed"`) | +| `data` | `object` | Optional. Present on some payment types with extra metadata | + +--- + +### Transaction Types (`type` field) + +| Value | Description | +|---|---| +| `topup` | Money deposited into the wallet (credit, positive amount) | +| `payment` | Money paid out for a service (debit, negative amount) | +| `transfer` | Peer-to-peer transfer to/from another Fahipay user | +| `withdraw` | Money withdrawn from the wallet to a bank account | + +--- + +### Known Subtypes (`subtype` field) + +| Code | Service | +|---|---| +| `OORCH` | Ooredoo Raastas (mobile top-up) | +| `OOBPY` | Ooredoo BillPay | +| `DHRCH` | Dhiraagu Reload (mobile top-up) | +| `DHBPY` | Dhiraagu BillPay | +| `DHPKG` | Dhiraagu Package (data package) | +| `FTZKT` | Fitr Zakat Payment | + +--- + +### Transaction ID Format + +``` +FP + YYYYMMDDHHMMSS + XXXX +``` + +Example: `FP20260101120000XXXX` +- `FP` — Fahipay prefix +- `20260101` — date (2026-01-01) +- `120000` — time (12:00:00) +- `XXXX` — 4-char random suffix + +--- + +## Amount Sign Convention + +| Sign | Meaning | +|---|---| +| Positive (`+`) | Credit — money received (top-up, incoming transfer) | +| Negative (`-`) | Debit — money spent (payment, withdrawal, outgoing transfer) | + +--- + +## Date Format + +All dates are in local Maldives time (UTC+5), formatted as: + +``` +YYYY-MM-DD HH:mm:ss +``` + +Example: `2026-05-16 15:10:25` + +--- + +  + +--- + +[← Balance](04-balance.md)     **Next →** [Profile Picture](06-profile-picture.md) diff --git a/docs/fahipayapi/06-profile-picture.md b/docs/fahipayapi/06-profile-picture.md new file mode 100644 index 0000000..a0c9c9e --- /dev/null +++ b/docs/fahipayapi/06-profile-picture.md @@ -0,0 +1,117 @@ +# Profile Picture + +Fetch the authenticated user's profile picture. The endpoint redirects to the actual image URL. + +--- + +## Endpoint + +``` +GET https://fahipay.mv/images/profiles/picture/?t={timestamp} +``` + +--- + +## Prerequisites + +- Valid `authID` from [login](01-login.md) or [OTP](02-otp.md) +- Valid `__Secure-sess` session cookie + +--- + +## Request + +### Headers + +| Header | Value | +|---|---| +| `authid` | `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` | +| `User-Agent` | `okhttp/4.12.0` | +| `Accept-Encoding` | `gzip` | +| `Connection` | `Keep-Alive` | +| `Cookie` | `__Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` | + +### Query Parameters + +| Parameter | Description | Example | +|---|---|---| +| `t` | Cache-busting timestamp string | `Sat May 16 2026 14:57:52 GMT+0500` | + +The `t` parameter is a URL-encoded timestamp used to prevent browser caching. The value can be any string — the server ignores it for routing purposes. + +--- + +## curl Example + +```bash +curl --request GET \ + --url 'https://fahipay.mv/images/profiles/picture/?t=Sat%20Jan%2001%202026%2012:00:00%20GMT+0500' \ + --compressed \ + --header 'Accept-Encoding: gzip' \ + --header 'Connection: Keep-Alive' \ + --header 'Cookie: __Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \ + --header 'User-Agent: okhttp/4.12.0' \ + --header 'authid: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' +``` + +--- + +## Response + +### Success + +The server responds with `HTTP 302` and a `Location` header pointing to the actual image URL. + +``` +HTTP/1.1 302 Found +Location: https://fahipay.mv/images/profiles/0000/avatar.jpg?v=0000000000 +``` + +Follow the redirect to download the image. The final response is the raw image bytes (`image/jpeg` or `image/png`). + +--- + +### No Picture Set + +If the user has not uploaded a profile picture, the redirect points to a default placeholder image: + +``` +Location: https://fahipay.mv/images/profiles/default.png +``` + +--- + +### Error + +If the session is invalid, the server returns `HTTP 401` or redirects to an error page. + +--- + +## Implementation Notes + +- HTTP clients that follow redirects automatically (e.g. `OkHttpClient` with `followRedirects(true)`) will return the image bytes directly. +- Use `followRedirects(false)` and read the `Location` header if you need the resolved image URL separately. +- The image URL contains the user's `profileID` in the path — this matches the `profileID` field from the [profile response](03-profile.md). +- The `v=` query parameter in the image URL is a version/cache key. It changes when the user updates their picture. + +--- + +## Suggested Usage + +``` +timestamp = current time formatted as URL-safe string +GET /images/profiles/picture/?t={timestamp} + → 302 Location: + → GET + → image bytes +``` + +Cache the downloaded image by `profileID` and re-fetch when the user explicitly refreshes, rather than on every app launch. + +--- + +  + +--- + +[← Transaction History](05-history.md) diff --git a/docs/fahipayapi/README.md b/docs/fahipayapi/README.md new file mode 100644 index 0000000..b2e8c9b --- /dev/null +++ b/docs/fahipayapi/README.md @@ -0,0 +1,129 @@ +# Fahipay API Documentation + +Reverse-engineered from traffic captures of the Fahipay Android WebView app (`fahipay.mv`). + +--- + +## Overview + +Fahipay is a Maldivian digital wallet service. The API uses a mix of `multipart/form-data` POST requests for authentication and simple authenticated `GET` requests for data retrieval. + +Authentication is session-based: +- A `__Secure-sess` cookie is set by the server on first contact and must be sent with every request. +- After login (and optional TOTP verification), the server returns an `authID` token that must be sent as an `authid` header with every subsequent request. + +--- + +## Base URL + +``` +https://fahipay.mv +``` + +--- + +## Authentication Model + +| Value | How obtained | How used | +|---|---|---| +| `__Secure-sess` cookie | Set by server on first request | Sent automatically via cookie jar | +| `authID` | Returned by `/api/app/login/` or `/api/app/otp/` | Sent as `authid: ` header | + +Both must be present on every authenticated request. + +--- + +## Common Request Headers + +### Login / OTP endpoints +``` +Content-Type: multipart/form-data; boundary= +accept: application/json +accept-encoding: gzip, deflate, br +connection: keep-alive +user-agent: Mozilla/5.0 (Linux; Android 14; 22101320I Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36 +``` + +### Authenticated data endpoints +``` +Accept-Encoding: gzip +Connection: Keep-Alive +User-Agent: okhttp/4.12.0 +authid: +content-type: multipart/form-data +``` + +--- + +## Common Form Fields (Device Info) + +All login and OTP requests include a standard set of device fields: + +| Field | Example value | Notes | +|---|---|---| +| `device[available]` | `true` | Always `true` | +| `device[platform]` | `Android` | Always `Android` | +| `device[uuid]` | `a1b2c3d4e5f60718` | 16 hex chars, generated once per install, persisted | +| `device[model]` | `22101320I` | Device model string | +| `device[manufacturer]` | `Xiaomi` | Device manufacturer | +| `device[isVirtual]` | `false` | Always `false` | +| `device[serial]` | `unknown` | Always `unknown` | + +The `device[uuid]` must be consistent across all requests from the same install. Generate it once and store it permanently. + +--- + +## Login Flow + +``` +Client Server + | | + | POST /api/app/login/ | + | { email=IDCARD, password, ... } | + |---------------------------------->| + | { two_factor_required: bool } | + |<----------------------------------| + | | + | (if two_factor_required=true) | + | POST /api/app/otp/ | + | { code=TOTP, channel=totp, ... } | + |---------------------------------->| + | { authID: "..." } | + |<----------------------------------| + | | + | (if two_factor_required=false) | + | authID already in login response | + | | + | GET /actions/getprofile/ | + | authid: | + |---------------------------------->| + | { fullname, profileID, ... } | + |<----------------------------------| + | | + | GET /actions/getbalance/ | + | authid: | + |---------------------------------->| + | { balance: 1.01 } | + |<----------------------------------| +``` + +--- + +## Documents + +| # | File | Description | +|---|---|---| +| 1 | [Login](01-login.md) | Authenticate with ID card and password | +| 2 | [OTP / 2FA](02-otp.md) | TOTP verification when 2FA is enabled | +| 3 | [Profile](03-profile.md) | Fetch user profile and linked bank accounts | +| 4 | [Balance](04-balance.md) | Fetch wallet balance | +| 5 | [Transaction History](05-history.md) | Paginated activity/transaction history | +| 6 | [Profile Picture](06-profile-picture.md) | Fetch user profile picture | + +--- + +  + +--- + +> **Next →** [Login](01-login.md) diff --git a/docs/fahipayapi/fahipay_logo_long.svg b/docs/fahipayapi/fahipay_logo_long.svg new file mode 100644 index 0000000..f407799 --- /dev/null +++ b/docs/fahipayapi/fahipay_logo_long.svg @@ -0,0 +1 @@ + \ No newline at end of file