forked from shihaam/thijooree
add support for fahipay login and view history
This commit is contained in:
@@ -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
|
||||
)
|
||||
Reference in New Issue
Block a user