forked from shihaam/thijooree
add support for fahipay login and view history
This commit is contained in:
@@ -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) ──────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 159 KiB |
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user