add support for fahipay login and view history

This commit is contained in:
2026-05-16 21:31:34 +05:00
parent 99a32dc9ed
commit 7864655a82
29 changed files with 2097 additions and 32 deletions
@@ -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
)