add support for fahipay login and view history
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
All checks were successful
Auto Tag on Version Change / check-version (push) Successful in 2s
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,5 +15,6 @@
|
||||
local.properties
|
||||
docs/mibapi/tmp
|
||||
docs/bmlapi/tmp
|
||||
docs/fahipayapi/tmp
|
||||
tmp
|
||||
app/key.jks
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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}"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) ──────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
BIN
app/src/main/res/drawable/fahipay_logo.png
Normal file
BIN
app/src/main/res/drawable/fahipay_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
66
app/src/main/res/drawable/fahipay_logo_long.xml
Normal file
66
app/src/main/res/drawable/fahipay_logo_long.xml
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
152
docs/fahipayapi/01-login.md
Normal 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)**
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← README](README.md) **Next →** [OTP / 2FA](02-otp.md)
|
||||
158
docs/fahipayapi/02-otp.md
Normal file
158
docs/fahipayapi/02-otp.md
Normal 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← Login](01-login.md) **Next →** [Profile](03-profile.md)
|
||||
229
docs/fahipayapi/03-profile.md
Normal file
229
docs/fahipayapi/03-profile.md
Normal 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).
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← OTP / 2FA](02-otp.md) **Next →** [Balance](04-balance.md)
|
||||
109
docs/fahipayapi/04-balance.md
Normal file
109
docs/fahipayapi/04-balance.md
Normal 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← Profile](03-profile.md) **Next →** [Transaction History](05-history.md)
|
||||
250
docs/fahipayapi/05-history.md
Normal file
250
docs/fahipayapi/05-history.md
Normal 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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← Balance](04-balance.md) **Next →** [Profile Picture](06-profile-picture.md)
|
||||
117
docs/fahipayapi/06-profile-picture.md
Normal file
117
docs/fahipayapi/06-profile-picture.md
Normal 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← Transaction History](05-history.md)
|
||||
129
docs/fahipayapi/README.md
Normal file
129
docs/fahipayapi/README.md
Normal 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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
> **Next →** [Login](01-login.md)
|
||||
1
docs/fahipayapi/fahipay_logo_long.svg
Normal file
1
docs/fahipayapi/fahipay_logo_long.svg
Normal 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 |
Reference in New Issue
Block a user