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
@@ -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"
+13
View File
@@ -30,6 +30,19 @@
android:defaultValue="MIB" />
</action>
<action
android:id="@+id/action_bankSelection_to_credentials_fahipay"
app:destination="@id/credentialsFragment"
app:enterAnim="@anim/slide_in_right"
app:exitAnim="@anim/slide_out_left"
app:popEnterAnim="@anim/slide_in_left"
app:popExitAnim="@anim/slide_out_right">
<argument
android:name="bankType"
app:argType="string"
android:defaultValue="MIB" />
</action>
</fragment>
<fragment
+7
View File
@@ -19,6 +19,13 @@
<string name="mib_desc">Faisanet Mobile Banking</string>
<string name="bml_name">Bank of Maldives</string>
<string name="bml_desc">BML Internet Banking</string>
<string name="fahipay_name">Fahipay</string>
<string name="fahipay_desc">Digital Wallet</string>
<string name="fahipay_sign_in_desc">Enter your Fahipay ID card number and password.</string>
<string name="fahipay_id_card">ID Card Number</string>
<string name="fahipay_totp_code">Authenticator Code (6 digits)</string>
<string name="fahipay_totp_hint">Enter the code from your authenticator app</string>
<string name="fahipay_verify">Verify</string>
<string name="sign_in">Sign In</string>
<string name="sign_in_desc">Enter your Maldives Islamic Bank credentials.</string>
<string name="bml_sign_in_desc">Enter your Bank of Maldives credentials.</string>