add support for fahipay login and view history
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s

This commit is contained in:
2026-05-16 21:31:34 +05:00
parent 99a32dc9ed
commit 7864655a82
29 changed files with 2097 additions and 32 deletions

1
.gitignore vendored
View File

@@ -15,5 +15,6 @@
local.properties
docs/mibapi/tmp
docs/bmlapi/tmp
docs/fahipayapi/tmp
tmp
app/key.jks

View File

@@ -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<MibProfile> = emptyList()
var bmlSession: BmlSession? = null
var bmlAccounts: List<MibAccount> = emptyList()
var fahipaySession: FahipaySession? = null
var fahipayAccounts: List<MibAccount> = emptyList()
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
val mibMutex = Mutex()

View File

@@ -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

View File

@@ -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<String, MutableList<Cookie>>()
private val cookieJar = object : CookieJar {
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) {
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<Cookie> =
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<List<Transaction>, 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<Pair<String, String>> = 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<String, String>): 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) }
}
}
}

View File

@@ -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
)

View File

@@ -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}"

View File

@@ -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<Transaction> = 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 {

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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

View File

@@ -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() {

View File

@@ -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

View File

@@ -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<MibAccount>) {
val arr = JSONArray()
@@ -86,6 +87,56 @@ object AccountCache {
} catch (e: Exception) { emptyList() }
}
fun saveFahipay(context: Context, accounts: List<MibAccount>) {
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<MibAccount> {
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()
}

View File

@@ -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<String, String>? {
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) ──────────────────────────────
/**

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

View File

@@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="190dp"
android:height="46dp"
android:viewportWidth="1142.31"
android:viewportHeight="277.3">
<!-- P letter mark - top stroke -->
<path
android:fillColor="#FF15BEA7"
android:pathData="M720.28,22.78c35,0,58.9,23.29,58.9,57.39,0,41.86-33,71-81.49,71h-38l-11.56,65.71H606.56L640.7,22.78ZM702.1,113.72c21.75,0,34.69-13.32,34.69-32.16,0-13.88-8.25-21.37-25.32-21.37H675.94l-9.63,53.53Z" />
<!-- a letter -->
<path
android:fillColor="#FF15BEA7"
android:pathData="M298.85,63.61h40.41L312.31,216.86H271.65l3.72-20.05a67,67,0,0,1-49.55,22.33c-32.67,0-58.74-25.19-58.74-66.45,0-48.12,35.23-91.39,81.64-91.39,20.34,0,37.82,8.31,46.69,22.08Zm-12,67.61c0-21.5-12.88-34.11-31.79-34.11-26.65,0-46.11,24.94-46.11,51.86,0,21.78,12.88,34.36,32.07,34.36C267.91,183.33,286.82,157.84,286.82,131.22Z" />
<!-- h letter -->
<path
android:fillColor="#FF15BEA7"
android:pathData="M510.16,120.9l-16.9,96H452.58l14.61-83.66c4-23.2-4.29-34.65-22.9-34.65-20.06,0-37,13.46-41.26,38.39l-14,79.92H348.31l37-209.13H426L412.5,84A64.85,64.85,0,0,1,462.32,61.3C494.42,61.3,516.47,84.81,510.16,120.9Z" />
<!-- i letter -->
<path
android:fillColor="#FF15BEA7"
android:pathData="M527.53,216.86,554.47,63.61h40.66L568.21,216.86ZM559,26.07C559,12.33,570.22,0,584.84,0c11.45,0,20.34,7.46,20.34,19.21,0,13.74-11.75,26.35-26.07,26.35C567.63,45.56,559,37.54,559,26.07Z" />
<!-- second a letter -->
<path
android:fillColor="#FF15BEA7"
android:pathData="M921.83,63.61h40.4L935.29,216.86H894.63l3.71-20.05a67,67,0,0,1-49.55,22.33c-32.67,0-58.74-25.19-58.74-66.45,0-48.12,35.24-91.39,81.65-91.39,20.34,0,37.82,8.31,46.69,22.08Zm-12,67.61c0-21.5-12.89-34.11-31.8-34.11-26.64,0-46.11,24.94-46.11,51.86,0,21.78,12.89,34.36,32.07,34.36C890.89,183.33,909.8,157.84,909.8,131.22Z" />
<!-- y letter -->
<path
android:fillColor="#FF15BEA7"
android:pathData="M1097.63,63.61h44.68l-97.39,176.15c-14.62,26.37-29.79,37.54-60.15,37.54H960.41L967,241.49h16.9c13.46,0,20.07-3.71,26.65-16l4.29-7.44L982.18,63.61H1024l18.91,105.13Z" />
<!-- logo mark - top arc -->
<path
android:fillColor="#FF15BEA7"
android:pathData="M156.34,57.58l8.92-49.85H102.89A78.06,78.06,0,0,0,26.05,72.09L23,89.49h0c16.8-22.76,42.41-31.91,70.71-31.91Z" />
<!-- logo mark - middle arc -->
<path
android:fillColor="#FF15BEA7"
android:pathData="M142.8,134l8.92-49.84H89.35a78,78,0,0,0-76.84,64.35L9.22,166.1a.19.19,0,0,0,.35.15C26.38,144,52.12,134,80.11,134Z" />
<!-- logo mark - bottom arc (gradient) -->
<path android:pathData="M9.55,166.25a.19.19,0,0,1-.34-.13c-.07.38-.32,1.73-.54,3L0,216.86H43.25l12.28-65.43c2.3-12.38,12.59-17.31,25.15-17.39h-.59C52.1,134,26.37,144,9.55,166.25Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="58.15"
android:startY="208.94"
android:endX="22.66"
android:endY="142.19"
android:type="linear">
<item android:offset="0.2" android:color="#FF15B79E" />
<item android:offset="0.36" android:color="#FF15A08B" />
<item android:offset="0.79" android:color="#FF13655C" />
<item android:offset="1.0" android:color="#FF134E4A" />
</gradient>
</aapt:attr>
</path>
</vector>

View File

@@ -117,5 +117,49 @@
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- Fahipay Card -->
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardFahipay"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="16dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<ImageView
android:layout_width="138dp"
android:layout_height="40dp"
android:src="@drawable/fahipay_logo_long"
android:contentDescription="@string/fahipay_name"
android:scaleType="fitStart"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/fahipay_name"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/fahipay_desc"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="4dp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
</ScrollView>

View File

@@ -42,6 +42,7 @@
android:layout_marginBottom="32dp" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilUsername"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/username"
@@ -73,6 +74,7 @@
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilOtpSeed"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/otp_seed"
@@ -89,6 +91,25 @@
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilTotpCode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/fahipay_totp_code"
android:layout_marginBottom="8dp"
app:helperText="@string/fahipay_totp_hint"
android:visibility="gone"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etTotpCode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:imeOptions="actionDone"
android:singleLine="true"
android:maxLength="6" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardOtp"
android:layout_width="match_parent"

View File

@@ -30,6 +30,19 @@
android:defaultValue="MIB" />
</action>
<action
android:id="@+id/action_bankSelection_to_credentials_fahipay"
app:destination="@id/credentialsFragment"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right">
<argument
android:name="bankType"
app:argType="string"
android:defaultValue="MIB" />
</action>
</fragment>
<fragment

View File

@@ -19,6 +19,13 @@
<string name="mib_desc">Faisanet Mobile Banking</string>
<string name="bml_name">Bank of Maldives</string>
<string name="bml_desc">BML Internet Banking</string>
<string name="fahipay_name">Fahipay</string>
<string name="fahipay_desc">Digital Wallet</string>
<string name="fahipay_sign_in_desc">Enter your Fahipay ID card number and password.</string>
<string name="fahipay_id_card">ID Card Number</string>
<string name="fahipay_totp_code">Authenticator Code (6 digits)</string>
<string name="fahipay_totp_hint">Enter the code from your authenticator app</string>
<string name="fahipay_verify">Verify</string>
<string name="sign_in">Sign In</string>
<string name="sign_in_desc">Enter your Maldives Islamic Bank credentials.</string>
<string name="bml_sign_in_desc">Enter your Bank of Maldives credentials.</string>

152
docs/fahipayapi/01-login.md Normal file
View File

@@ -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)**
---
&nbsp;
---
[← README](README.md) &nbsp;&nbsp;&nbsp; **Next →** [OTP / 2FA](02-otp.md)

158
docs/fahipayapi/02-otp.md Normal file
View File

@@ -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=<value>` |
On app restart, attempt requests with the stored session before falling back to a full re-login.
---
&nbsp;
---
[← Login](01-login.md) &nbsp;&nbsp;&nbsp; **Next →** [Profile](03-profile.md)

View File

@@ -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).
---
&nbsp;
---
[← OTP / 2FA](02-otp.md) &nbsp;&nbsp;&nbsp; **Next →** [Balance](04-balance.md)

View File

@@ -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.
---
&nbsp;
---
[← Profile](03-profile.md) &nbsp;&nbsp;&nbsp; **Next →** [Transaction History](05-history.md)

View File

@@ -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`
---
&nbsp;
---
[← Balance](04-balance.md) &nbsp;&nbsp;&nbsp; **Next →** [Profile Picture](06-profile-picture.md)

View File

@@ -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: <image URL>
→ GET <image URL>
→ image bytes
```
Cache the downloaded image by `profileID` and re-fetch when the user explicitly refreshes, rather than on every app launch.
---
&nbsp;
---
[← Transaction History](05-history.md)

129
docs/fahipayapi/README.md Normal file
View File

@@ -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: <value>` header |
Both must be present on every authenticated request.
---
## Common Request Headers
### Login / OTP endpoints
```
Content-Type: multipart/form-data; boundary=<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: <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: <authID> |
|---------------------------------->|
| { fullname, profileID, ... } |
|<----------------------------------|
| |
| GET /actions/getbalance/ |
| authid: <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 |
---
&nbsp;
---
> **Next →** [Login](01-login.md)

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1142.31 277.3"><defs><style>.cls-1{fill:#15bea7;}.cls-2{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" x1="58.15" y1="208.94" x2="22.66" y2="142.19" gradientUnits="userSpaceOnUse"><stop offset="0.2" stop-color="#15b79e"/><stop offset="0.36" stop-color="#15a08b"/><stop offset="0.79" stop-color="#13655c"/><stop offset="1" stop-color="#134e4a"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Layer_1-2" data-name="Layer 1"><path class="cls-1" d="M720.28,22.78c35,0,58.9,23.29,58.9,57.39,0,41.86-33,71-81.49,71h-38l-11.56,65.71H606.56L640.7,22.78ZM702.1,113.72c21.75,0,34.69-13.32,34.69-32.16,0-13.88-8.25-21.37-25.32-21.37H675.94l-9.63,53.53Z"/><path class="cls-1" d="M298.85,63.61h40.41L312.31,216.86H271.65l3.72-20.05a67,67,0,0,1-49.55,22.33c-32.67,0-58.74-25.19-58.74-66.45,0-48.12,35.23-91.39,81.64-91.39,20.34,0,37.82,8.31,46.69,22.08Zm-12,67.61c0-21.5-12.88-34.11-31.79-34.11-26.65,0-46.11,24.94-46.11,51.86,0,21.78,12.88,34.36,32.07,34.36C267.91,183.33,286.82,157.84,286.82,131.22Z"/><path class="cls-1" d="M510.16,120.9l-16.9,96H452.58l14.61-83.66c4-23.2-4.29-34.65-22.9-34.65-20.06,0-37,13.46-41.26,38.39l-14,79.92H348.31l37-209.13H426L412.5,84A64.85,64.85,0,0,1,462.32,61.3C494.42,61.3,516.47,84.81,510.16,120.9Z"/><path class="cls-1" d="M527.53,216.86,554.47,63.61h40.66L568.21,216.86ZM559,26.07C559,12.33,570.22,0,584.84,0c11.45,0,20.34,7.46,20.34,19.21,0,13.74-11.75,26.35-26.07,26.35C567.63,45.56,559,37.54,559,26.07Z"/><path class="cls-1" d="M921.83,63.61h40.4L935.29,216.86H894.63l3.71-20.05a67,67,0,0,1-49.55,22.33c-32.67,0-58.74-25.19-58.74-66.45,0-48.12,35.24-91.39,81.65-91.39,20.34,0,37.82,8.31,46.69,22.08Zm-12,67.61c0-21.5-12.89-34.11-31.8-34.11-26.64,0-46.11,24.94-46.11,51.86,0,21.78,12.89,34.36,32.07,34.36C890.89,183.33,909.8,157.84,909.8,131.22Z"/><path class="cls-1" d="M1097.63,63.61h44.68l-97.39,176.15c-14.62,26.37-29.79,37.54-60.15,37.54H960.41L967,241.49h16.9c13.46,0,20.07-3.71,26.65-16l4.29-7.44L982.18,63.61H1024l18.91,105.13Z"/><path class="cls-1" d="M156.34,57.58l8.92-49.85H102.89A78.06,78.06,0,0,0,26.05,72.09L23,89.49h0c16.8-22.76,42.41-31.91,70.71-31.91Z"/><path class="cls-1" d="M142.8,134l8.92-49.84H89.35a78,78,0,0,0-76.84,64.35L9.22,166.1a.19.19,0,0,0,.35.15C26.38,144,52.12,134,80.11,134Z"/><path class="cls-2" d="M9.55,166.25a.19.19,0,0,1-.34-.13c-.07.38-.32,1.73-.54,3L0,216.86H43.25l12.28-65.43c2.3-12.38,12.59-17.31,25.15-17.39h-.59C52.1,134,26.37,144,9.55,166.25Z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.5 KiB