21 Commits
Author SHA1 Message Date
shihaam 89a9731797 new version
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 4m24s
2026-05-19 00:15:19 +05:00
shihaam 50badc7d54 support for edge to edge
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-19 00:13:42 +05:00
shihaam d4f86bb738 optimize OTP seed (check password =) remove legacy code
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-18 23:57:05 +05:00
shihaam b35f44f35b optimize bank login screens, add support for otpauth://totp/ URI
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-18 23:49:42 +05:00
shihaam 1a58ce8b54 disable welcome screen page switch with dots
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-18 23:36:31 +05:00
shihaam 2dd84ec50a countdown now starts after scoll
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-18 23:30:21 +05:00
shihaam 33651ca107 redesign the welcome pages
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-18 23:12:51 +05:00
shihaam ae307e3118 add biometrics disabled hint
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-18 21:46:34 +05:00
shihaam 3ab75bff92 fix contact add with multi BML logins
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-18 05:52:18 +05:00
shihaam 1753d648bd loading indicator for account info fetch and tranfer
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-18 05:31:20 +05:00
shihaam 423b0bf1e1 dark theme on bml recipt and hide bottom nav bar on recipt page 2026-05-18 05:21:46 +05:00
shihaam d713047970 keep bottom bar behind keyboard
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-18 05:12:49 +05:00
shihaam d59a6fad82 new feature: resume last activity after unlocking instead of goind to dashboard everytime
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-18 05:09:42 +05:00
shihaam 8e47101401 refactor: extract BML/MIB product label parsing into dedicated parser utilities
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-18 04:53:40 +05:00
shihaam 9431a90cd0 add card icons
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-18 04:35:20 +05:00
shihaam 3a10f36c39 fix prod build
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-18 00:43:05 +05:00
shihaam 27c428d1f6 fix prod build
Auto Tag on Version Change / check-version (push) Successful in 5s
Build and Release APK / build (push) Successful in 4m18s
2026-05-18 00:39:20 +05:00
shihaam 7b4f650f4e auto select all tab in contact picker on search bar selection
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Failing after 4m4s
2026-05-18 00:32:55 +05:00
shihaam 00e109562b add support for multiple BML accounts, and BML credit cards
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-18 00:29:52 +05:00
shihaam cd4b3fef8b redeisgn accounts page
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-17 22:34:15 +05:00
shihaam 389344a192 normaize account input
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-17 21:45:49 +05:00
56 changed files with 1406 additions and 653 deletions
+15 -2
View File
@@ -3,7 +3,7 @@
<component name="deploymentTargetSelector"> <component name="deploymentTargetSelector">
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DIALOG" />
<DropdownSelection timestamp="2026-05-15T13:54:16.798188666Z"> <DropdownSelection timestamp="2026-05-15T13:54:16.798188666Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
@@ -11,7 +11,20 @@
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>
<DialogSelection /> <DialogSelection>
<targets>
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=ce61d84c" />
</handle>
</Target>
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=683a9830" />
</handle>
</Target>
</targets>
</DialogSelection>
</SelectionState> </SelectionState>
</selectionStates> </selectionStates>
</component> </component>
+5 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "sh.sar.basedbank" applicationId = "sh.sar.basedbank"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 1 versionCode = 2
versionName = "1.0" versionName = "1.0.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -64,6 +64,9 @@ dependencies {
// RecyclerView for accounts list // RecyclerView for accounts list
implementation("androidx.recyclerview:recyclerview:1.3.2") implementation("androidx.recyclerview:recyclerview:1.3.2")
// CircularProgressDrawable for spinning search icons
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
// OkHttp for API calls // OkHttp for API calls
implementation("com.squareup.okhttp3:okhttp:4.11.0") implementation("com.squareup.okhttp3:okhttp:4.11.0")
+2 -1
View File
@@ -48,7 +48,8 @@
<activity <activity
android:name=".ui.home.HomeActivity" android:name=".ui.home.HomeActivity"
android:exported="false" /> android:exported="false"
android:windowSoftInputMode="adjustPan" />
<activity <activity
android:name=".ui.home.QrScannerActivity" android:name=".ui.home.QrScannerActivity"
@@ -19,11 +19,19 @@ class BasedBankApp : Application() {
var fullName: String = "" var fullName: String = ""
var mibSession: MibSession? = null var mibSession: MibSession? = null
var mibProfiles: List<MibProfile> = emptyList() var mibProfiles: List<MibProfile> = emptyList()
var bmlSession: BmlSession? = null /** Active BML sessions keyed by loginId (= BML username). */
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
var bmlAccounts: List<MibAccount> = emptyList() var bmlAccounts: List<MibAccount> = emptyList()
var fahipaySession: FahipaySession? = null var fahipaySession: FahipaySession? = null
var fahipayAccounts: List<MibAccount> = emptyList() var fahipayAccounts: List<MibAccount> = emptyList()
/** Returns the BML session for the given account (matched via loginTag). */
fun bmlSessionFor(account: MibAccount): BmlSession? =
bmlSessions[account.loginTag.removePrefix("bml_")]
/** Returns any available BML session (for non-account-specific operations). */
fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull()
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */ /** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
val mibMutex = Mutex() val mibMutex = Mutex()
@@ -10,6 +10,9 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -18,8 +21,6 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.databinding.ActivityLockBinding import sh.sar.basedbank.databinding.ActivityLockBinding
import sh.sar.basedbank.ui.home.HomeActivity import sh.sar.basedbank.ui.home.HomeActivity
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import java.security.MessageDigest
import java.security.SecureRandom
import javax.crypto.SecretKeyFactory import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec import javax.crypto.spec.PBEKeySpec
@@ -31,7 +32,6 @@ class LockActivity : AppCompatActivity() {
private lateinit var salt: String private lateinit var salt: String
private lateinit var storedHash: String private lateinit var storedHash: String
private var biometricsEnabled = false private var biometricsEnabled = false
private var isLegacyFormat = false
private var isVerifying = false private var isVerifying = false
private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE) private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE)
@@ -39,28 +39,32 @@ class LockActivity : AppCompatActivity() {
companion object { companion object {
private const val MAX_ATTEMPTS = 5 private const val MAX_ATTEMPTS = 5
private const val LOCKOUT_MS = 30_000L private const val LOCKOUT_MS = 30_000L
const val EXTRA_RESUME = "resume"
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityLockBinding.inflate(layoutInflater) binding = ActivityLockBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
val isLight = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_NO
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
insets
}
val prefs = getSharedPreferences("prefs", MODE_PRIVATE) val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
method = prefs.getString("security_method", "pin") ?: "pin" method = prefs.getString("security_method", "pin") ?: "pin"
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false) biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
// Try new encrypted format first; fall back to legacy SHA-256 val stored = CredentialStore(this).loadSecurityHash() ?: run { finish(); return }
val stored = CredentialStore(this).loadSecurityHash() salt = stored.first
if (stored != null) { storedHash = stored.second
salt = stored.first
storedHash = stored.second
isLegacyFormat = false
} else {
salt = prefs.getString("security_salt", "") ?: ""
storedHash = prefs.getString("security_hash", "") ?: ""
isLegacyFormat = true
}
if (method == "pin") { if (method == "pin") {
binding.viewPin.visibility = View.VISIBLE binding.viewPin.visibility = View.VISIBLE
@@ -149,7 +153,6 @@ class LockActivity : AppCompatActivity() {
val ok = withContext(Dispatchers.Default) { verify(entered) } val ok = withContext(Dispatchers.Default) { verify(entered) }
isVerifying = false isVerifying = false
if (ok) { if (ok) {
migrateIfNeeded(entered)
resetFailures() resetFailures()
proceed() proceed()
} else { } else {
@@ -174,7 +177,6 @@ class LockActivity : AppCompatActivity() {
val ok = withContext(Dispatchers.Default) { verify(entered) } val ok = withContext(Dispatchers.Default) { verify(entered) }
isVerifying = false isVerifying = false
if (ok) { if (ok) {
migrateIfNeeded(entered)
resetFailures() resetFailures()
proceed() proceed()
} else { } else {
@@ -216,30 +218,8 @@ class LockActivity : AppCompatActivity() {
private fun verify(input: String): Boolean { private fun verify(input: String): Boolean {
if (storedHash.isBlank()) return false if (storedHash.isBlank()) return false
return if (isLegacyFormat) { val saltBytes = Base64.decode(salt, Base64.NO_WRAP)
sha256Legacy(salt + input) == storedHash return pbkdf2(input, saltBytes) == storedHash
} else {
val saltBytes = Base64.decode(salt, Base64.NO_WRAP)
pbkdf2(input, saltBytes) == storedHash
}
}
/**
* On the first successful unlock after legacy SHA-256 format is detected,
* transparently migrate to PBKDF2 + CredentialStore.
*/
private fun migrateIfNeeded(input: String) {
if (!isLegacyFormat) return
try {
val newSalt = ByteArray(16).also { SecureRandom().nextBytes(it) }
val newHash = pbkdf2(input, newSalt)
val saltB64 = Base64.encodeToString(newSalt, Base64.NO_WRAP)
CredentialStore(this).saveSecurityHash(saltB64, newHash)
// Remove legacy plaintext fields
getSharedPreferences("prefs", MODE_PRIVATE).edit()
.remove("security_salt").remove("security_hash").apply()
isLegacyFormat = false
} catch (_: Exception) { /* migration will retry next unlock */ }
} }
private fun triggerBiometric() { private fun triggerBiometric() {
@@ -270,8 +250,12 @@ class LockActivity : AppCompatActivity() {
} }
private fun proceed() { private fun proceed() {
startActivity(Intent(this, HomeActivity::class.java)) if (intent.getBooleanExtra(EXTRA_RESUME, false)) {
finish() finish()
} else {
startActivity(Intent(this, HomeActivity::class.java))
finish()
}
} }
// ── Brute-force tracking ────────────────────────────────────────────────── // ── Brute-force tracking ──────────────────────────────────────────────────
@@ -308,9 +292,4 @@ class LockActivity : AppCompatActivity() {
} }
} }
/** Legacy: raw SHA-256(salt + input) — only used for migration path. */
private fun sha256Legacy(input: String) = MessageDigest.getInstance("SHA-256")
.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
} }
@@ -166,17 +166,17 @@ class BmlLoginFlow {
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed") .takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
val session = BmlSession(accessToken = accessToken, deviceId = deviceId) val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
val accounts = fetchAccounts(session) val accounts = fetchAccounts(session, "bml_$username")
return Pair(session, accounts) return Pair(session, accounts)
} }
fun fetchAccounts(session: BmlSession): List<MibAccount> { fun fetchAccounts(session: BmlSession, loginTag: String): List<MibAccount> {
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute() val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute()
val code = resp.code val code = resp.code
val json = resp.body?.string() val json = resp.body?.string()
resp.close() resp.close()
if (code == 401 || code == 419) throw AuthExpiredException() if (code == 401 || code == 419) throw AuthExpiredException()
return parseDashboard(json ?: return emptyList(), "bml_${session.deviceId}") return parseDashboard(json ?: return emptyList(), loginTag)
} }
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> { fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
@@ -324,11 +324,11 @@ class BmlLoginFlow {
return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false } return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false }
} }
fun fetchContacts(session: BmlSession): List<MibBeneficiary> { fun fetchContacts(session: BmlSession, loginId: String): List<MibBeneficiary> {
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/contacts")).execute() val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/contacts")).execute()
val json = resp.body?.string() ?: return emptyList() val json = resp.body?.string() ?: return emptyList()
resp.close() resp.close()
return parseContacts(json) return parseContacts(json, loginId)
} }
/** /**
@@ -634,29 +634,28 @@ class BmlLoginFlow {
internalId = internalId internalId = internalId
)) ))
} else if (accountType == "Card") { } else if (accountType == "Card") {
val isVisible = item.optBoolean("account_visible", false)
if (!isVisible) continue // debit cards and other hidden cards — skip
val isPrepaid = item.optBoolean("prepaid_card", false) val isPrepaid = item.optBoolean("prepaid_card", false)
if (isPrepaid) { val cardBalance = item.optJSONObject("cardBalance")
val cardBalance = item.optJSONObject("cardBalance") val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0 val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
prepaidCards.add(MibAccount( prepaidCards.add(MibAccount(
profileName = "Personal", profileName = "Personal",
profileType = "BML_PREPAID", profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
accountNumber = accountNumber, accountNumber = accountNumber,
accountBriefName = product, accountBriefName = item.optString("alias").ifBlank { product },
currencyName = currency, currencyName = currency,
accountTypeName = product, accountTypeName = product,
availableBalance = "%.2f".format(available), availableBalance = "%.2f".format(available),
currentBalance = "%.2f".format(cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0), currentBalance = "%.2f".format(current),
blockedAmount = "0.00", blockedAmount = "0.00",
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00", mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
statusDesc = status, statusDesc = status,
profileImageHash = null, profileImageHash = null,
loginTag = loginTag, loginTag = loginTag,
internalId = internalId internalId = internalId
)) ))
} else {
// Linked debit cards have no independent balance or account link — skip
}
} }
} }
@@ -694,7 +693,7 @@ class BmlLoginFlow {
} catch (_: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
} }
private fun parseContacts(json: String): List<MibBeneficiary> { private fun parseContacts(json: String, loginId: String = ""): List<MibBeneficiary> {
val root = JSONObject(json) val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList() if (!root.optBoolean("success")) return emptyList()
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList() val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList()
@@ -715,7 +714,8 @@ class BmlLoginFlow {
benefStatus = item.optString("status", "S"), benefStatus = item.optString("status", "S"),
transferCyDesc = item.optString("currency", "MVR"), transferCyDesc = item.optString("currency", "MVR"),
customerImgHash = null, customerImgHash = null,
benefCategoryId = "BML" benefCategoryId = "BML",
profileId = loginId
)) ))
} }
return result return result
@@ -34,6 +34,7 @@ class AccountHistoryAdapter(
private val iconUrlCache = mutableMapOf<String, Bitmap>() private val iconUrlCache = mutableMapOf<String, Bitmap>()
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
var onIconUrlNeeded: ((url: String) -> Unit)? = null var onIconUrlNeeded: ((url: String) -> Unit)? = null
var onTransferClick: ((MibAccount) -> Unit)? = null
fun updateImage(counterpartyName: String, bitmap: Bitmap) { fun updateImage(counterpartyName: String, bitmap: Bitmap) {
imageCache[counterpartyName] = bitmap imageCache[counterpartyName] = bitmap
@@ -164,6 +165,7 @@ class AccountHistoryAdapter(
} else { } else {
b.llHeaderBlocked.visibility = View.GONE b.llHeaderBlocked.visibility = View.GONE
} }
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(acc) }
} }
private fun friendlyType(raw: String): String { private fun friendlyType(raw: String): String {
@@ -20,6 +20,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibAccount
@@ -82,6 +83,9 @@ class AccountHistoryFragment : Fragment() {
adapter = AccountHistoryAdapter(account) adapter = AccountHistoryAdapter(account)
adapter.onImageNeeded = { name -> loadContactImage(name) } adapter.onImageNeeded = { name -> loadContactImage(name) }
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) } adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
adapter.onTransferClick = { acc ->
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(acc))
}
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
@@ -130,7 +134,7 @@ class AccountHistoryFragment : Fragment() {
} }
private fun isMib() = !account.profileType.startsWith("BML") && account.profileType != "FAHIPAY" private fun isMib() = !account.profileType.startsWith("BML") && account.profileType != "FAHIPAY"
private fun isBmlCard() = account.profileType == "BML_PREPAID" private fun isBmlCard() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
private fun isFahipay() = account.profileType == "FAHIPAY" private fun isFahipay() = account.profileType == "FAHIPAY"
private fun hasMore(): Boolean = when { private fun hasMore(): Boolean = when {
@@ -185,7 +189,7 @@ class AccountHistoryFragment : Fragment() {
} }
} }
isBmlCard() -> { isBmlCard() -> {
val session = app.bmlSession ?: return@withContext emptyList() val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
val cal = Calendar.getInstance() val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -cardMonthOffset) cal.add(Calendar.MONTH, -cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time) val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
@@ -199,7 +203,7 @@ class AccountHistoryFragment : Fragment() {
) )
} }
else -> { else -> {
val session = app.bmlSession ?: return@withContext emptyList() val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory( val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
session = session, session = session,
accountId = account.internalId, accountId = account.internalId,
@@ -3,17 +3,18 @@ package sh.sar.basedbank.ui.home
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.databinding.ItemAccountBinding import sh.sar.basedbank.databinding.ItemAccountBinding
import sh.sar.basedbank.databinding.ItemCardBinding import sh.sar.basedbank.databinding.ItemCardBinding
import sh.sar.basedbank.databinding.ItemProfileHeaderBinding import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.util.BmlDashboardParser
import sh.sar.basedbank.util.MibAccountParser
class AccountsAdapter( class AccountsAdapter(
accounts: List<MibAccount>, accounts: List<MibAccount>,
@@ -21,7 +22,7 @@ class AccountsAdapter(
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item { private sealed class Item {
data class SectionTitle(val label: String, val chip: String) : Item() data class SectionTitle(val label: String) : Item()
data class Account(val account: MibAccount) : Item() data class Account(val account: MibAccount) : Item()
data class Card(val account: MibAccount) : Item() data class Card(val account: MibAccount) : Item()
} }
@@ -35,19 +36,40 @@ class AccountsAdapter(
} }
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList { private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
val regular = accounts.filter { it.profileType != "BML_PREPAID" } val nonPrepaid = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
val prepaid = accounts.filter { it.profileType == "BML_PREPAID" } val prepaid = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
if (regular.isNotEmpty()) { // Group non-prepaid accounts by their derived section title, preserving order
add(Item.SectionTitle("Accounts", "")) val groups = LinkedHashMap<String, MutableList<MibAccount>>()
regular.forEach { add(Item.Account(it)) } for (acc in nonPrepaid) {
val title = sectionTitle(acc)
groups.getOrPut(title) { mutableListOf() }.add(acc)
} }
for ((title, group) in groups) {
add(Item.SectionTitle(title))
group.forEach { add(Item.Account(it)) }
}
if (prepaid.isNotEmpty()) { if (prepaid.isNotEmpty()) {
add(Item.SectionTitle("Cards", "BML")) add(Item.SectionTitle("Cards · Bank of Maldives"))
prepaid.forEach { add(Item.Card(it)) } prepaid.forEach { add(Item.Card(it)) }
} }
} }
private fun sectionTitle(account: MibAccount): String {
val profileLabel = when (account.profileType) {
"0" -> "Personal"
"1" -> "Business"
else -> account.profileName
}
val bank = when {
account.profileType.startsWith("BML") -> "Bank of Maldives"
account.profileType == "FAHIPAY" -> "Fahipay"
else -> "Maldives Islamic Bank"
}
return if (profileLabel.isNotBlank()) "$profileLabel · $bank" else bank
}
override fun getItemViewType(position: Int) = when (items[position]) { override fun getItemViewType(position: Int) = when (items[position]) {
is Item.SectionTitle -> TYPE_HEADER is Item.SectionTitle -> TYPE_HEADER
is Item.Account -> TYPE_ACCOUNT is Item.Account -> TYPE_ACCOUNT
@@ -57,7 +79,7 @@ class AccountsAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context) val inflater = LayoutInflater.from(parent.context)
return when (viewType) { return when (viewType) {
TYPE_HEADER -> SectionViewHolder(ItemProfileHeaderBinding.inflate(inflater, parent, false)) TYPE_HEADER -> SectionViewHolder(ItemDateHeaderBinding.inflate(inflater, parent, false))
TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false)) TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false))
else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false)) else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
} }
@@ -73,16 +95,10 @@ class AccountsAdapter(
override fun getItemCount() = items.size override fun getItemCount() = items.size
private inner class SectionViewHolder(private val binding: ItemProfileHeaderBinding) : private inner class SectionViewHolder(private val binding: ItemDateHeaderBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(item: Item.SectionTitle) { fun bind(item: Item.SectionTitle) {
binding.tvProfileName.text = item.label binding.tvDateHeader.text = item.label
if (item.chip.isNotEmpty()) {
binding.tvProfileType.text = item.chip
binding.tvProfileType.visibility = View.VISIBLE
} else {
binding.tvProfileType.visibility = View.GONE
}
} }
} }
@@ -91,18 +107,12 @@ class AccountsAdapter(
fun bind(account: MibAccount) { fun bind(account: MibAccount) {
binding.tvAccountName.text = account.accountBriefName binding.tvAccountName.text = account.accountBriefName
binding.tvAccountNumber.text = account.accountNumber binding.tvAccountNumber.text = account.accountNumber
binding.tvPillBank.text = when { val label = if (account.profileType.startsWith("BML"))
account.profileType.startsWith("BML") -> "BML" BmlDashboardParser.productLabel(account.accountTypeName)
account.profileType == "FAHIPAY" -> "FP" else
else -> null MibAccountParser.productLabel(account.accountTypeName)
} binding.tvPillType.text = label
binding.tvPillType.text = friendlyAccountType(account.accountTypeName) binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
binding.tvPillProfile.text = when (account.profileType) {
"0" -> "Personal"
"1" -> "Business"
else -> account.profileName
}
binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
binding.root.setOnClickListener { onAccountClick(account) } binding.root.setOnClickListener { onAccountClick(account) }
binding.root.setOnLongClickListener { binding.root.setOnLongClickListener {
copyToClipboard(it.context, account.accountNumber) copyToClipboard(it.context, account.accountNumber)
@@ -114,15 +124,10 @@ class AccountsAdapter(
private inner class CardViewHolder(private val binding: ItemCardBinding) : private inner class CardViewHolder(private val binding: ItemCardBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(account: MibAccount) { fun bind(account: MibAccount) {
val brand = cardBrand(account.accountTypeName) binding.ivCardBrand.setImageResource(cardBrandIcon(account.accountTypeName))
binding.tvCardBrand.text = brand.label binding.tvCardName.text = account.accountBriefName
binding.tvCardBrand.background = GradientDrawable().apply { binding.tvCardNumber.text = account.accountNumber
shape = GradientDrawable.RECTANGLE binding.tvCardProduct.text = BmlDashboardParser.productLabel(account.accountTypeName)
cornerRadius = 100f
setColor(Color.parseColor(brand.color))
}
binding.tvCardName.text = account.accountBriefName
binding.tvCardNumber.text = account.accountNumber
binding.layoutCardBalance.visibility = View.VISIBLE binding.layoutCardBalance.visibility = View.VISIBLE
binding.tvCardBalance.text = "${account.currencyName} ${account.availableBalance}" binding.tvCardBalance.text = "${account.currencyName} ${account.availableBalance}"
@@ -150,29 +155,12 @@ class AccountsAdapter(
Toast.makeText(context, "Account number copied", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Account number copied", Toast.LENGTH_SHORT).show()
} }
private fun friendlyAccountType(raw: String): String { private fun cardBrandIcon(productName: String): Int = when {
val u = raw.trim().uppercase()
return when {
u == "SAVINGS ACCOUNT" ||
u == "SAVING ACCOUNT" -> "Savings"
u == "CURRENT ACCOUNT" ||
u == "CURRENT ACCOUNT(PERSONAL)" ||
u == "CURRENT ACCOUNT(BUSINESS)" -> "Current"
u == "WADIAH RETAIL CURRENT ACCOUNT" ||
u == "WADIAH BUSINESS CURRENT ACCOUNT" -> "Islamic Current"
u == "BML ISLAMIC SAVINGS ACCOUNT" -> "Islamic Savings"
else -> raw.trim()
}
}
private data class Brand(val label: String, val color: String)
private fun cardBrand(productName: String): Brand = when {
productName.contains("AMEX", ignoreCase = true) || productName.contains("AMEX", ignoreCase = true) ||
productName.contains("AMERICAN EXPRESS", ignoreCase = true) -> Brand("AMEX", "#016FD0") productName.contains("AMERICAN EXPRESS", ignoreCase = true) -> R.drawable.americanexpress
productName.contains("VISA", ignoreCase = true) -> Brand("VISA", "#1A1F71") productName.contains("VISA", ignoreCase = true) -> R.drawable.visa
productName.contains("MASTERCARD", ignoreCase = true) -> Brand("MC", "#FF5F00") productName.contains("MASTERCARD", ignoreCase = true) -> R.drawable.mastercard
else -> Brand("CARD", "#555555") else -> R.drawable.ic_nav_card
} }
} }
} }
@@ -12,12 +12,18 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.Filter
import android.widget.Filterable
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import sh.sar.basedbank.util.ContactsCache import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.CredentialStore
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -43,7 +49,9 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private data class DestinationOption( private data class DestinationOption(
val label: String, val label: String,
val isBml: Boolean, val isBml: Boolean,
val mibProfile: MibProfile? = null val mibProfile: MibProfile? = null,
val bmlLoginId: String? = null,
val subtitle: String = ""
) )
private var destinations: List<DestinationOption> = emptyList() private var destinations: List<DestinationOption> = emptyList()
@@ -86,18 +94,44 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
for (profile in app.mibProfiles) { for (profile in app.mibProfiles) {
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile)) list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile))
} }
if (app.bmlSession != null) { val store = CredentialStore(requireContext())
list.add(DestinationOption("BML · Personal", isBml = true)) for ((loginId, _) in app.bmlSessions) {
val ownerName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() } ?: loginId
val profileName = app.bmlAccounts.firstOrNull { it.loginTag == "bml_$loginId" }?.profileName ?: ""
list.add(DestinationOption("BML · $ownerName", isBml = true, bmlLoginId = loginId, subtitle = profileName))
} }
return list return list
} }
private fun setupDestinationDropdown() { private fun setupDestinationDropdown() {
val labels = destinations.map { it.label } val adapter = object : ArrayAdapter<DestinationOption>(requireContext(), android.R.layout.simple_list_item_2, destinations) {
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, labels) override fun getView(position: Int, convertView: View?, parent: ViewGroup) =
getDropDownView(position, convertView, parent)
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val view = convertView ?: LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_2, parent, false)
val opt = destinations[position]
view.findViewById<TextView>(android.R.id.text1).text = opt.label
val text2 = view.findViewById<TextView>(android.R.id.text2)
if (opt.subtitle.isNotBlank()) {
text2.text = opt.subtitle
text2.visibility = View.VISIBLE
} else {
text2.visibility = View.GONE
}
return view
}
override fun getFilter() = object : Filter() {
override fun performFiltering(c: CharSequence?) = FilterResults().apply { values = destinations; count = destinations.size }
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
override fun convertResultToString(r: Any?) = ""
}
}
binding.actvDestination.setAdapter(adapter) binding.actvDestination.setAdapter(adapter)
binding.actvDestination.setOnItemClickListener { _, _, position, _ -> binding.actvDestination.setOnItemClickListener { _, _, position, _ ->
selectedDest = destinations[position] selectedDest = destinations[position]
binding.actvDestination.setText(destinations[position].label, false)
clearLookupResult() clearLookupResult()
updateMibOnlyVisibility() updateMibOnlyVisibility()
binding.btnSave.isEnabled = false binding.btnSave.isEnabled = false
@@ -129,6 +163,22 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
} }
} }
private fun startLookupLoading() {
val spinner = CircularProgressDrawable(requireContext()).apply {
setStyle(CircularProgressDrawable.DEFAULT)
setColorSchemeColors(com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorPrimary, Color.GRAY))
start()
}
binding.tilAccount.endIconDrawable = spinner
binding.tilAccount.isEnabled = false
}
private fun stopLookupLoading() {
binding.tilAccount.isEnabled = true
binding.tilAccount.endIconDrawable = ContextCompat.getDrawable(requireContext(), android.R.drawable.ic_menu_search)
}
private fun setupAccountSearch() { private fun setupAccountSearch() {
binding.tilAccount.setEndIconOnClickListener { performLookup() } binding.tilAccount.setEndIconOnClickListener { performLookup() }
} }
@@ -167,7 +217,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
return return
} }
binding.tilAccount.isEnabled = false startLookupLoading()
binding.tilDestination.isEnabled = false binding.tilDestination.isEnabled = false
binding.btnSave.isEnabled = false binding.btnSave.isEnabled = false
@@ -175,7 +225,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
if (dest.isBml) lookupForBml(input) else lookupForMib(dest, input) if (dest.isBml) lookupForBml(input) else lookupForMib(dest, input)
} }
binding.tilAccount.isEnabled = true stopLookupLoading()
binding.tilDestination.isEnabled = true binding.tilDestination.isEnabled = true
if (result != null) { if (result != null) {
showLookupResult(result, input) showLookupResult(result, input)
@@ -186,7 +236,8 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
} }
private fun lookupForBml(input: String): BmlAccountValidation? { private fun lookupForBml(input: String): BmlAccountValidation? {
val bmlSess = app.bmlSession ?: return null val loginId = selectedDest?.bmlLoginId ?: return null
val bmlSess = app.bmlSessions[loginId] ?: return null
val bmlFlow = BmlLoginFlow() val bmlFlow = BmlLoginFlow()
// 1) Try BML validate // 1) Try BML validate
@@ -236,7 +287,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
if (mibResult != null) return mibResult if (mibResult != null) return mibResult
// MIB lookup failed (e.g. BML USD account) — fall back to BML validate // MIB lookup failed (e.g. BML USD account) — fall back to BML validate
val bmlSess = app.bmlSession ?: return null val bmlSess = app.anyBmlSession() ?: return null
return try { BmlLoginFlow().validateAccount(bmlSess, input) } catch (_: Exception) { null } return try { BmlLoginFlow().validateAccount(bmlSess, input) } catch (_: Exception) { null }
} }
@@ -341,6 +392,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
binding.tilAlias.error = null binding.tilAlias.error = null
binding.btnSave.isEnabled = false binding.btnSave.isEnabled = false
binding.btnSave.text = "Saving..."
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val success = withContext(Dispatchers.IO) { val success = withContext(Dispatchers.IO) {
@@ -352,13 +404,15 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
dismiss() dismiss()
} else { } else {
binding.btnSave.isEnabled = true binding.btnSave.isEnabled = true
binding.btnSave.text = "Save"
Toast.makeText(requireContext(), R.string.contact_save_failed, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.contact_save_failed, Toast.LENGTH_SHORT).show()
} }
} }
} }
private fun saveToBml(alias: String): Boolean { private fun saveToBml(alias: String): Boolean {
val bmlSess = app.bmlSession ?: return false val loginId = selectedDest?.bmlLoginId ?: return false
val bmlSess = app.bmlSessions[loginId] ?: return false
val lookup = bmlLookup ?: return false val lookup = bmlLookup ?: return false
val bmlFlow = BmlLoginFlow() val bmlFlow = BmlLoginFlow()
val account = lookup.account val account = lookup.account
@@ -425,12 +479,13 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
requireActivity().lifecycleScope.launch(Dispatchers.IO) { requireActivity().lifecycleScope.launch(Dispatchers.IO) {
try { try {
if (dest.isBml) { if (dest.isBml) {
val bmlSess = app.bmlSession ?: return@launch val loginId = dest.bmlLoginId ?: return@launch
val fresh = BmlLoginFlow().fetchContacts(bmlSess) val bmlSess = app.bmlSessions[loginId] ?: return@launch
val fresh = BmlLoginFlow().fetchContacts(bmlSess, loginId)
val existing = viewModel.contacts.value ?: emptyList() val existing = viewModel.contacts.value ?: emptyList()
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
viewModel.contacts.postValue(merged) viewModel.contacts.postValue(merged)
ContactsCache.saveBml(requireContext(), fresh) if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, fresh)
} else { } else {
val profile = dest.mibProfile ?: return@launch val profile = dest.mibProfile ?: return@launch
val mibSess = app.mibSession ?: return@launch val mibSess = app.mibSession ?: return@launch
@@ -122,6 +122,12 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
attachMediator(initialPages) attachMediator(initialPages)
binding.etSheetSearch.addTextChangedListener { pagerAdapter.rebuildAll() } binding.etSheetSearch.addTextChangedListener { pagerAdapter.rebuildAll() }
binding.etSheetSearch.setOnFocusChangeListener { _, hasFocus ->
if (hasFocus) {
val allIndex = pagerAdapter.pages.indexOfFirst { it.tag == null }
if (allIndex >= 0) binding.viewPager.setCurrentItem(allIndex, true)
}
}
viewModel.contactCategories.observe(viewLifecycleOwner) { cats -> viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
val pages = buildList { val pages = buildList {
@@ -203,11 +209,11 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber } val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
val fromCurrency = fromAccount?.currencyName ?: "" val fromCurrency = fromAccount?.currencyName ?: ""
val fromLoginTag = fromAccount?.loginTag ?: "" val fromLoginTag = fromAccount?.loginTag ?: ""
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT"
if (tabTag == MY_ACCOUNTS_TAG) { if (tabTag == MY_ACCOUNTS_TAG) {
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" } val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
val cards = accounts.filter { it.profileType == "BML_PREPAID" } val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
val filteredRegular = if (search.isBlank()) regularAccounts else regularAccounts.filter { val filteredRegular = if (search.isBlank()) regularAccounts else regularAccounts.filter {
it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search) it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
@@ -158,7 +158,7 @@ class ContactsFragment : Fragment() {
colorHex = contact.bankColor, colorHex = contact.bankColor,
imageHash = contact.customerImgHash imageHash = contact.customerImgHash
) )
(requireActivity() as HomeActivity).showWithBackStack(fragment) (requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
} }
private fun confirmDelete(contact: MibBeneficiary) { private fun confirmDelete(contact: MibBeneficiary) {
@@ -185,7 +185,7 @@ class ContactsFragment : Fragment() {
} }
private fun deleteBml(contact: MibBeneficiary): Boolean { private fun deleteBml(contact: MibBeneficiary): Boolean {
val sess = app.bmlSession ?: return false val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false
val contactId = contact.benefNo.removePrefix("bml_") val contactId = contact.benefNo.removePrefix("bml_")
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false } return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
} }
@@ -205,7 +205,11 @@ class ContactsFragment : Fragment() {
val updated = viewModel.contacts.value?.filter { it.benefNo != contact.benefNo } ?: return val updated = viewModel.contacts.value?.filter { it.benefNo != contact.benefNo } ?: return
viewModel.contacts.value = updated viewModel.contacts.value = updated
if (contact.benefCategoryId == "BML") { if (contact.benefCategoryId == "BML") {
ContactsCache.saveBml(requireContext(), updated.filter { it.benefCategoryId == "BML" }) updated.filter { it.benefCategoryId == "BML" }
.groupBy { it.profileId }
.forEach { (loginId, contacts) ->
if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, contacts)
}
} else { } else {
ContactsCache.save( ContactsCache.save(
requireContext(), requireContext(),
@@ -1,6 +1,7 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.content.Intent import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import android.os.CountDownTimer import android.os.CountDownTimer
import android.os.Handler import android.os.Handler
@@ -14,6 +15,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.activity.viewModels import androidx.activity.viewModels
import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
@@ -64,24 +66,37 @@ class HomeActivity : AppCompatActivity() {
private val warningRunnable = Runnable { showAutolockWarning() } private val warningRunnable = Runnable { showAutolockWarning() }
private var isLocked = false
private val autolockRunnable = Runnable { private val autolockRunnable = Runnable {
countdownTimer?.cancel(); countdownTimer = null countdownTimer?.cancel(); countdownTimer = null
warningDialog?.dismiss(); warningDialog = null warningDialog?.dismiss(); warningDialog = null
val securitySet = getSharedPreferences("prefs", MODE_PRIVATE) val securitySet = getSharedPreferences("prefs", MODE_PRIVATE)
.getString("security_method", null) != null .getString("security_method", null) != null
if (securitySet) { if (securitySet) lock()
startActivity(Intent(this, sh.sar.basedbank.LockActivity::class.java)) }
finish()
} private fun lock() {
isLocked = true
startActivity(
Intent(this, sh.sar.basedbank.LockActivity::class.java)
.putExtra(sh.sar.basedbank.LockActivity.EXTRA_RESUME, true)
)
} }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityHomeBinding.inflate(layoutInflater) binding = ActivityHomeBinding.inflate(layoutInflater)
if (getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("block_screenshots", true)) { if (getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("block_screenshots", true)) {
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE) window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
} }
setContentView(binding.root) setContentView(binding.root)
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
setSupportActionBar(binding.toolbar) setSupportActionBar(binding.toolbar)
toggle = ActionBarDrawerToggle( toggle = ActionBarDrawerToggle(
@@ -119,7 +134,10 @@ class HomeActivity : AppCompatActivity() {
val merged = mibAccounts + app.bmlAccounts + app.fahipayAccounts val merged = mibAccounts + app.bmlAccounts + app.fahipayAccounts
viewModel.accounts.value = merged viewModel.accounts.value = merged
if (mibAccounts.isNotEmpty()) AccountCache.save(this, mibAccounts) if (mibAccounts.isNotEmpty()) AccountCache.save(this, mibAccounts)
if (app.bmlAccounts.isNotEmpty()) AccountCache.saveBml(this, app.bmlAccounts) if (app.bmlAccounts.isNotEmpty()) {
val byLoginId = app.bmlAccounts.groupBy { it.loginTag.removePrefix("bml_") }
byLoginId.forEach { (loginId, accounts) -> AccountCache.saveBml(this, loginId, accounts) }
}
if (app.fahipayAccounts.isNotEmpty()) AccountCache.saveFahipay(this, app.fahipayAccounts) if (app.fahipayAccounts.isNotEmpty()) AccountCache.saveFahipay(this, app.fahipayAccounts)
val cachedFinancing = FinancingCache.load(this) val cachedFinancing = FinancingCache.load(this)
@@ -128,11 +146,12 @@ class HomeActivity : AppCompatActivity() {
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
refreshFinancing(app.mibSession, app.mibProfiles) refreshFinancing(app.mibSession, app.mibProfiles)
if (app.bmlSession != null) refreshBmlLimits(app.bmlSession!!) for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
} else { } else {
// Came from lock screen — show caches immediately, refresh everything in background // Came from lock screen — show caches immediately, refresh everything in background
val store = CredentialStore(this)
val cachedMib = AccountCache.load(this) val cachedMib = AccountCache.load(this)
val cachedBml = AccountCache.loadBml(this) val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds())
val cachedFahipay = AccountCache.loadFahipay(this) val cachedFahipay = AccountCache.loadFahipay(this)
val merged = cachedMib + cachedBml + cachedFahipay val merged = cachedMib + cachedBml + cachedFahipay
if (merged.isNotEmpty()) viewModel.accounts.value = merged if (merged.isNotEmpty()) viewModel.accounts.value = merged
@@ -141,8 +160,7 @@ class HomeActivity : AppCompatActivity() {
val cachedLimits = ForeignLimitsCache.load(this) val cachedLimits = ForeignLimitsCache.load(this)
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
val store = CredentialStore(this) autoRefresh(store.loadMibCredentials(), store.loadFahipayCredentials(), store)
autoRefresh(store.loadMibCredentials(), store.loadBmlCredentials(), store.loadFahipayCredentials(), store)
} }
// Show dashboard on first create // Show dashboard on first create
@@ -199,18 +217,19 @@ class HomeActivity : AppCompatActivity() {
} }
} }
fun navigateTo(itemId: Int) { fun navigateTo(itemId: Int, fragment: Fragment? = null) {
when (itemId) { val dest = fragment ?: when (itemId) {
R.id.nav_dashboard -> show(DashboardFragment()) R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> show(AccountsFragment()) R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> show(ContactsFragment()) R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> show(TransferFragment()) R.id.nav_transfer -> TransferFragment()
R.id.nav_transfer_history -> show(TransferHistoryFragment()) R.id.nav_transfer_history -> TransferHistoryFragment()
R.id.nav_finances -> show(FinancingFragment()) R.id.nav_finances -> FinancingFragment()
R.id.nav_otp -> show(OtpFragment()) R.id.nav_otp -> OtpFragment()
R.id.nav_settings -> show(SettingsFragment()) R.id.nav_settings -> SettingsFragment()
else -> Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show() else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
} }
show(dest)
binding.navigationView.setCheckedItem(itemId) binding.navigationView.setCheckedItem(itemId)
val bottomNavIds = setOf(R.id.nav_dashboard, R.id.nav_accounts, R.id.nav_contacts, R.id.nav_transfer, R.id.nav_more) val bottomNavIds = setOf(R.id.nav_dashboard, R.id.nav_accounts, R.id.nav_contacts, R.id.nav_transfer, R.id.nav_more)
if (binding.bottomNavigation.visibility == View.VISIBLE && itemId in bottomNavIds) { if (binding.bottomNavigation.visibility == View.VISIBLE && itemId in bottomNavIds) {
@@ -220,6 +239,13 @@ class HomeActivity : AppCompatActivity() {
} }
} }
fun setBottomNavVisible(visible: Boolean) {
val isBottom = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
if (isBottom) {
binding.bottomNavigation.visibility = if (visible) View.VISIBLE else View.GONE
}
}
fun setRefreshing(visible: Boolean) { fun setRefreshing(visible: Boolean) {
binding.refreshIndicator.visibility = if (visible) View.VISIBLE else View.GONE binding.refreshIndicator.visibility = if (visible) View.VISIBLE else View.GONE
} }
@@ -233,6 +259,13 @@ class HomeActivity : AppCompatActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
// Returning from LockActivity — skip the elapsed check and reset state.
if (isLocked) {
isLocked = false
pauseTime = 0L
resetAutolockTimer()
return
}
// If we were away long enough to have hit the autolock timeout (e.g. while // If we were away long enough to have hit the autolock timeout (e.g. while
// QrScannerActivity was in the foreground), lock immediately. // QrScannerActivity was in the foreground), lock immediately.
if (pauseTime > 0L) { if (pauseTime > 0L) {
@@ -240,8 +273,7 @@ class HomeActivity : AppCompatActivity() {
val timeout = getSharedPreferences("prefs", MODE_PRIVATE).getLong("autolock_timeout", 60_000L) val timeout = getSharedPreferences("prefs", MODE_PRIVATE).getLong("autolock_timeout", 60_000L)
val securitySet = getSharedPreferences("prefs", MODE_PRIVATE).getString("security_method", null) != null val securitySet = getSharedPreferences("prefs", MODE_PRIVATE).getString("security_method", null) != null
if (timeout > 0L && elapsed >= timeout && securitySet) { if (timeout > 0L && elapsed >= timeout && securitySet) {
startActivity(Intent(this, sh.sar.basedbank.LockActivity::class.java)) lock()
finish()
return return
} }
} }
@@ -312,8 +344,7 @@ class HomeActivity : AppCompatActivity() {
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.action_lock) { if (item.itemId == R.id.action_lock) {
startActivity(Intent(this, sh.sar.basedbank.LockActivity::class.java)) lock()
finish()
return true return true
} }
return super.onOptionsItemSelected(item) return super.onOptionsItemSelected(item)
@@ -323,9 +354,9 @@ class HomeActivity : AppCompatActivity() {
fun relogin() { fun relogin() {
val store = CredentialStore(this) val store = CredentialStore(this)
val hasMib = store.hasMibCredentials() val hasMib = store.hasMibCredentials()
val hasBml = store.hasBmlCredentials() val bmlLoginIds = store.getBmlLoginIds()
val hasFahipay = store.hasFahipayCredentials() val hasFahipay = store.hasFahipayCredentials()
if (!hasMib && !hasBml && !hasFahipay) { if (!hasMib && bmlLoginIds.isEmpty() && !hasFahipay) {
startActivity(Intent(this, LoginActivity::class.java)) startActivity(Intent(this, LoginActivity::class.java))
finish() finish()
return return
@@ -334,24 +365,26 @@ class HomeActivity : AppCompatActivity() {
val current = viewModel.accounts.value ?: emptyList() val current = viewModel.accounts.value ?: emptyList()
viewModel.accounts.value = current.filter { acc -> viewModel.accounts.value = current.filter { acc ->
if (!hasMib && !acc.profileType.startsWith("BML") && acc.profileType != "FAHIPAY") 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 (acc.profileType.startsWith("BML")) {
val loginId = acc.loginTag.removePrefix("bml_")
return@filter loginId in bmlLoginIds
}
if (!hasFahipay && acc.profileType == "FAHIPAY") return@filter false if (!hasFahipay && acc.profileType == "FAHIPAY") return@filter false
true true
} }
autoRefresh(store.loadMibCredentials(), store.loadBmlCredentials(), store.loadFahipayCredentials(), store) autoRefresh(store.loadMibCredentials(), store.loadFahipayCredentials(), store)
} }
private fun autoRefresh( private fun autoRefresh(
mibCreds: CredentialStore.MibCredentials?, mibCreds: CredentialStore.MibCredentials?,
bmlCreds: CredentialStore.BmlCredentials?,
fahipayCreds: CredentialStore.FahipayCredentials?, fahipayCreds: CredentialStore.FahipayCredentials?,
store: CredentialStore store: CredentialStore
) { ) {
if (mibCreds == null && bmlCreds == null && fahipayCreds == null) return val bmlLoginIds = store.getBmlLoginIds()
if (mibCreds == null && bmlLoginIds.isEmpty() && fahipayCreds == null) return
binding.refreshIndicator.visibility = View.VISIBLE binding.refreshIndicator.visibility = View.VISIBLE
lifecycleScope.launch { lifecycleScope.launch {
// MIB and BML login run in parallel
val mibJob = mibCreds?.let { val mibJob = mibCreds?.let {
async(Dispatchers.IO) { async(Dispatchers.IO) {
try { try {
@@ -367,39 +400,36 @@ class HomeActivity : AppCompatActivity() {
} }
} }
val bmlJob = bmlCreds?.let { // One async job per BML login, all run in parallel
async(Dispatchers.IO) { val bmlJobs = bmlLoginIds.mapNotNull { loginId ->
val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null
loginId to async(Dispatchers.IO) {
val bmlFlow = BmlLoginFlow() val bmlFlow = BmlLoginFlow()
val savedToken = store.loadBmlSession() val loginTag = "bml_$loginId"
val savedToken = store.loadBmlSession(loginId)
// Try cached token first
if (savedToken != null) { if (savedToken != null) {
try { try {
val session = BmlSession(savedToken.first, savedToken.second) val session = BmlSession(savedToken.first, savedToken.second)
val accounts = bmlFlow.fetchAccounts(session) val accounts = bmlFlow.fetchAccounts(session, loginTag)
val app = application as BasedBankApp val app = application as BasedBankApp
app.bmlSession = session app.bmlSessions[loginId] = session
app.bmlAccounts = accounts AccountCache.saveBml(this@HomeActivity, loginId, accounts)
AccountCache.saveBml(this@HomeActivity, accounts)
return@async Pair(session, accounts) return@async Pair(session, accounts)
} catch (_: AuthExpiredException) { } catch (_: AuthExpiredException) {
// Token expired — fall through to full login
} catch (_: Exception) { } catch (_: Exception) {
// Network or other error — fall through to full login
} }
} }
// Full login (token missing or expired)
try { try {
val (session, accounts) = bmlFlow.login(it.username, it.password, it.otpSeed) val (session, accounts) = bmlFlow.login(creds.username, creds.password, creds.otpSeed)
store.saveBmlSession(session.accessToken, session.deviceId) store.saveBmlSession(loginId, session.accessToken, session.deviceId)
val app = application as BasedBankApp val app = application as BasedBankApp
app.bmlSession = session app.bmlSessions[loginId] = session
app.bmlAccounts = accounts AccountCache.saveBml(this@HomeActivity, loginId, accounts)
AccountCache.saveBml(this@HomeActivity, accounts)
Pair(session, accounts) Pair(session, accounts)
} catch (_: Exception) { } catch (_: Exception) {
Pair(null, AccountCache.loadBml(this@HomeActivity)) Pair(null, AccountCache.loadBml(this@HomeActivity, loginId))
} }
} }
} }
@@ -409,7 +439,6 @@ class HomeActivity : AppCompatActivity() {
val fahipayFlow = FahipayLoginFlow() val fahipayFlow = FahipayLoginFlow()
val deviceUuid = store.getOrCreateFahipayDeviceUuid() val deviceUuid = store.getOrCreateFahipayDeviceUuid()
// Try cached session first
val savedSession = store.loadFahipaySession() val savedSession = store.loadFahipaySession()
if (savedSession != null) { if (savedSession != null) {
try { try {
@@ -425,15 +454,12 @@ class HomeActivity : AppCompatActivity() {
AccountCache.saveFahipay(this@HomeActivity, accounts) AccountCache.saveFahipay(this@HomeActivity, accounts)
return@async Pair(session, accounts) return@async Pair(session, accounts)
} catch (_: Exception) { } catch (_: Exception) {
// Session expired — fall through to full login
} }
} }
// Full re-login (only works if user has no 2FA, or 2FA was skipped)
try { try {
val step = fahipayFlow.login(creds.idCard, creds.password, deviceUuid) val step = fahipayFlow.login(creds.idCard, creds.password, deviceUuid)
if (step.twoFactorRequired) { if (step.twoFactorRequired) {
// Can't auto-complete 2FA — use cached data
return@async Pair(null, AccountCache.loadFahipay(this@HomeActivity)) return@async Pair(null, AccountCache.loadFahipay(this@HomeActivity))
} }
val authId = step.authId ?: return@async Pair(null, AccountCache.loadFahipay(this@HomeActivity)) val authId = step.authId ?: return@async Pair(null, AccountCache.loadFahipay(this@HomeActivity))
@@ -456,14 +482,16 @@ class HomeActivity : AppCompatActivity() {
} }
val mibAccounts = mibJob?.await() ?: AccountCache.load(this@HomeActivity) val mibAccounts = mibJob?.await() ?: AccountCache.load(this@HomeActivity)
val (bmlSession, bmlAccounts) = bmlJob?.await() ?: Pair(null, AccountCache.loadBml(this@HomeActivity)) val bmlResults = bmlJobs.map { (_, job) -> job.await() }
val bmlAccounts = bmlResults.flatMap { it.second }
val (_, fahipayAccounts) = fahipayJob?.await() ?: Pair(null, AccountCache.loadFahipay(this@HomeActivity)) val (_, fahipayAccounts) = fahipayJob?.await() ?: Pair(null, AccountCache.loadFahipay(this@HomeActivity))
val app = application as BasedBankApp
app.bmlAccounts = bmlAccounts
viewModel.accounts.postValue(mibAccounts + bmlAccounts + fahipayAccounts) viewModel.accounts.postValue(mibAccounts + bmlAccounts + fahipayAccounts)
binding.refreshIndicator.visibility = View.GONE binding.refreshIndicator.visibility = View.GONE
val app = application as BasedBankApp for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
if (bmlSession != null) refreshBmlLimits(bmlSession)
refreshFinancing(app.mibSession, app.mibProfiles) refreshFinancing(app.mibSession, app.mibProfiles)
} }
} }
@@ -486,16 +514,21 @@ class HomeActivity : AppCompatActivity() {
} }
private fun refreshBmlContacts(app: BasedBankApp) { private fun refreshBmlContacts(app: BasedBankApp) {
val session = app.bmlSession ?: return if (app.bmlSessions.isEmpty()) return
val bmlFlow = BmlLoginFlow()
lifecycleScope.launch { lifecycleScope.launch {
try { try {
val bmlContacts = withContext(Dispatchers.IO) { bmlFlow.fetchContacts(session) } val allBmlContacts = withContext(Dispatchers.IO) {
if (bmlContacts.isNotEmpty()) { app.bmlSessions.flatMap { (loginId, session) ->
ContactsCache.saveBml(this@HomeActivity, bmlContacts) val contacts = BmlLoginFlow().fetchContacts(session, loginId)
if (contacts.isNotEmpty()) ContactsCache.saveBml(this@HomeActivity, loginId, contacts)
contacts
}
}
if (allBmlContacts.isNotEmpty()) {
val store = sh.sar.basedbank.util.CredentialStore(this@HomeActivity)
val mibContacts = ContactsCache.loadContacts(this@HomeActivity) val mibContacts = ContactsCache.loadContacts(this@HomeActivity)
val fahipayContacts = ContactsCache.loadFahipay(this@HomeActivity) val fahipayContacts = ContactsCache.loadFahipay(this@HomeActivity)
viewModel.contacts.postValue(mergeContacts(mergeContacts(mibContacts, bmlContacts), fahipayContacts)) viewModel.contacts.postValue(mergeContacts(mergeContacts(mibContacts, allBmlContacts), fahipayContacts))
} }
} catch (_: Exception) { /* keep cached */ } } catch (_: Exception) { /* keep cached */ }
} }
@@ -503,10 +536,11 @@ class HomeActivity : AppCompatActivity() {
fun loadAllContacts() { fun loadAllContacts() {
val app = application as BasedBankApp val app = application as BasedBankApp
val store = sh.sar.basedbank.util.CredentialStore(this)
// Populate ViewModel from cache immediately if empty // Populate ViewModel from cache immediately if empty
if (viewModel.contacts.value.isNullOrEmpty()) { if (viewModel.contacts.value.isNullOrEmpty()) {
val cached = mergeContacts( val cached = mergeContacts(
mergeContacts(ContactsCache.loadContacts(this), ContactsCache.loadBml(this)), mergeContacts(ContactsCache.loadContacts(this), ContactsCache.loadBml(this, store.getBmlLoginIds())),
ContactsCache.loadFahipay(this) ContactsCache.loadFahipay(this)
) )
if (cached.isNotEmpty()) viewModel.contacts.value = cached if (cached.isNotEmpty()) viewModel.contacts.value = cached
@@ -534,7 +568,8 @@ class HomeActivity : AppCompatActivity() {
val categories = groups.map { MibBeneficiaryCategory(it.categoryId, it.label, it.contacts.size) } val categories = groups.map { MibBeneficiaryCategory(it.categoryId, it.label, it.contacts.size) }
ContactsCache.saveFahipay(this@HomeActivity, contacts, categories) ContactsCache.saveFahipay(this@HomeActivity, contacts, categories)
val mibContacts = ContactsCache.loadContacts(this@HomeActivity) val mibContacts = ContactsCache.loadContacts(this@HomeActivity)
val bmlContacts = ContactsCache.loadBml(this@HomeActivity) val bmlLoginIds = sh.sar.basedbank.util.CredentialStore(this@HomeActivity).getBmlLoginIds()
val bmlContacts = ContactsCache.loadBml(this@HomeActivity, bmlLoginIds)
viewModel.contacts.postValue(mergeContacts(mergeContacts(mibContacts, bmlContacts), contacts)) viewModel.contacts.postValue(mergeContacts(mergeContacts(mibContacts, bmlContacts), contacts))
val mibCategories = ContactsCache.loadCategories(this@HomeActivity) val mibCategories = ContactsCache.loadCategories(this@HomeActivity)
viewModel.contactCategories.postValue(mibCategories + categories) viewModel.contactCategories.postValue(mibCategories + categories)
@@ -580,7 +615,8 @@ class HomeActivity : AppCompatActivity() {
} }
if (allContacts.isNotEmpty()) { if (allContacts.isNotEmpty()) {
ContactsCache.save(this@HomeActivity, allContacts, allCategories) ContactsCache.save(this@HomeActivity, allContacts, allCategories)
val bmlContacts = ContactsCache.loadBml(this@HomeActivity) val bmlLoginIds = sh.sar.basedbank.util.CredentialStore(this@HomeActivity).getBmlLoginIds()
val bmlContacts = ContactsCache.loadBml(this@HomeActivity, bmlLoginIds)
val fahipayContacts = ContactsCache.loadFahipay(this@HomeActivity) val fahipayContacts = ContactsCache.loadFahipay(this@HomeActivity)
val fahipayCategories = ContactsCache.loadFahipayCategories(this@HomeActivity) val fahipayCategories = ContactsCache.loadFahipayCategories(this@HomeActivity)
viewModel.contacts.postValue(mergeContacts(mergeContacts(allContacts, bmlContacts), fahipayContacts)) viewModel.contacts.postValue(mergeContacts(mergeContacts(allContacts, bmlContacts), fahipayContacts))
@@ -612,17 +648,19 @@ class HomeActivity : AppCompatActivity() {
val others = current.filter { it.profileType != "FAHIPAY" } val others = current.filter { it.profileType != "FAHIPAY" }
viewModel.accounts.postValue(others + fresh) viewModel.accounts.postValue(others + fresh)
} else if (src.profileType.startsWith("BML")) { } else if (src.profileType.startsWith("BML")) {
val loginId = src.loginTag.removePrefix("bml_")
val fresh = withContext(Dispatchers.IO) { val fresh = withContext(Dispatchers.IO) {
val sess = app.bmlSession ?: return@withContext null val sess = app.bmlSessionFor(src) ?: return@withContext null
try { try {
val accounts = BmlLoginFlow().fetchAccounts(sess) val accounts = BmlLoginFlow().fetchAccounts(sess, src.loginTag)
AccountCache.saveBml(this@HomeActivity, accounts) AccountCache.saveBml(this@HomeActivity, loginId, accounts)
app.bmlAccounts = accounts val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag }
app.bmlAccounts = otherBml + accounts
accounts accounts
} catch (_: Exception) { null } } catch (_: Exception) { null }
} ?: return@launch } ?: return@launch
val mibOnly = current.filter { !it.profileType.startsWith("BML") } val otherAccounts = current.filter { !it.profileType.startsWith("BML") || it.loginTag != src.loginTag }
viewModel.accounts.postValue(mibOnly + fresh) viewModel.accounts.postValue(otherAccounts + fresh)
} else { } else {
val fresh = withContext(Dispatchers.IO) { val fresh = withContext(Dispatchers.IO) {
val sess = app.mibSession ?: return@withContext null val sess = app.mibSession ?: return@withContext null
@@ -75,9 +75,10 @@ class OtpFragment : Fragment() {
val name = store.loadMibFullName() val name = store.loadMibFullName()
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed)) entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
} }
store.loadBmlCredentials()?.let { creds -> for (loginId in store.getBmlLoginIds()) {
val name = store.loadBmlFullName() val creds = store.loadBmlCredentials(loginId) ?: continue
entries.add(OtpEntry(if (name != null) "BML · $name" else "BML", creds.otpSeed)) val name = store.loadBmlUserProfile(loginId)?.fullName
entries.add(OtpEntry(if (!name.isNullOrBlank()) "BML · $name" else "BML", creds.otpSeed))
} }
val adapter = OtpAdapter(entries) val adapter = OtpAdapter(entries)
@@ -105,13 +106,14 @@ class OtpFragment : Fragment() {
} }
} }
} }
if (store.loadBmlFullName() == null) { for (loginId in store.getBmlLoginIds()) {
app.bmlSession?.let { session -> if (store.loadBmlUserProfile(loginId)?.fullName.isNullOrBlank()) {
val session = app.bmlSessions[loginId] ?: continue
val info = withContext(Dispatchers.IO) { val info = withContext(Dispatchers.IO) {
try { BmlLoginFlow().fetchUserInfo(session) } catch (_: Exception) { null } try { BmlLoginFlow().fetchUserInfo(session) } catch (_: Exception) { null }
} }
if (info != null) { if (info != null) {
store.saveBmlUserProfile(CredentialStore.BmlUserProfile( store.saveBmlUserProfile(loginId, CredentialStore.BmlUserProfile(
fullName = info.fullName, fullName = info.fullName,
email = info.email, email = info.email,
mobile = info.mobile, mobile = info.mobile,
@@ -119,7 +121,8 @@ class OtpFragment : Fragment() {
idCard = info.idCard, idCard = info.idCard,
birthdate = info.birthdate birthdate = info.birthdate
)) ))
val idx = entries.indexOfFirst { it.seed == store.loadBmlCredentials()?.otpSeed } val seed = store.loadBmlCredentials(loginId)?.otpSeed
val idx = entries.indexOfFirst { it.seed == seed }
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "BML · ${info.fullName}"); changed = true } if (idx >= 0) { entries[idx] = entries[idx].copy(label = "BML · ${info.fullName}"); changed = true }
} }
} }
@@ -14,6 +14,9 @@ import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview import androidx.camera.core.Preview
import androidx.camera.core.resolutionselector.AspectRatioStrategy import androidx.camera.core.resolutionselector.AspectRatioStrategy
@@ -84,8 +87,23 @@ class QrScannerActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityQrScannerBinding.inflate(layoutInflater) binding = ActivityQrScannerBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
// Black camera background — always use light (white) system bar icons
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = false
isAppearanceLightNavigationBars = false
}
val originalBtnMarginBottom = (48 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.btnContainer) { view, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
(view.layoutParams as android.widget.FrameLayout.LayoutParams).also {
it.bottomMargin = originalBtnMarginBottom + bars.bottom
view.layoutParams = it
}
insets
}
binding.btnCancel.setOnClickListener { finish() } binding.btnCancel.setOnClickListener { finish() }
binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") } binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") }
@@ -59,10 +59,10 @@ class SettingsLoginsFragment : Fragment() {
container.removeAllViews() container.removeAllViews()
val hasMib = store.hasMibCredentials() val hasMib = store.hasMibCredentials()
val hasBml = store.hasBmlCredentials() val bmlLoginIds = store.getBmlLoginIds()
val hasFahipay = store.hasFahipayCredentials() val hasFahipay = store.hasFahipayCredentials()
binding.tvLoginsTitle.visibility = if (hasMib || hasBml || hasFahipay) View.VISIBLE else View.GONE binding.tvLoginsTitle.visibility = if (hasMib || bmlLoginIds.isNotEmpty() || hasFahipay) View.VISIBLE else View.GONE
if (hasMib) { if (hasMib) {
val profile = store.loadMibUserProfile() val profile = store.loadMibUserProfile()
@@ -86,10 +86,10 @@ class SettingsLoginsFragment : Fragment() {
} }
} }
if (hasBml) { for (loginId in bmlLoginIds) {
val profile = store.loadBmlUserProfile() val profile = store.loadBmlUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name) val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
val profileNames = AccountCache.loadBml(ctx).map { it.profileName }.filter { it.isNotBlank() }.distinct() val profileNames = AccountCache.loadBml(ctx, loginId).map { it.profileName }.filter { it.isNotBlank() }.distinct()
addLoginRow(container, R.drawable.bml_logo_vector, displayName) { addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
showLoginDetails( showLoginDetails(
title = getString(R.string.bml_name), title = getString(R.string.bml_name),
@@ -105,7 +105,7 @@ class SettingsLoginsFragment : Fragment() {
profileNames.forEach { appendLine("$it") } profileNames.forEach { appendLine("$it") }
} }
}.trim(), }.trim(),
onLogout = { confirmLogout(getString(R.string.bml_name)) { logoutBml(store) } } onLogout = { confirmLogout(getString(R.string.bml_name)) { logoutBml(store, loginId) } }
) )
} }
} }
@@ -183,11 +183,12 @@ class SettingsLoginsFragment : Fragment() {
buildLoginsSection() buildLoginsSection()
} }
private fun logoutBml(store: CredentialStore) { private fun logoutBml(store: CredentialStore, loginId: String) {
val ctx = requireContext() val ctx = requireContext()
store.clearBmlCredentials(); store.clearBmlSession() store.clearBmlCredentials(loginId); store.clearBmlSession(loginId)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
app.bmlSession = null; app.bmlAccounts = emptyList() app.bmlSessions.remove(loginId)
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
clearAllCaches(ctx) clearAllCaches(ctx)
(activity as HomeActivity).relogin() (activity as HomeActivity).relogin()
buildLoginsSection() buildLoginsSection()
@@ -35,8 +35,6 @@ class SettingsSecurityFragment : Fragment() {
val canUseBiometrics = BiometricManager.from(requireContext()) val canUseBiometrics = BiometricManager.from(requireContext())
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
if (canUseBiometrics) { if (canUseBiometrics) {
binding.rowBiometrics.visibility = View.VISIBLE
val unlockEnabled = prefs.getBoolean("biometrics_enabled", false) val unlockEnabled = prefs.getBoolean("biometrics_enabled", false)
binding.switchBiometrics.isChecked = unlockEnabled binding.switchBiometrics.isChecked = unlockEnabled
binding.switchBiometricsTransfer.isChecked = prefs.getBoolean("biometrics_transfer_confirm", false) binding.switchBiometricsTransfer.isChecked = prefs.getBoolean("biometrics_transfer_confirm", false)
@@ -54,6 +52,10 @@ class SettingsSecurityFragment : Fragment() {
binding.switchBiometricsTransfer.setOnCheckedChangeListener { _, isChecked -> binding.switchBiometricsTransfer.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("biometrics_transfer_confirm", isChecked).apply() prefs.edit().putBoolean("biometrics_transfer_confirm", isChecked).apply()
} }
} else {
binding.tvBiometricsHint.visibility = View.VISIBLE
binding.switchBiometrics.isEnabled = false
binding.switchBiometricsTransfer.isEnabled = false
} }
// Auto-lock // Auto-lock
@@ -23,6 +23,7 @@ import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -63,7 +64,9 @@ class TransferFragment : Fragment() {
private var selectedAccount: MibAccount? = null private var selectedAccount: MibAccount? = null
private val session get() = (requireActivity().application as BasedBankApp).mibSession private val session get() = (requireActivity().application as BasedBankApp).mibSession
private val bmlSession get() = (requireActivity().application as BasedBankApp).bmlSession private fun bmlSessionFor(account: MibAccount?) =
account?.let { (requireActivity().application as BasedBankApp).bmlSessionFor(it) }
?: (requireActivity().application as BasedBankApp).anyBmlSession()
// Resolved recipient info — set after successful lookup or prefill // Resolved recipient info — set after successful lookup or prefill
private var resolvedAccountNumber = "" private var resolvedAccountNumber = ""
@@ -93,6 +96,11 @@ class TransferFragment : Fragment() {
private const val ARG_SUBTITLE = "contact_subtitle" private const val ARG_SUBTITLE = "contact_subtitle"
private const val ARG_COLOR = "contact_color" private const val ARG_COLOR = "contact_color"
private const val ARG_IMAGE_HASH = "contact_image_hash" private const val ARG_IMAGE_HASH = "contact_image_hash"
private const val ARG_FROM_ACCOUNT = "from_account"
fun newInstanceFrom(account: MibAccount) = TransferFragment().apply {
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
}
fun newInstance( fun newInstance(
accountNumber: String, accountNumber: String,
@@ -152,6 +160,22 @@ class TransferFragment : Fragment() {
} }
} }
private fun startLookupLoading() {
val spinner = CircularProgressDrawable(requireContext()).apply {
setStyle(CircularProgressDrawable.DEFAULT)
setColorSchemeColors(com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorPrimary, Color.GRAY))
start()
}
binding.tilTo.endIconDrawable = spinner
binding.tilTo.isEnabled = false
}
private fun stopLookupLoading() {
binding.tilTo.isEnabled = true
binding.tilTo.endIconDrawable = ContextCompat.getDrawable(requireContext(), android.R.drawable.ic_menu_search)
}
private fun setupFromDropdown() { private fun setupFromDropdown() {
binding.btnClearFromInfo.setOnClickListener { binding.btnClearFromInfo.setOnClickListener {
selectedAccount = null selectedAccount = null
@@ -171,6 +195,16 @@ class TransferFragment : Fragment() {
updateAmountPrefix(picked) updateAmountPrefix(picked)
showFromCard(picked) showFromCard(picked)
} }
val fromNumber = arguments?.getString(ARG_FROM_ACCOUNT)
if (fromNumber != null && selectedAccount == null) {
val match = accounts.firstOrNull { it.accountNumber == fromNumber }
if (match != null) {
selectedAccount = match
updateAmountPrefix(match)
showFromCard(match)
}
}
} }
} }
@@ -188,6 +222,7 @@ class TransferFragment : Fragment() {
} }
val typeLabel = when { val typeLabel = when {
account.profileType == "BML_PREPAID" -> "Prepaid Card" account.profileType == "BML_PREPAID" -> "Prepaid Card"
account.profileType == "BML_CREDIT" -> "Credit Card"
account.accountTypeName.isNotBlank() -> account.accountTypeName account.accountTypeName.isNotBlank() -> account.accountTypeName
else -> account.profileType else -> account.profileType
} }
@@ -256,7 +291,7 @@ class TransferFragment : Fragment() {
Toast.makeText(requireContext(), R.string.transfer_select_source_first, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.transfer_select_source_first, Toast.LENGTH_SHORT).show()
return return
} }
val accountNumber = binding.etTo.text?.toString()?.trim() ?: "" val accountNumber = AccountInputParser.normalize(binding.etTo.text?.toString()?.trim() ?: "")
if (accountNumber.isBlank()) { if (accountNumber.isBlank()) {
Toast.makeText(requireContext(), R.string.transfer_enter_account_first, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.transfer_enter_account_first, Toast.LENGTH_SHORT).show()
return return
@@ -287,7 +322,7 @@ class TransferFragment : Fragment() {
} }
val mibSess = session val mibSess = session
val bmlSess = bmlSession val bmlSess = bmlSessionFor(selectedAccount)
if (mibSess == null && bmlSess == null) { if (mibSess == null && bmlSess == null) {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return return
@@ -295,7 +330,7 @@ class TransferFragment : Fragment() {
val isBmlSource = selectedAccount?.profileType?.startsWith("BML") == true val isBmlSource = selectedAccount?.profileType?.startsWith("BML") == true
binding.tilTo.isEnabled = false startLookupLoading()
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
var errorMsg: String? = null var errorMsg: String? = null
@@ -339,7 +374,7 @@ class TransferFragment : Fragment() {
} }
} }
} }
binding.tilTo.isEnabled = true stopLookupLoading()
if (info != null) { if (info != null) {
val accounts = viewModel.accounts.value ?: emptyList() val accounts = viewModel.accounts.value ?: emptyList()
val matchedAcc = accounts.firstOrNull { it.accountNumber == info.accountNumber } val matchedAcc = accounts.firstOrNull { it.accountNumber == info.accountNumber }
@@ -374,7 +409,7 @@ class TransferFragment : Fragment() {
} }
private fun lookupFahipayTarget(number: String) { private fun lookupFahipayTarget(number: String) {
binding.tilTo.isEnabled = false startLookupLoading()
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
data class LookupResult( data class LookupResult(
val dhiraagu: DhiraaguClient.Result, val dhiraagu: DhiraaguClient.Result,
@@ -401,7 +436,7 @@ class TransferFragment : Fragment() {
LookupResult(d, o) LookupResult(d, o)
} }
} }
binding.tilTo.isEnabled = true stopLookupLoading()
val dhiraaguName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() } val dhiraaguName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() }
@@ -533,7 +568,7 @@ class TransferFragment : Fragment() {
val remarks = binding.etRemarks.text?.toString()?.trim() ?: "" val remarks = binding.etRemarks.text?.toString()?.trim() ?: ""
val isSrcBml = src.profileType.startsWith("BML") val isSrcBml = src.profileType.startsWith("BML")
val isSrcCard = src.profileType == "BML_PREPAID" val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT"
val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT
val currency = src.currencyName.ifBlank { "MVR" } val currency = src.currencyName.ifBlank { "MVR" }
val allAccounts = viewModel.accounts.value ?: emptyList() val allAccounts = viewModel.accounts.value ?: emptyList()
@@ -562,11 +597,13 @@ class TransferFragment : Fragment() {
?.transferCyDesc?.ifBlank { "MVR" } ?.transferCyDesc?.ifBlank { "MVR" }
?: if (isDestMib) "MVR" else "MVR" ?: if (isDestMib) "MVR" else "MVR"
val isUsdToMvr = currency.equals("USD", ignoreCase = true) && destCurrency.equals("MVR", ignoreCase = true) val isUsdToMvr = currency.equals("USD", ignoreCase = true) && destCurrency.equals("MVR", ignoreCase = true)
val isSrcCredit = src.profileType == "BML_CREDIT"
val mainMsg = "Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}" val mainMsg = "Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}"
val doTransfer: () -> Unit = { val doTransfer: () -> Unit = {
binding.btnTransfer.isEnabled = false binding.btnTransfer.isEnabled = false
(activity as? HomeActivity)?.setRefreshing(true)
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val (ok, msg, receipt) = withContext(Dispatchers.IO) { val (ok, msg, receipt) = withContext(Dispatchers.IO) {
if (!isSrcBml) { if (!isSrcBml) {
@@ -576,6 +613,7 @@ class TransferFragment : Fragment() {
} }
} }
binding.btnTransfer.isEnabled = true binding.btnTransfer.isEnabled = true
(activity as? HomeActivity)?.setRefreshing(false)
if (ok && receipt != null) { if (ok && receipt != null) {
clearForm() clearForm()
val activity = requireActivity() as HomeActivity val activity = requireActivity() as HomeActivity
@@ -592,7 +630,7 @@ class TransferFragment : Fragment() {
.setPositiveButton(R.string.transfer_confirm) { _, _ -> doTransfer() } .setPositiveButton(R.string.transfer_confirm) { _, _ -> doTransfer() }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel, null)
if (isUsdToMvr) { if (isUsdToMvr || isSrcCredit) {
val ctx = requireContext() val ctx = requireContext()
val dp = resources.displayMetrics.density val dp = resources.displayMetrics.density
val container = LinearLayout(ctx).apply { val container = LinearLayout(ctx).apply {
@@ -600,13 +638,24 @@ class TransferFragment : Fragment() {
setPadding((24 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt(), 0) setPadding((24 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt(), 0)
} }
container.addView(TextView(ctx).apply { text = mainMsg }) container.addView(TextView(ctx).apply { text = mainMsg })
container.addView(TextView(ctx).apply { if (isUsdToMvr) {
text = "⚠ You are transferring from a USD account to an MVR account. The currency will be converted at the bank's rate and this cannot be reversed!" container.addView(TextView(ctx).apply {
setTextColor(Color.RED) text = "⚠ You are transferring from a USD account to an MVR account. The currency will be converted at the bank's rate and this cannot be reversed!"
textSize = 16f setTextColor(Color.RED)
typeface = Typeface.DEFAULT_BOLD textSize = 16f
setPadding(0, (16 * dp).toInt(), 0, 0) typeface = Typeface.DEFAULT_BOLD
}) setPadding(0, (16 * dp).toInt(), 0, 0)
})
}
if (isSrcCredit) {
container.addView(TextView(ctx).apply {
text = "⚠ Transferring from a credit card is treated as a cash advance. Cash advance fees will be charged on the 10th of the month."
setTextColor(Color.RED)
textSize = 16f
typeface = Typeface.DEFAULT_BOLD
setPadding(0, (16 * dp).toInt(), 0, 0)
})
}
dialogBuilder.setView(container) dialogBuilder.setView(container)
} else { } else {
dialogBuilder.setMessage(mainMsg) dialogBuilder.setMessage(mainMsg)
@@ -726,8 +775,9 @@ class TransferFragment : Fragment() {
allAccounts: List<MibAccount>, allAccounts: List<MibAccount>,
allContacts: List<MibBeneficiary> allContacts: List<MibBeneficiary>
): Triple<Boolean, String, TransferReceiptData?> { ): Triple<Boolean, String, TransferReceiptData?> {
val sess = bmlSession ?: return Triple(false, getString(R.string.transfer_session_unavailable), null) val loginId = src.loginTag.removePrefix("bml_")
val otp = CredentialStore(requireContext()).loadBmlCredentials()?.otpSeed val sess = bmlSessionFor(src) ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) } ?.let { Totp.generate(it) }
?: return Triple(false, "OTP unavailable", null) ?: return Triple(false, "OTP unavailable", null)
val debitAccount = src.internalId.ifBlank { val debitAccount = src.internalId.ifBlank {
@@ -735,7 +785,7 @@ class TransferFragment : Fragment() {
} }
// Determine type + credit account // Determine type + credit account
val isDestMyCard = allAccounts.any { it.profileType == "BML_PREPAID" && it.accountNumber == destAccount } val isDestMyCard = allAccounts.any { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && it.accountNumber == destAccount }
val (transferType, creditAccount, bank) = when { val (transferType, creditAccount, bank) = when {
isSrcCard -> { isSrcCard -> {
// CAD: card → own BML account // CAD: card → own BML account
@@ -744,7 +794,7 @@ class TransferFragment : Fragment() {
} }
isDestMyCard -> { isDestMyCard -> {
// CPA: BML CASA → own card top-up // CPA: BML CASA → own card top-up
val card = allAccounts.first { it.profileType == "BML_PREPAID" && it.accountNumber == destAccount } val card = allAccounts.first { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && it.accountNumber == destAccount }
Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?) Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?)
} }
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB") isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
@@ -767,7 +817,7 @@ class TransferFragment : Fragment() {
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null) if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
// Step 2: confirm with fresh OTP // Step 2: confirm with fresh OTP
val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials()?.otpSeed val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) } ?: otp ?.let { Totp.generate(it) } ?: otp
return try { return try {
@@ -917,8 +967,8 @@ class TransferFragment : Fragment() {
) : BaseAdapter(), Filterable { ) : BaseAdapter(), Filterable {
private val items: List<Any> = buildList { private val items: List<Any> = buildList {
val regular = accounts.filter { it.profileType != "BML_PREPAID" } val regular = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
val cards = accounts.filter { it.profileType == "BML_PREPAID" } val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
addAll(regular) addAll(regular)
if (cards.isNotEmpty()) { if (cards.isNotEmpty()) {
add(getString(R.string.cards)) add(getString(R.string.cards))
@@ -927,7 +977,7 @@ class TransferFragment : Fragment() {
} }
fun getAccount(position: Int): MibAccount? = (items.getOrNull(position) as? MibAccount) fun getAccount(position: Int): MibAccount? = (items.getOrNull(position) as? MibAccount)
?.takeUnless { it.profileType == "BML_PREPAID" && !it.statusDesc.equals("Active", ignoreCase = true) } ?.takeUnless { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && !it.statusDesc.equals("Active", ignoreCase = true) }
override fun getCount() = items.size override fun getCount() = items.size
override fun getItem(position: Int) = items[position] override fun getItem(position: Int) = items[position]
@@ -958,8 +1008,10 @@ class TransferFragment : Fragment() {
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false) ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
.also { it.root.tag = it } .also { it.root.tag = it }
} }
val inactive = acc.profileType == "BML_PREPAID" && !acc.statusDesc.equals("Active", ignoreCase = true) val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT") && !acc.statusDesc.equals("Active", ignoreCase = true)
b.tvDropdownAccountName.text = acc.accountBriefName val isBmlAccount = acc.profileType.startsWith("BML")
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber
b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}" b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}"
b.root.alpha = if (inactive) 0.4f else 1f b.root.alpha = if (inactive) 0.4f else 1f
@@ -62,7 +62,7 @@ class TransferHistoryFragment : Fragment() {
) { ) {
fun hasMore(): Boolean = when { fun hasMore(): Boolean = when {
account.profileType == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal account.profileType == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
account.profileType == "BML_PREPAID" -> cardMonthOffset < 2 account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2
account.profileType.startsWith("BML") -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages account.profileType.startsWith("BML") -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
} }
@@ -143,7 +143,6 @@ class TransferHistoryFragment : Fragment() {
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val mibSession = app.mibSession val mibSession = app.mibSession
val bmlSession = app.bmlSession
lifecycleScope.launch { lifecycleScope.launch {
val newTransactions = withContext(Dispatchers.IO) { val newTransactions = withContext(Dispatchers.IO) {
@@ -155,8 +154,8 @@ class TransferHistoryFragment : Fragment() {
async { async {
try { try {
when { when {
state.account.profileType == "BML_PREPAID" -> { state.account.profileType == "BML_PREPAID" || state.account.profileType == "BML_CREDIT" -> {
val session = bmlSession ?: return@async emptyList() val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
val cal = Calendar.getInstance() val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -state.cardMonthOffset) cal.add(Calendar.MONTH, -state.cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time) val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
@@ -170,7 +169,7 @@ class TransferHistoryFragment : Fragment() {
) )
} }
else -> { else -> {
val session = bmlSession ?: return@async emptyList() val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory( val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
session = session, session = session,
accountId = state.account.internalId, accountId = state.account.internalId,
@@ -313,6 +313,12 @@ class TransferReceiptFragment : Fragment() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
requireActivity().title = "Receipt" requireActivity().title = "Receipt"
(activity as? HomeActivity)?.setBottomNavVisible(false)
}
override fun onPause() {
super.onPause()
(activity as? HomeActivity)?.setBottomNavVisible(true)
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -67,11 +67,27 @@ class CredentialsFragment : Fragment() {
} }
} }
binding.btnLogin.isEnabled = false
binding.btnLogin.setOnClickListener { attemptLogin() } binding.btnLogin.setOnClickListener { attemptLogin() }
val loginFieldWatcher = object : TextWatcher {
override fun afterTextChanged(s: Editable?) { updateLoginButtonState() }
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
}
binding.etUsername.addTextChangedListener(loginFieldWatcher)
binding.etPassword.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) { updateLoginButtonState(); 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") { if (bankType != "FAHIPAY") {
binding.etOtpSeed.addTextChangedListener(object : TextWatcher { binding.etOtpSeed.addTextChangedListener(object : TextWatcher {
override fun afterTextChanged(s: Editable?) { updateOtpDisplay() } override fun afterTextChanged(s: Editable?) {
updateOtpDisplay()
updateLoginButtonState()
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
}) })
@@ -88,10 +104,35 @@ class CredentialsFragment : Fragment() {
otpHandler.removeCallbacks(otpRunnable) otpHandler.removeCallbacks(otpRunnable)
} }
private fun resolveOtpSeed(input: String): String {
val secret = if (input.startsWith("otpauth://totp/"))
android.net.Uri.parse(input).getQueryParameter("secret") ?: input
else
input
return secret.replace("\\s".toRegex(), "").replace("-", "").uppercase()
}
private fun updateLoginButtonState() {
val username = binding.etUsername.text.toString().trim()
val password = binding.etPassword.text.toString()
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
val otpSeed = resolveOtpSeed(otpSeedRaw)
binding.btnLogin.isEnabled = when (bankType) {
"FAHIPAY" -> username.isNotEmpty() && password.isNotEmpty()
else -> username.isNotEmpty() && password.isNotEmpty() && otpSeed.isNotEmpty() && password != otpSeedRaw
}
}
private fun updateOtpDisplay() { private fun updateOtpDisplay() {
val seed = binding.etOtpSeed.text.toString().trim() val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
val seed = resolveOtpSeed(otpSeedRaw)
if (seed.isEmpty()) { if (seed.isEmpty()) {
binding.cardOtp.visibility = View.GONE binding.cardOtp.visibility = View.INVISIBLE
return
}
val password = binding.etPassword.text.toString()
if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) {
binding.cardOtp.visibility = View.INVISIBLE
return return
} }
try { try {
@@ -104,7 +145,7 @@ class CredentialsFragment : Fragment() {
binding.otpTimer.progress = remaining binding.otpTimer.progress = remaining
binding.cardOtp.visibility = View.VISIBLE binding.cardOtp.visibility = View.VISIBLE
} catch (e: Exception) { } catch (e: Exception) {
binding.cardOtp.visibility = View.GONE binding.cardOtp.visibility = View.INVISIBLE
} }
} }
@@ -116,7 +157,7 @@ class CredentialsFragment : Fragment() {
val username = binding.etUsername.text.toString().trim() val username = binding.etUsername.text.toString().trim()
val password = binding.etPassword.text.toString() val password = binding.etPassword.text.toString()
val otpSeed = binding.etOtpSeed.text.toString().trim() val otpSeed = resolveOtpSeed(binding.etOtpSeed.text.toString().trim())
if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) { if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
binding.tvError.text = "Please fill in all fields" binding.tvError.text = "Please fill in all fields"
@@ -173,7 +214,7 @@ class CredentialsFragment : Fragment() {
private fun attemptBmlLogin() { private fun attemptBmlLogin() {
val username = binding.etUsername.text.toString().trim() val username = binding.etUsername.text.toString().trim()
val password = binding.etPassword.text.toString() val password = binding.etPassword.text.toString()
val otpSeed = binding.etOtpSeed.text.toString().trim() val otpSeed = resolveOtpSeed(binding.etOtpSeed.text.toString().trim())
if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) { if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
binding.tvError.text = "Please fill in all fields" binding.tvError.text = "Please fill in all fields"
@@ -185,6 +226,7 @@ class CredentialsFragment : Fragment() {
binding.progressBar.visibility = View.VISIBLE binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false binding.btnLogin.isEnabled = false
val loginId = username
val flow = BmlLoginFlow() val flow = BmlLoginFlow()
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
try { try {
@@ -192,11 +234,12 @@ class CredentialsFragment : Fragment() {
flow.login(username, password, otpSeed) flow.login(username, password, otpSeed)
} }
val store = CredentialStore(requireContext()) val store = CredentialStore(requireContext())
store.saveBmlCredentials(username, password, otpSeed) store.saveBmlCredentials(loginId, username, password, otpSeed)
store.saveBmlSession(session.accessToken, session.deviceId) store.saveBmlSession(loginId, session.accessToken, session.deviceId)
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val info = flow.fetchUserInfo(session) val info = flow.fetchUserInfo(session)
if (info != null) store.saveBmlUserProfile( if (info != null) store.saveBmlUserProfile(
loginId,
CredentialStore.BmlUserProfile( CredentialStore.BmlUserProfile(
fullName = info.fullName, fullName = info.fullName,
email = info.email, email = info.email,
@@ -207,11 +250,11 @@ class CredentialsFragment : Fragment() {
) )
) )
} }
AccountCache.saveBml(requireContext(), accounts) AccountCache.saveBml(requireContext(), loginId, accounts)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
app.bmlSession = session app.bmlSessions[loginId] = session
app.bmlAccounts = accounts // Merge with any existing BML accounts from other logins
// Merge with any existing MIB accounts already in app app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" } + accounts
app.accounts = app.accounts + accounts app.accounts = app.accounts + accounts
val intent = Intent(requireContext(), HomeActivity::class.java) val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
@@ -1,7 +1,9 @@
package sh.sar.basedbank.ui.login package sh.sar.basedbank.ui.login
import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import sh.sar.basedbank.databinding.ActivityLoginBinding import sh.sar.basedbank.databinding.ActivityLoginBinding
class LoginActivity : AppCompatActivity() { class LoginActivity : AppCompatActivity() {
@@ -10,7 +12,13 @@ class LoginActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityLoginBinding.inflate(layoutInflater) binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
} }
} }
@@ -3,10 +3,14 @@ package sh.sar.basedbank.ui.onboarding
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.os.CountDownTimer
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import sh.sar.basedbank.R import sh.sar.basedbank.R
@@ -17,46 +21,68 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
private lateinit var binding: ActivityOnboardingBinding private lateinit var binding: ActivityOnboardingBinding
private lateinit var prefs: SharedPreferences private lateinit var prefs: SharedPreferences
private var countDownTimer: CountDownTimer? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityOnboardingBinding.inflate(layoutInflater) binding = ActivityOnboardingBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
val isLight = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_NO
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
prefs = getSharedPreferences("prefs", MODE_PRIVATE) prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val originalBottomPadding = binding.bottomBar.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets ->
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, originalBottomPadding + navBar.bottom)
insets
}
val adapter = OnboardingPagerAdapter(this) val adapter = OnboardingPagerAdapter(this)
binding.viewPager.adapter = adapter binding.viewPager.adapter = adapter
TabLayoutMediator(binding.dotsIndicator, binding.viewPager) { _, _ -> }.attach() TabLayoutMediator(binding.dotsIndicator, binding.viewPager) { _, _ -> }.attach()
// Disable tap-to-navigate on dots: touch listener must be on the individual
// Pre-select language chip without triggering the listener // tab views inside SlidingTabStrip (child 0), because they consume ACTION_DOWN
val savedLang = prefs.getString("language", null) // before the TabLayout's own touch listener ever fires.
binding.languageChipGroup.setOnCheckedStateChangeListener(null) val tabStrip = binding.dotsIndicator.getChildAt(0) as? android.view.ViewGroup
when (savedLang) { tabStrip?.let {
"en" -> binding.chipEnglish.isChecked = true for (i in 0 until it.childCount) {
"dv" -> binding.chipDhivehi.isChecked = true it.getChildAt(i).setOnTouchListener { _, _ -> true }
}
binding.languageChipGroup.setOnCheckedStateChangeListener { _, checkedIds ->
if (checkedIds.isNotEmpty()) {
selectLanguage(if (checkedIds[0] == R.id.chipEnglish) "en" else "dv")
} }
} }
// Pre-select language button without triggering the listener
val savedLang = prefs.getString("language", null)
binding.languageToggle.clearOnButtonCheckedListeners()
when (savedLang) {
"en" -> binding.btnLangEnglish.isChecked = true
"dv" -> binding.btnLangDhivehi.isChecked = true
}
binding.languageToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (isChecked) selectLanguage(if (checkedId == R.id.btnLangEnglish) "en" else "dv")
}
supportFragmentManager.setFragmentResultListener(OnboardingFragment.RESULT_SCROLLED_TO_BOTTOM, this) { _, _ ->
startGetStartedCountdown()
}
binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() { binding.viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageSelected(position: Int) { override fun onPageSelected(position: Int) {
binding.languageChipGroup.visibility = if (position == 0) View.VISIBLE else View.GONE binding.languageSection.visibility = if (position == 0) View.VISIBLE else View.GONE
// Block forward swipe on slide 1 until security is set up binding.viewPager.isUserInputEnabled = when {
if (position == 1) { position > 2 -> false
binding.viewPager.isUserInputEnabled = position == 1 -> prefs.getString("security_method", null) != null
prefs.getString("security_method", null) != null else -> true
} else {
binding.viewPager.isUserInputEnabled = true
} }
updateButtons(position, adapter.itemCount) updateButtons(position, adapter.itemCount)
} }
}) })
binding.languageChipGroup.visibility = View.VISIBLE binding.languageSection.visibility = View.VISIBLE
updateButtons(0, adapter.itemCount) updateButtons(0, adapter.itemCount)
binding.btnNext.setOnClickListener { binding.btnNext.setOnClickListener {
@@ -71,10 +97,21 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
} }
} }
override fun onDestroy() {
super.onDestroy()
countDownTimer?.cancel()
}
// Called by SecuritySetupFragment when setup is complete // Called by SecuritySetupFragment when setup is complete
override fun onSecuritySetupComplete() { override fun onSecuritySetupComplete() {
binding.viewPager.isUserInputEnabled = true binding.viewPager.isUserInputEnabled = true
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 3) updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 4)
}
// Called by SecuritySetupFragment when user resets to reconfigure
override fun onSecuritySetupReset() {
binding.viewPager.isUserInputEnabled = false
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 4)
} }
private fun selectLanguage(lang: String) { private fun selectLanguage(lang: String) {
@@ -83,23 +120,34 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 3) updateButtons(binding.viewPager.currentItem, binding.viewPager.adapter?.itemCount ?: 3)
} }
private fun startGetStartedCountdown() {
binding.btnGetStarted.isEnabled = false
countDownTimer?.cancel()
countDownTimer = object : CountDownTimer(5000, 1000) {
override fun onTick(millisUntilFinished: Long) {
val seconds = (millisUntilFinished / 1000 + 1).toInt()
binding.btnGetStarted.text = "${getString(R.string.get_started)} ($seconds)"
}
override fun onFinish() {
binding.btnGetStarted.text = getString(R.string.get_started)
binding.btnGetStarted.isEnabled = true
}
}.start()
}
private fun updateButtons(position: Int, count: Int) { private fun updateButtons(position: Int, count: Int) {
val langSelected = prefs.getString("language", null) != null val langSelected = prefs.getString("language", null) != null
val securityDone = prefs.getString("security_method", null) != null val securityDone = prefs.getString("security_method", null) != null
val isLast = position == count - 1 val isLast = position == count - 1
binding.btnGetStarted.visibility = if (isLast) View.VISIBLE else View.GONE binding.btnGetStarted.visibility = if (isLast) View.VISIBLE else View.GONE
if (isLast) binding.btnGetStarted.isEnabled = false
// Hide Next on slide 1 until security is done (avoids a disabled-button-with-no-explanation) binding.btnNext.visibility = if (isLast) View.GONE else View.VISIBLE
binding.btnNext.visibility = when {
isLast -> View.GONE
position == 1 && !securityDone -> View.GONE
else -> View.VISIBLE
}
binding.btnNext.isEnabled = when (position) { binding.btnNext.isEnabled = when (position) {
0 -> langSelected 0 -> langSelected
1 -> securityDone 1 -> securityDone
else -> true else -> true // position 2 (configure) has no gate
} }
} }
} }
@@ -0,0 +1,100 @@
package sh.sar.basedbank.ui.onboarding
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatDelegate
import androidx.biometric.BiometricManager
import androidx.fragment.app.Fragment
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentOnboardingConfigureBinding
class OnboardingConfigureFragment : Fragment() {
private var _binding: FragmentOnboardingConfigureBinding? = null
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentOnboardingConfigureBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
// Navigation — default Drawer
val isBottom = prefs.getBoolean("bottom_nav", false)
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer)
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
}
// Theme — default System
val savedTheme = prefs.getString("theme", "system")
binding.themeToggle.check(when (savedTheme) {
"light" -> R.id.btnThemeLight
"dark" -> R.id.btnThemeDark
else -> R.id.btnThemeSystem
})
binding.themeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener
val (key, mode) = when (checkedId) {
R.id.btnThemeLight -> "light" to AppCompatDelegate.MODE_NIGHT_NO
R.id.btnThemeDark -> "dark" to AppCompatDelegate.MODE_NIGHT_YES
else -> "system" to AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
}
prefs.edit().putString("theme", key).apply()
AppCompatDelegate.setDefaultNightMode(mode)
}
// Biometrics
val canUseBiometrics = BiometricManager.from(requireContext())
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
if (canUseBiometrics) {
val unlockEnabled = prefs.getBoolean("biometrics_enabled", false)
binding.switchBiometrics.isChecked = unlockEnabled
binding.switchBiometricsTransfer.isChecked = prefs.getBoolean("biometrics_transfer_confirm", false)
binding.switchBiometricsTransfer.isEnabled = unlockEnabled
binding.switchBiometrics.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("biometrics_enabled", isChecked).apply()
binding.switchBiometricsTransfer.isEnabled = isChecked
if (!isChecked) {
binding.switchBiometricsTransfer.isChecked = false
prefs.edit().putBoolean("biometrics_transfer_confirm", false).apply()
}
}
binding.switchBiometricsTransfer.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("biometrics_transfer_confirm", isChecked).apply()
}
} else {
binding.tvBiometricsHint.visibility = View.VISIBLE
binding.switchBiometrics.isEnabled = false
binding.switchBiometricsTransfer.isEnabled = false
}
// Block screenshots — default on
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
binding.switchBlockScreenshots.isChecked = blockScreenshots
applyFlagSecure(blockScreenshots)
binding.switchBlockScreenshots.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("block_screenshots", isChecked).apply()
applyFlagSecure(isChecked)
}
}
private fun applyFlagSecure(enabled: Boolean) {
val win = activity?.window ?: return
if (enabled) win.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
else win.clearFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
@@ -4,6 +4,8 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewTreeObserver
import android.widget.ScrollView
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import sh.sar.basedbank.databinding.FragmentOnboardingSlideBinding import sh.sar.basedbank.databinding.FragmentOnboardingSlideBinding
@@ -12,6 +14,7 @@ class OnboardingFragment : Fragment() {
private var _binding: FragmentOnboardingSlideBinding? = null private var _binding: FragmentOnboardingSlideBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private var scrolledToBottom = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentOnboardingSlideBinding.inflate(inflater, container, false) _binding = FragmentOnboardingSlideBinding.inflate(inflater, container, false)
@@ -19,17 +22,45 @@ class OnboardingFragment : Fragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val title = requireArguments().getString(ARG_TITLE, "") val titleRes = requireArguments().getInt(ARG_TITLE)
val desc = requireArguments().getString(ARG_DESC, "") val descRes = requireArguments().getInt(ARG_DESC)
val icon = requireArguments().getInt(ARG_ICON, 0) val icon = requireArguments().getInt(ARG_ICON, 0)
val isFirst = requireArguments().getBoolean(ARG_IS_FIRST, false) val isFirst = requireArguments().getBoolean(ARG_IS_FIRST, false)
val isLast = requireArguments().getBoolean(ARG_IS_LAST, false)
binding.icon.visibility = if (isLast) View.GONE else View.VISIBLE
binding.icon.setImageResource(icon) binding.icon.setImageResource(icon)
binding.title.text = title binding.title.text = getString(titleRes)
binding.description.text = desc binding.description.text = getString(descRes)
binding.description.gravity = if (isLast) android.view.Gravity.START else android.view.Gravity.CENTER
// On the first slide, show the two placeholder cards for upcoming banks // On the first slide, show the two placeholder cards for upcoming banks
binding.placeholderCards.visibility = if (isFirst) View.VISIBLE else View.GONE binding.placeholderCards.visibility = if (isFirst) View.VISIBLE else View.GONE
if (isLast) setupScrollToBottomDetection()
}
private fun setupScrollToBottomDetection() {
val scrollView = binding.scrollView
// If content fits without scrolling, fire immediately after layout
scrollView.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
scrollView.viewTreeObserver.removeOnGlobalLayoutListener(this)
val child = scrollView.getChildAt(0) ?: return
if (child.height <= scrollView.height) notifyScrolledToBottom()
}
})
scrollView.setOnScrollChangeListener { v, _, scrollY, _, _ ->
val sv = v as ScrollView
val child = sv.getChildAt(0) ?: return@setOnScrollChangeListener
if (scrollY + sv.height >= child.height) notifyScrolledToBottom()
}
}
private fun notifyScrolledToBottom() {
if (scrolledToBottom) return
scrolledToBottom = true
parentFragmentManager.setFragmentResult(RESULT_SCROLLED_TO_BOTTOM, Bundle.EMPTY)
} }
override fun onDestroyView() { override fun onDestroyView() {
@@ -38,17 +69,20 @@ class OnboardingFragment : Fragment() {
} }
companion object { companion object {
const val RESULT_SCROLLED_TO_BOTTOM = "scroll_to_bottom"
private const val ARG_TITLE = "title" private const val ARG_TITLE = "title"
private const val ARG_DESC = "desc" private const val ARG_DESC = "desc"
private const val ARG_ICON = "icon" private const val ARG_ICON = "icon"
private const val ARG_IS_FIRST = "is_first" private const val ARG_IS_FIRST = "is_first"
private const val ARG_IS_LAST = "is_last"
fun newInstance(slide: OnboardingSlide) = OnboardingFragment().apply { fun newInstance(slide: OnboardingSlide) = OnboardingFragment().apply {
arguments = bundleOf( arguments = bundleOf(
ARG_TITLE to slide.title, ARG_TITLE to slide.titleRes,
ARG_DESC to slide.description, ARG_DESC to slide.descRes,
ARG_ICON to slide.iconRes, ARG_ICON to slide.iconRes,
ARG_IS_FIRST to slide.isFirst ARG_IS_FIRST to slide.isFirst,
ARG_IS_LAST to slide.isLast
) )
} }
} }
@@ -9,36 +9,39 @@ class OnboardingPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(
private val slides = listOf( private val slides = listOf(
OnboardingSlide( OnboardingSlide(
title = activity.getString(R.string.onboarding_title_1), titleRes = R.string.onboarding_title_1,
description = activity.getString(R.string.onboarding_desc_1), descRes = R.string.onboarding_desc_1,
iconRes = R.drawable.ic_launcher_foreground, iconRes = R.drawable.ic_launcher_foreground,
isFirst = true isFirst = true
), ),
OnboardingSlide( OnboardingSlide(
title = activity.getString(R.string.onboarding_title_2), titleRes = R.string.onboarding_title_2,
description = activity.getString(R.string.onboarding_desc_2), descRes = R.string.onboarding_desc_2,
iconRes = R.drawable.ic_launcher_foreground, iconRes = R.drawable.ic_launcher_foreground,
isFirst = false isFirst = false
), ),
OnboardingSlide( OnboardingSlide(
title = activity.getString(R.string.onboarding_title_3), titleRes = R.string.onboarding_title_3,
description = activity.getString(R.string.onboarding_desc_3), descRes = R.string.onboarding_desc_3,
iconRes = R.drawable.ic_launcher_foreground, iconRes = R.drawable.ic_launcher_foreground,
isFirst = false isFirst = false,
isLast = true
) )
) )
override fun getItemCount() = slides.size override fun getItemCount() = slides.size + 1 // +1 for OnboardingConfigureFragment at position 2
override fun createFragment(position: Int): Fragment = when (position) { override fun createFragment(position: Int): Fragment = when (position) {
1 -> SecuritySetupFragment() 1 -> SecuritySetupFragment()
else -> OnboardingFragment.newInstance(slides[position]) 2 -> OnboardingConfigureFragment()
else -> OnboardingFragment.newInstance(slides[position - if (position > 2) 1 else 0])
} }
} }
data class OnboardingSlide( data class OnboardingSlide(
val title: String, val titleRes: Int,
val description: String, val descRes: Int,
val iconRes: Int, val iconRes: Int,
val isFirst: Boolean val isFirst: Boolean,
val isLast: Boolean = false
) )
@@ -86,17 +86,21 @@ class PatternView @JvmOverloads constructor(
if (errorState) return false if (errorState) return false
when (event.action) { when (event.action) {
MotionEvent.ACTION_DOWN -> { MotionEvent.ACTION_DOWN -> {
parent?.requestDisallowInterceptTouchEvent(true)
recording = true; selected.clear() recording = true; selected.clear()
hit(event.x, event.y) hit(event.x, event.y)
} }
MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_MOVE -> {
parent?.requestDisallowInterceptTouchEvent(true)
touchX = event.x; touchY = event.y touchX = event.x; touchY = event.y
hit(event.x, event.y) hit(event.x, event.y)
} }
MotionEvent.ACTION_UP -> { MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
parent?.requestDisallowInterceptTouchEvent(false)
recording = false recording = false
invalidate() invalidate()
onPatternComplete?.invoke(selected.map { it.index }) if (event.action == MotionEvent.ACTION_UP)
onPatternComplete?.invoke(selected.map { it.index })
} }
} }
invalidate() invalidate()
@@ -7,7 +7,6 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.biometric.BiometricManager
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import sh.sar.basedbank.R import sh.sar.basedbank.R
@@ -21,6 +20,7 @@ class SecuritySetupFragment : Fragment() {
interface Callback { interface Callback {
fun onSecuritySetupComplete() fun onSecuritySetupComplete()
fun onSecuritySetupReset()
} }
companion object { companion object {
@@ -33,7 +33,7 @@ class SecuritySetupFragment : Fragment() {
private var _b: FragmentSecuritySetupBinding? = null private var _b: FragmentSecuritySetupBinding? = null
private val b get() = _b!! private val b get() = _b!!
private enum class Step { CHOOSE, PIN_ENTER, PIN_CONFIRM, PATTERN_ENTER, PATTERN_CONFIRM, BIOMETRIC } private enum class Step { CONFIGURED, CHOOSE, PIN_ENTER, PIN_CONFIRM, PATTERN_ENTER, PATTERN_CONFIRM }
private var step = Step.CHOOSE private var step = Step.CHOOSE
private val pinDigits = mutableListOf<Int>() private val pinDigits = mutableListOf<Int>()
@@ -48,9 +48,6 @@ class SecuritySetupFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val changeMode = arguments?.getBoolean(ARG_CHANGE_MODE, false) ?: false val changeMode = arguments?.getBoolean(ARG_CHANGE_MODE, false) ?: false
if (!changeMode && prefs.getString("security_method", null) != null) {
(activity as? Callback)?.onSecuritySetupComplete()
}
b.cardPin.setOnClickListener { goTo(Step.PIN_ENTER) } b.cardPin.setOnClickListener { goTo(Step.PIN_ENTER) }
b.cardPattern.setOnClickListener { goTo(Step.PATTERN_ENTER) } b.cardPattern.setOnClickListener { goTo(Step.PATTERN_ENTER) }
@@ -62,20 +59,21 @@ class SecuritySetupFragment : Fragment() {
} }
} }
b.btnPatternBack.setOnClickListener { goTo(Step.CHOOSE) } b.btnPatternBack.setOnClickListener { goTo(Step.CHOOSE) }
b.btnChangeLock.setOnClickListener {
prefs.edit().remove("security_method").apply()
(activity as? Callback)?.onSecuritySetupReset()
goTo(Step.CHOOSE)
}
b.patternView.onPatternComplete = { pattern -> handlePattern(pattern) } b.patternView.onPatternComplete = { pattern -> handlePattern(pattern) }
b.btnEnableBiometrics.setOnClickListener {
prefs.edit().putBoolean("biometrics_enabled", true).apply()
finishSetup()
}
b.btnSkipBiometrics.setOnClickListener {
prefs.edit().putBoolean("biometrics_enabled", false).apply()
finishSetup()
}
buildNumpad() buildNumpad()
goTo(Step.CHOOSE)
if (!changeMode && prefs.getString("security_method", null) != null) {
goTo(Step.CONFIGURED)
} else {
goTo(Step.CHOOSE)
}
} }
private fun buildNumpad() { private fun buildNumpad() {
@@ -144,7 +142,7 @@ class SecuritySetupFragment : Fragment() {
Step.PIN_CONFIRM -> { Step.PIN_CONFIRM -> {
if (entered == firstPin) { if (entered == firstPin) {
saveCredential("pin", entered) saveCredential("pin", entered)
goToBiometricOrFinish() finishSetup()
} else { } else {
b.tvPinDots.text = getString(R.string.pin_no_match) b.tvPinDots.text = getString(R.string.pin_no_match)
pinDigits.clear() pinDigits.clear()
@@ -172,7 +170,7 @@ class SecuritySetupFragment : Fragment() {
Step.PATTERN_CONFIRM -> { Step.PATTERN_CONFIRM -> {
if (pattern == firstPattern) { if (pattern == firstPattern) {
saveCredential("pattern", pattern.joinToString("")) saveCredential("pattern", pattern.joinToString(""))
goToBiometricOrFinish() finishSetup()
} else { } else {
b.patternView.showError() b.patternView.showError()
b.tvPatternStatus.text = getString(R.string.pattern_no_match) b.tvPatternStatus.text = getString(R.string.pattern_no_match)
@@ -187,29 +185,12 @@ class SecuritySetupFragment : Fragment() {
} }
} }
private fun goToBiometricOrFinish() {
// In change mode, biometrics is managed from Settings — skip this step
if (arguments?.getBoolean(ARG_CHANGE_MODE, false) == true) {
finishSetup()
return
}
val canAuth = BiometricManager.from(requireContext())
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK)
if (canAuth == BiometricManager.BIOMETRIC_SUCCESS) {
goTo(Step.BIOMETRIC)
} else {
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
.edit().putBoolean("biometrics_enabled", false).apply()
finishSetup()
}
}
private fun goTo(s: Step) { private fun goTo(s: Step) {
step = s step = s
b.viewConfigured.visibility = if (s == Step.CONFIGURED) View.VISIBLE else View.GONE
b.viewChooseMethod.visibility = if (s == Step.CHOOSE) View.VISIBLE else View.GONE b.viewChooseMethod.visibility = if (s == Step.CHOOSE) View.VISIBLE else View.GONE
b.viewPinSetup.visibility = if (s == Step.PIN_ENTER || s == Step.PIN_CONFIRM) View.VISIBLE else View.GONE b.viewPinSetup.visibility = if (s == Step.PIN_ENTER || s == Step.PIN_CONFIRM) View.VISIBLE else View.GONE
b.viewPatternSetup.visibility = if (s == Step.PATTERN_ENTER || s == Step.PATTERN_CONFIRM) View.VISIBLE else View.GONE b.viewPatternSetup.visibility = if (s == Step.PATTERN_ENTER || s == Step.PATTERN_CONFIRM) View.VISIBLE else View.GONE
b.viewBiometric.visibility = if (s == Step.BIOMETRIC) View.VISIBLE else View.GONE
when (s) { when (s) {
Step.PIN_ENTER -> { Step.PIN_ENTER -> {
@@ -236,9 +217,6 @@ class SecuritySetupFragment : Fragment() {
val hash = pbkdf2(input, salt) val hash = pbkdf2(input, salt)
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).edit() requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).edit()
.putString("security_method", method) .putString("security_method", method)
// Remove legacy plaintext fields if they exist from an old install
.remove("security_salt")
.remove("security_hash")
.apply() .apply()
CredentialStore(requireContext()).saveSecurityHash(saltB64, hash) CredentialStore(requireContext()).saveSecurityHash(saltB64, hash)
} }
@@ -256,6 +234,7 @@ class SecuritySetupFragment : Fragment() {
private fun finishSetup() { private fun finishSetup() {
val cb = activity as? Callback val cb = activity as? Callback
if (cb != null) { if (cb != null) {
goTo(Step.CONFIGURED)
cb.onSecuritySetupComplete() cb.onSecuritySetupComplete()
} else { } else {
parentFragmentManager.popBackStack() parentFragmentManager.popBackStack()
@@ -9,9 +9,10 @@ object AccountCache {
private const val PREFS = "account_cache" private const val PREFS = "account_cache"
private const val KEY_MIB = "mib_accounts" private const val KEY_MIB = "mib_accounts"
private const val KEY_BML = "bml_accounts"
private const val KEY_FAHIPAY = "fahipay_accounts" private const val KEY_FAHIPAY = "fahipay_accounts"
private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
fun save(context: Context, accounts: List<MibAccount>) { fun save(context: Context, accounts: List<MibAccount>) {
val arr = JSONArray() val arr = JSONArray()
for (acc in accounts) { for (acc in accounts) {
@@ -36,7 +37,7 @@ object AccountCache {
.edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply() .edit().putString(KEY_MIB, CacheEncryption.encrypt(arr.toString())).apply()
} }
fun saveBml(context: Context, accounts: List<MibAccount>) { fun saveBml(context: Context, loginId: String, accounts: List<MibAccount>) {
val arr = JSONArray() val arr = JSONArray()
for (acc in accounts) { for (acc in accounts) {
arr.put(JSONObject().apply { arr.put(JSONObject().apply {
@@ -56,15 +57,14 @@ object AccountCache {
}) })
} }
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_BML, CacheEncryption.encrypt(arr.toString())).apply() .edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
} }
fun loadBml(context: Context): List<MibAccount> { fun loadBml(context: Context, loginId: String): List<MibAccount> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_BML, null) ?: return emptyList() .getString(bmlKey(loginId), null) ?: return emptyList()
return try { return try {
val json = CacheEncryption.decrypt(raw) val arr = JSONArray(CacheEncryption.decrypt(raw))
val arr = JSONArray(json)
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
MibAccount( MibAccount(
@@ -84,9 +84,12 @@ object AccountCache {
internalId = o.optString("internalId", "") internalId = o.optString("internalId", "")
) )
} }
} catch (e: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
} }
fun loadBml(context: Context, loginIds: List<String>): List<MibAccount> =
loginIds.flatMap { loadBml(context, it) }
fun saveFahipay(context: Context, accounts: List<MibAccount>) { fun saveFahipay(context: Context, accounts: List<MibAccount>) {
val arr = JSONArray() val arr = JSONArray()
for (acc in accounts) { for (acc in accounts) {
@@ -11,6 +11,21 @@ object AccountInputParser {
UNKNOWN UNKNOWN
} }
/**
* Strip spaces and remove a 960/+960 country code prefix, but only when
* the stripped result is exactly 7 digits (so "9603456" is left intact).
*/
fun normalize(input: String): String {
var s = input.replace(" ", "")
val stripped = when {
s.startsWith("+960") -> s.removePrefix("+960")
s.startsWith("960") -> s.removePrefix("960")
else -> null
}
if (stripped != null && stripped.matches(Regex("^\\d{7}$"))) s = stripped
return s
}
fun detect(input: String): InputType { fun detect(input: String): InputType {
val s = input.trim() val s = input.trim()
return when { return when {
@@ -0,0 +1,28 @@
package sh.sar.basedbank.util
object BmlDashboardParser {
/**
* Returns a display-ready product label for a BML dashboard account or card.
* Known BML product names are mapped to short friendly labels.
* Everything else is title-cased (first letter of each word capitalised).
*/
fun productLabel(raw: String): String {
val u = raw.trim().uppercase()
return when {
u == "SAVINGS ACCOUNT" -> "Savings"
u == "CURRENT ACCOUNT" ||
u == "CURRENT ACCOUNT(PERSONAL)" ||
u == "CURRENT ACCOUNT(BUSINESS)" -> "Current"
u == "WADIAH RETAIL CURRENT ACCOUNT" ||
u == "WADIAH BUSINESS CURRENT ACCOUNT" -> "Islamic Current"
u == "BML ISLAMIC SAVINGS ACCOUNT" -> "Islamic Savings"
else -> toTitleCase(raw)
}
}
fun toTitleCase(input: String): String =
input.trim().lowercase().split(" ").joinToString(" ") { word ->
word.replaceFirstChar { it.uppercaseChar() }
}
}
@@ -84,7 +84,9 @@ object ContactsCache {
} }
} }
fun saveBml(context: Context, contacts: List<MibBeneficiary>) { private fun bmlKey(loginId: String) = "bml_contacts_$loginId"
fun saveBml(context: Context, loginId: String, contacts: List<MibBeneficiary>) {
val arr = JSONArray() val arr = JSONArray()
for (c in contacts) { for (c in contacts) {
arr.put(JSONObject().apply { arr.put(JSONObject().apply {
@@ -99,18 +101,18 @@ object ContactsCache {
put("benefStatus", c.benefStatus) put("benefStatus", c.benefStatus)
put("transferCyDesc", c.transferCyDesc) put("transferCyDesc", c.transferCyDesc)
put("benefCategoryId", c.benefCategoryId) put("benefCategoryId", c.benefCategoryId)
put("profileId", c.profileId)
}) })
} }
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString("bml_contacts", CacheEncryption.encrypt(arr.toString())).apply() .edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
} }
fun loadBml(context: Context): List<MibBeneficiary> { fun loadBml(context: Context, loginId: String): List<MibBeneficiary> {
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString("bml_contacts", null) ?: return emptyList() .getString(bmlKey(loginId), null) ?: return emptyList()
return try { return try {
val json = CacheEncryption.decrypt(raw) val arr = JSONArray(CacheEncryption.decrypt(raw))
val arr = JSONArray(json)
(0 until arr.length()).map { i -> (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i) val o = arr.getJSONObject(i)
MibBeneficiary( MibBeneficiary(
@@ -125,12 +127,16 @@ object ContactsCache {
benefStatus = o.optString("benefStatus"), benefStatus = o.optString("benefStatus"),
transferCyDesc = o.optString("transferCyDesc", "MVR"), transferCyDesc = o.optString("transferCyDesc", "MVR"),
customerImgHash = null, customerImgHash = null,
benefCategoryId = o.optString("benefCategoryId", "BML") benefCategoryId = o.optString("benefCategoryId", "BML"),
profileId = o.optString("profileId", "")
) )
} }
} catch (e: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
} }
fun loadBml(context: Context, loginIds: List<String>): List<MibBeneficiary> =
loginIds.flatMap { loadBml(context, it) }
fun saveFahipay(context: Context, contacts: List<MibBeneficiary>, categories: List<MibBeneficiaryCategory>) { fun saveFahipay(context: Context, contacts: List<MibBeneficiary>, categories: List<MibBeneficiaryCategory>) {
val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit() val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit()
val arr = JSONArray() val arr = JSONArray()
@@ -24,7 +24,6 @@ class CredentialStore(context: Context) {
// ── MIB login credentials ───────────────────────────────────────────────── // ── MIB login credentials ─────────────────────────────────────────────────
fun hasMibCredentials(): Boolean = prefs.contains("mib_enc_username") 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 hasFahipayCredentials(): Boolean = prefs.contains("fahipay_enc_id_card")
fun saveMibCredentials(username: String, passwordHash: String, otpSeed: String) { fun saveMibCredentials(username: String, passwordHash: String, otpSeed: String) {
@@ -91,58 +90,84 @@ class CredentialStore(context: Context) {
return try { decrypt(enc, key) } catch (_: Exception) { null } return try { decrypt(enc, key) } catch (_: Exception) { null }
} }
// ── BML login credentials ───────────────────────────────────────────────── // ── BML login credentials (multi-login, keyed by loginId = username) ────────
fun saveBmlCredentials(username: String, password: String, otpSeed: String) { fun getBmlLoginIds(): List<String> {
val json = prefs.getString("bml_login_ids", null) ?: return emptyList()
return try {
val arr = org.json.JSONArray(json)
(0 until arr.length()).map { arr.getString(it) }
} catch (_: Exception) { emptyList() }
}
fun hasBmlCredentials(): Boolean = getBmlLoginIds().isNotEmpty()
private fun addBmlLoginId(loginId: String) {
val ids = getBmlLoginIds().toMutableList()
if (loginId !in ids) {
ids.add(loginId)
prefs.edit().putString("bml_login_ids", org.json.JSONArray(ids).toString()).apply()
}
}
private fun removeBmlLoginId(loginId: String) {
val ids = getBmlLoginIds().toMutableList()
if (ids.remove(loginId))
prefs.edit().putString("bml_login_ids", org.json.JSONArray(ids).toString()).apply()
}
fun saveBmlCredentials(loginId: String, username: String, password: String, otpSeed: String) {
addBmlLoginId(loginId)
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit() prefs.edit()
.putString("bml_enc_username", encrypt(username, key)) .putString("bml_${loginId}_enc_username", encrypt(username, key))
.putString("bml_enc_password", encrypt(password, key)) .putString("bml_${loginId}_enc_password", encrypt(password, key))
.putString("bml_enc_otp_seed", encrypt(otpSeed, key)) .putString("bml_${loginId}_enc_otp_seed", encrypt(otpSeed, key))
.apply() .apply()
} }
fun loadBmlCredentials(): BmlCredentials? { fun loadBmlCredentials(loginId: String): BmlCredentials? {
val key = getOrCreateKey() val key = getOrCreateKey()
val encUsername = prefs.getString("bml_enc_username", null) ?: return null val encUsername = prefs.getString("bml_${loginId}_enc_username", null) ?: return null
val encPassword = prefs.getString("bml_enc_password", null) ?: return null val encPassword = prefs.getString("bml_${loginId}_enc_password", null) ?: return null
val encSeed = prefs.getString("bml_enc_otp_seed", null) ?: return null val encSeed = prefs.getString("bml_${loginId}_enc_otp_seed", null) ?: return null
return try { return try {
BmlCredentials(decrypt(encUsername, key), decrypt(encPassword, key), decrypt(encSeed, key)) BmlCredentials(decrypt(encUsername, key), decrypt(encPassword, key), decrypt(encSeed, key))
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
fun clearBmlCredentials() { fun clearBmlCredentials(loginId: String) {
removeBmlLoginId(loginId)
prefs.edit() prefs.edit()
.remove("bml_enc_username") .remove("bml_${loginId}_enc_username")
.remove("bml_enc_password") .remove("bml_${loginId}_enc_password")
.remove("bml_enc_otp_seed") .remove("bml_${loginId}_enc_otp_seed")
.apply() .apply()
} }
// ── BML session token ───────────────────────────────────────────────────── // ── BML session token (per loginId) ───────────────────────────────────────
fun saveBmlSession(accessToken: String, deviceId: String) { fun saveBmlSession(loginId: String, accessToken: String, deviceId: String) {
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit() prefs.edit()
.putString("bml_enc_token", encrypt(accessToken, key)) .putString("bml_${loginId}_enc_token", encrypt(accessToken, key))
.putString("bml_enc_device_id", encrypt(deviceId, key)) .putString("bml_${loginId}_enc_device_id", encrypt(deviceId, key))
.apply() .apply()
} }
fun loadBmlSession(): Pair<String, String>? { fun loadBmlSession(loginId: String): Pair<String, String>? {
val key = getOrCreateKey() val key = getOrCreateKey()
val encToken = prefs.getString("bml_enc_token", null) ?: return null val encToken = prefs.getString("bml_${loginId}_enc_token", null) ?: return null
val encDeviceId = prefs.getString("bml_enc_device_id", null) ?: return null val encDeviceId = prefs.getString("bml_${loginId}_enc_device_id", null) ?: return null
return try { return try {
Pair(decrypt(encToken, key), decrypt(encDeviceId, key)) Pair(decrypt(encToken, key), decrypt(encDeviceId, key))
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
fun clearBmlSession() { fun clearBmlSession(loginId: String) {
prefs.edit() prefs.edit()
.remove("bml_enc_token") .remove("bml_${loginId}_enc_token")
.remove("bml_enc_device_id") .remove("bml_${loginId}_enc_device_id")
.apply() .apply()
} }
@@ -315,16 +340,6 @@ class CredentialStore(context: Context) {
return try { decrypt(enc, key) } catch (_: Exception) { null } return try { decrypt(enc, key) } catch (_: Exception) { null }
} }
fun saveBmlFullName(name: String) {
val key = getOrCreateKey()
prefs.edit().putString("bml_enc_full_name", encrypt(name, key)).apply()
}
fun loadBmlFullName(): String? {
val key = getOrCreateKey()
val enc = prefs.getString("bml_enc_full_name", null) ?: return null
return try { decrypt(enc, key) } catch (_: Exception) { null }
}
fun saveMibUserProfile(p: MibUserProfile) { fun saveMibUserProfile(p: MibUserProfile) {
val json = JSONObject().apply { val json = JSONObject().apply {
@@ -355,7 +370,7 @@ class CredentialStore(context: Context) {
} catch (_: Exception) { null } } catch (_: Exception) { null }
} }
fun saveBmlUserProfile(p: BmlUserProfile) { fun saveBmlUserProfile(loginId: String, p: BmlUserProfile) {
val json = JSONObject().apply { val json = JSONObject().apply {
put("fullName", p.fullName) put("fullName", p.fullName)
put("email", p.email) put("email", p.email)
@@ -365,13 +380,12 @@ class CredentialStore(context: Context) {
put("birthdate", p.birthdate) put("birthdate", p.birthdate)
}.toString() }.toString()
val key = getOrCreateKey() val key = getOrCreateKey()
prefs.edit().putString("bml_enc_profile", encrypt(json, key)).apply() prefs.edit().putString("bml_${loginId}_enc_profile", encrypt(json, key)).apply()
prefs.edit().putString("bml_enc_full_name", encrypt(p.fullName, key)).apply()
} }
fun loadBmlUserProfile(): BmlUserProfile? { fun loadBmlUserProfile(loginId: String): BmlUserProfile? {
val key = getOrCreateKey() val key = getOrCreateKey()
val enc = prefs.getString("bml_enc_profile", null) ?: return null val enc = prefs.getString("bml_${loginId}_enc_profile", null) ?: return null
return try { return try {
val o = JSONObject(decrypt(enc, key)) val o = JSONObject(decrypt(enc, key))
BmlUserProfile( BmlUserProfile(
@@ -0,0 +1,18 @@
package sh.sar.basedbank.util
object MibAccountParser {
/**
* Returns a display-ready product label for a MIB (Faisanet) account type name.
* Known MIB accountTypeName values are mapped to short friendly labels.
* Everything else is returned trimmed as-is.
*/
fun productLabel(raw: String): String {
val u = raw.trim().uppercase()
return when {
u == "SAVING ACCOUNT" -> "Savings"
u == "CURRENT ACCOUNT" -> "Current"
else -> raw.trim()
}
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#016FD0"
android:pathData="M16.015 14.378c0-.32-.135-.496-.344-.622-.21-.12-.464-.135-.81-.135h-1.543v2.82h.675v-1.027h.72c.24 0 .39.024.478.125.12.13.104.38.104.55v.35h.66v-.555c-.002-.25-.017-.376-.108-.516-.06-.08-.18-.18-.33-.234l.02-.008c.18-.072.48-.297.48-.747zm-.87.407l-.028-.002c-.09.053-.195.058-.33.058h-.81v-.63h.824c.12 0 .24 0 .33.05.098.048.156.147.15.255 0 .12-.045.215-.134.27zM20.297 15.837H19v.6h1.304c.676 0 1.05-.278 1.05-.884 0-.28-.066-.448-.187-.582-.153-.133-.392-.193-.73-.207l-.376-.015c-.104 0-.18 0-.255-.03-.09-.03-.15-.105-.15-.21 0-.09.017-.166.09-.21.083-.046.177-.066.272-.06h1.23v-.602h-1.35c-.704 0-.958.437-.958.84 0 .9.776.855 1.407.87.104 0 .18.015.225.06.046.03.082.106.082.18 0 .077-.035.15-.08.18-.06.053-.15.07-.277.07zM0 0v10.096L.81 8.22h1.75l.225.464V8.22h2.043l.45 1.02.437-1.013h6.502c.295 0 .56.057.756.236v-.23h1.787v.23c.307-.17.686-.23 1.12-.23h2.606l.24.466v-.466h1.918l.254.465v-.466h1.858v3.948H20.87l-.36-.6v.585h-2.353l-.256-.63h-.583l-.27.614h-1.213c-.48 0-.84-.104-1.08-.24v.24h-2.89v-.884c0-.12-.03-.12-.105-.135h-.105v1.036H6.067v-.48l-.21.48H4.69l-.202-.48v.465H2.235l-.256-.624H1.4l-.256.624H0V24h23.786v-7.108c-.27.135-.613.18-.973.18H21.09v-.255c-.21.165-.57.255-.914.255H14.71v-.9c0-.12-.018-.12-.12-.12h-.075v1.022h-1.8v-1.066c-.298.136-.643.15-.928.136h-.214v.915h-2.18l-.54-.617-.57.6H4.742v-3.93h3.61l.518.602.554-.6h2.412c.28 0 .74.03.942.225v-.24h2.177c.202 0 .644.045.903.225v-.24h3.265v.24c.163-.164.508-.24.803-.24h1.89v.24c.194-.15.464-.24.84-.24h1.176V0H0zM21.156 14.955c.004.005.006.012.01.016.01.01.024.01.032.02l-.042-.035zM23.828 13.082h.065v.555h-.065zM23.865 15.03v-.005c-.03-.025-.046-.048-.075-.07-.15-.153-.39-.215-.764-.225l-.36-.012c-.12 0-.194-.007-.27-.03-.09-.03-.15-.105-.15-.21 0-.09.03-.16.09-.204.076-.045.15-.05.27-.05h1.223v-.588h-1.283c-.69 0-.96.437-.96.84 0 .9.78.855 1.41.87.104 0 .18.015.224.06.046.03.076.106.076.18 0 .07-.034.138-.09.18-.045.056-.136.07-.27.07h-1.288v.605h1.287c.42 0 .734-.118.9-.36h.03c.09-.134.135-.3.135-.523 0-.24-.045-.39-.135-.526zM18.597 14.208v-.583h-2.235V16.458h2.235v-.585h-1.57v-.57h1.533v-.584h-1.532v-.51M13.51 8.787h.685V11.6h-.684zM13.126 9.543l-.007.006c0-.314-.13-.5-.34-.624-.217-.125-.47-.135-.81-.135H10.43v2.82h.674v-1.034h.72c.24 0 .39.03.487.12.122.136.107.378.107.548v.354h.677v-.553c0-.25-.016-.375-.11-.516-.09-.107-.202-.19-.33-.237.172-.07.472-.3.472-.75zm-.855.396h-.015c-.09.054-.195.056-.33.056H11.1v-.623h.825c.12 0 .24.004.33.05.09.04.15.128.15.25s-.047.22-.134.266zM15.92 9.373h.632v-.6h-.644c-.464 0-.804.105-1.02.33-.286.3-.362.69-.362 1.11 0 .512.123.833.36 1.074.232.238.645.31.97.31h.78l.255-.627h1.39l.262.627h1.36v-2.11l1.272 2.11h.95l.002.002V8.786h-.684v1.963l-1.18-1.96h-1.02V11.4L18.11 8.744h-1.004l-.943 2.22h-.3c-.177 0-.362-.03-.468-.134-.125-.15-.186-.36-.186-.662 0-.285.08-.51.194-.63.133-.135.272-.165.516-.165zm1.668-.108l.464 1.118v.002h-.93l.466-1.12zM2.38 10.97l.254.628H4V9.393l.972 2.205h.584l.973-2.202.015 2.202h.69v-2.81H6.118l-.807 1.904-.876-1.905H3.343v2.663L2.205 8.787h-.997L.01 11.597h.72l.26-.626h1.39zm-.688-1.705l.46 1.118-.003.002h-.915l.457-1.12zM11.856 13.62H9.714l-.85.923-.825-.922H5.346v2.82H8l.855-.932.824.93h1.302v-.94h.838c.6 0 1.17-.164 1.17-.945l-.006-.003c0-.78-.598-.93-1.128-.93zM7.67 15.853l-.014-.002H6.02v-.557h1.47v-.574H6.02v-.51H7.7l.733.82-.764.824zm2.642.33l-1.03-1.147 1.03-1.108v2.253zm1.553-1.258h-.885v-.717h.885c.24 0 .42.098.42.344 0 .243-.15.372-.42.372zM9.967 9.373v-.586H7.73V11.6h2.237v-.58H8.4v-.564h1.527V9.88H8.4v-.507" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zm-2,15l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF5F00"
android:pathData="M11.343 18.031c.058.049.12.098.181.146-1.177.783-2.59 1.238-4.107 1.238C3.32 19.416 0 16.096 0 12c0-4.095 3.32-7.416 7.416-7.416 1.518 0 2.931.456 4.105 1.238-.06.051-.12.098-.165.15C9.6 7.489 8.595 9.688 8.595 12c0 2.311 1.001 4.51 2.748 6.031zm5.241-13.447c-1.52 0-2.931.456-4.105 1.238.06.051.12.098.165.15C14.4 7.489 15.405 9.688 15.405 12c0 2.31-1.001 4.507-2.748 6.031-.058.049-.12.098-.181.146 1.177.783 2.588 1.238 4.107 1.238C20.68 19.416 24 16.096 24 12c0-4.094-3.32-7.416-7.416-7.416zM12 6.174c-.096.075-.189.15-.28.231C10.156 7.764 9.169 9.765 9.169 12c0 2.236.987 4.236 2.551 5.595.09.08.185.158.28.232.096-.074.189-.152.28-.232 1.563-1.359 2.551-3.359 2.551-5.595 0-2.235-.987-4.236-2.551-5.595-.09-.08-.184-.156-.28-.231z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#1A1F71"
android:pathData="M9.112 8.262L5.97 15.758H3.92L2.374 9.775c-0.094,-0.368,-0.175,-0.503,-0.461,-0.658C1.447 8.864 0.677 8.627 0 8.479l0.046,-0.217h3.3a0.904,0.904,0,0,1,0.894,0.764l0.817 4.338 2.018,-5.102zM17.145 13.311c0.008,-1.979,-2.736,-2.088,-2.717,-2.972 0.006,-0.269 0.262,-0.555 0.822,-0.628a3.66,3.66,0,0,1,1.913,0.336l0.34,-1.59a5.207,5.207,0,0,0,-1.814,-0.333c-1.917 0,-3.266 1.02,-3.278 2.479,-0.012 1.079 0.963 1.68 1.698 2.04 0.756 0.367 1.01 0.603 1.006 0.931,-0.005 0.504,-0.602 0.725,-1.16 0.734,-0.975 0.015,-1.54,-0.263,-1.992,-0.473l-0.351 1.642c0.453 0.208 1.289 0.39 2.156 0.398 2.037 0 3.37,-1.006 3.377,-2.564zM22.206 15.758H24l-1.565,-7.496h-1.656a0.883,0.883,0,0,0,-0.826,0.55l-2.909 6.946h2.036l0.405,-1.12h2.488zM20.043 13.102l1.02,-2.815 0.588 2.815zM11.883 8.262l-1.603 7.496H8.34l1.605,-7.496z" />
</vector>
+8 -3
View File
@@ -4,17 +4,20 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/drawerLayout" android:id="@+id/drawerLayout"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent"
android:fitsSystemWindows="true">
<!-- Main content --> <!-- Main content -->
<androidx.coordinatorlayout.widget.CoordinatorLayout <androidx.coordinatorlayout.widget.CoordinatorLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/colorSurface"> android:background="?attr/colorSurface"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.AppBarLayout <com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.MaterialToolbar <com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar" android:id="@+id/toolbar"
@@ -49,6 +52,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:visibility="gone" android:visibility="gone"
android:fitsSystemWindows="true"
app:menu="@menu/bottom_nav_menu" /> app:menu="@menu/bottom_nav_menu" />
</LinearLayout> </LinearLayout>
@@ -61,6 +65,7 @@
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_gravity="start" android:layout_gravity="start"
android:fitsSystemWindows="true"
app:menu="@menu/drawer_menu" /> app:menu="@menu/drawer_menu" />
</androidx.drawerlayout.widget.DrawerLayout> </androidx.drawerlayout.widget.DrawerLayout>
+2 -1
View File
@@ -4,7 +4,8 @@
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="?attr/colorSurface"> android:background="?attr/colorSurface"
android:fitsSystemWindows="true">
<androidx.fragment.app.FragmentContainerView <androidx.fragment.app.FragmentContainerView
android:id="@+id/navHostFragment" android:id="@+id/navHostFragment"
+37 -17
View File
@@ -27,31 +27,49 @@
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"> app:layout_constraintEnd_toEndOf="parent">
<com.google.android.material.chip.ChipGroup <LinearLayout
android:id="@+id/languageChipGroup" android:id="@+id/languageSection"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="center_horizontal" android:orientation="vertical"
android:layout_marginBottom="16dp" android:layout_marginBottom="16dp"
android:visibility="gone" android:visibility="gone">
app:singleSelection="true"
app:selectionRequired="false">
<com.google.android.material.chip.Chip <TextView
android:id="@+id/chipEnglish"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="English" android:layout_gravity="center_horizontal"
style="@style/Widget.Material3.Chip.Filter" /> android:text="@string/select_language"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface"
android:layout_marginBottom="8dp" />
<com.google.android.material.chip.Chip <com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/chipDhivehi" android:id="@+id/languageToggle"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="ދިވެހި" app:singleSelection="true"
style="@style/Widget.Material3.Chip.Filter" /> app:selectionRequired="true">
</com.google.android.material.chip.ChipGroup> <com.google.android.material.button.MaterialButton
android:id="@+id/btnLangEnglish"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="English" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnLangDhivehi"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="ދިވެހި" />
</com.google.android.material.button.MaterialButtonToggleGroup>
</LinearLayout>
<com.google.android.material.tabs.TabLayout <com.google.android.material.tabs.TabLayout
android:id="@+id/dotsIndicator" android:id="@+id/dotsIndicator"
@@ -61,6 +79,8 @@
android:background="@null" android:background="@null"
android:layout_gravity="center_horizontal" android:layout_gravity="center_horizontal"
android:layout_marginBottom="24dp" android:layout_marginBottom="24dp"
android:clickable="false"
android:focusable="false"
app:tabBackground="@drawable/tab_indicator_selector" app:tabBackground="@drawable/tab_indicator_selector"
app:tabGravity="center" app:tabGravity="center"
app:tabIndicatorHeight="0dp" app:tabIndicatorHeight="0dp"
@@ -10,6 +10,7 @@
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<LinearLayout <LinearLayout
android:id="@+id/btnContainer"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal" android:layout_gravity="bottom|center_horizontal"
@@ -80,7 +80,6 @@
android:hint="@string/otp_seed" android:hint="@string/otp_seed"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
app:endIconMode="password_toggle" app:endIconMode="password_toggle"
app:helperText="@string/otp_seed_hint"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"> style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/etOtpSeed" android:id="@+id/etOtpSeed"
@@ -116,7 +115,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="8dp"
android:layout_marginBottom="8dp" android:layout_marginBottom="8dp"
android:visibility="gone" android:visibility="invisible"
app:cardBackgroundColor="?attr/colorSecondaryContainer" app:cardBackgroundColor="?attr/colorSecondaryContainer"
app:cardCornerRadius="12dp" app:cardCornerRadius="12dp"
app:cardElevation="0dp"> app:cardElevation="0dp">
@@ -0,0 +1,218 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp"
android:paddingTop="40dp">
<!-- Appearance -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_appearance"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_navigation"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface"
android:layout_marginBottom="8dp" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/navModeToggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnNavDrawer"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_nav_drawer" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnNavBottom"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_nav_bottom" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/theme"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface"
android:layout_marginBottom="8dp" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/themeToggle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:singleSelection="true"
app:selectionRequired="true">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnThemeSystem"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/theme_system" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnThemeLight"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/theme_light" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnThemeDark"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/theme_dark" />
</com.google.android.material.button.MaterialButtonToggleGroup>
<!-- Privacy & Security -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_privacy_security"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="24dp"
android:layout_marginBottom="16dp" />
<!-- Biometrics -->
<LinearLayout
android:id="@+id/rowBiometrics"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_biometrics"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="4dp" />
<TextView
android:id="@+id/tvBiometricsHint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_biometrics_unavailable"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp"
android:visibility="gone" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_biometrics_unlock"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchBiometrics"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="8dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_biometrics_transfer"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchBiometricsTransfer"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
</LinearLayout>
<!-- Block screenshots -->
<LinearLayout
android:id="@+id/rowBlockScreenshots"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_block_screenshots"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_block_screenshots_desc"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchBlockScreenshots"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp" />
</LinearLayout>
</LinearLayout>
</ScrollView>
@@ -1,94 +1,96 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ScrollView <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:fillViewport="true"> android:orientation="vertical"
android:gravity="center_horizontal"
android:paddingHorizontal="32dp"
android:paddingTop="64dp"
android:paddingBottom="16dp">
<LinearLayout <ImageView
android:id="@+id/icon"
android:layout_width="120dp"
android:layout_height="120dp"
android:layout_marginBottom="40dp"
android:contentDescription="@string/app_name" />
<TextView
android:id="@+id/title"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:textAppearance="?attr/textAppearanceHeadlineMedium"
android:gravity="center_horizontal" android:textColor="?attr/colorOnSurface"
android:paddingHorizontal="32dp" android:gravity="center"
android:paddingTop="64dp" android:layout_marginBottom="16dp" />
android:paddingBottom="16dp">
<ImageView <ScrollView
android:id="@+id/icon" android:id="@+id/scrollView"
android:layout_width="120dp" android:layout_width="match_parent"
android:layout_height="120dp" android:layout_height="0dp"
android:layout_marginBottom="40dp" android:layout_weight="1"
android:contentDescription="@string/app_name" /> android:fillViewport="true">
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceHeadlineMedium"
android:textColor="?attr/colorOnSurface"
android:gravity="center"
android:layout_marginBottom="16dp" />
<TextView
android:id="@+id/description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurfaceVariant"
android:gravity="center"
android:lineSpacingMultiplier="1.4" />
<!-- Bank logo cards shown only on first slide -->
<LinearLayout <LinearLayout
android:id="@+id/placeholderCards"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="vertical">
android:layout_marginTop="40dp"
android:weightSum="2"
android:visibility="gone">
<com.google.android.material.card.MaterialCardView <TextView
android:layout_width="0dp" android:id="@+id/description"
android:layout_height="80dp" android:layout_width="match_parent"
android:layout_weight="1" android:layout_height="wrap_content"
android:layout_marginEnd="8dp" android:textAppearance="?attr/textAppearanceBodyLarge"
app:cardCornerRadius="12dp" android:textColor="?attr/colorOnSurfaceVariant"
app:strokeWidth="1dp" android:gravity="center"
app:strokeColor="?attr/colorOutline"> android:lineSpacingMultiplier="1.4" />
<!-- Supported services shown only on first slide -->
<LinearLayout
android:id="@+id/placeholderCards"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="40dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:text="@string/onboarding_supported_services"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="16dp" />
<ImageView <ImageView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="40dp"
android:src="@drawable/mib_faisanet_logo" android:src="@drawable/mib_faisanet_logo"
android:scaleType="centerInside" android:scaleType="centerInside"
android:padding="12dp"
android:contentDescription="@string/mib_name" /> android:contentDescription="@string/mib_name" />
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:layout_width="0dp"
android:layout_height="80dp"
android:layout_weight="1"
android:layout_marginStart="8dp"
app:cardCornerRadius="12dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
<ImageView <ImageView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="40dp"
android:layout_marginTop="24dp"
android:src="@drawable/bml_logo_vector" android:src="@drawable/bml_logo_vector"
android:scaleType="centerInside" android:scaleType="centerInside"
android:padding="8dp"
android:contentDescription="@string/bml_name" /> android:contentDescription="@string/bml_name" />
</com.google.android.material.card.MaterialCardView> <ImageView
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginTop="24dp"
android:src="@drawable/fahipay_logo_long"
android:scaleType="centerInside"
android:contentDescription="@string/fahipay_name" />
</LinearLayout>
</LinearLayout> </LinearLayout>
</ScrollView>
</LinearLayout> </LinearLayout>
</ScrollView>
@@ -4,7 +4,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:background="#FFFFFF"> android:background="?attr/colorSurface">
<!-- ══════════════════════════════════════════════════════════════════════ --> <!-- ══════════════════════════════════════════════════════════════════════ -->
<!-- Renderable receipt card --> <!-- Renderable receipt card -->
@@ -206,12 +206,6 @@
</LinearLayout> </LinearLayout>
<!-- Pushes buttons to bottom of screen -->
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<!-- ══════════════════════════════════════════════════════════════════════ --> <!-- ══════════════════════════════════════════════════════════════════════ -->
<!-- Action buttons — outside renderable area --> <!-- Action buttons — outside renderable area -->
<!-- ══════════════════════════════════════════════════════════════════════ --> <!-- ══════════════════════════════════════════════════════════════════════ -->
@@ -219,7 +213,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:background="#FFFFFF" android:background="?attr/colorSurface"
android:paddingHorizontal="12dp" android:paddingHorizontal="12dp"
android:paddingTop="8dp" android:paddingTop="8dp"
android:paddingBottom="12dp"> android:paddingBottom="12dp">
@@ -19,11 +19,12 @@
android:paddingBottom="24dp" android:paddingBottom="24dp"
android:gravity="center_horizontal"> android:gravity="center_horizontal">
<TextView <ImageView
android:layout_width="wrap_content" android:layout_width="80dp"
android:layout_height="wrap_content" android:layout_height="80dp"
android:text="🔒" android:src="@drawable/ic_lock"
android:textSize="56sp" android:tint="?attr/colorPrimary"
android:contentDescription="@null"
android:layout_marginBottom="16dp" /> android:layout_marginBottom="16dp" />
<TextView <TextView
@@ -255,73 +256,49 @@
</LinearLayout> </LinearLayout>
<!-- Step: Biometric prompt -->
<!-- Step: Already configured -->
<LinearLayout <LinearLayout
android:id="@+id/viewBiometric" android:id="@+id/viewConfigured"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:gravity="center"
android:paddingHorizontal="24dp" android:paddingHorizontal="24dp"
android:paddingTop="40dp"
android:paddingBottom="24dp"
android:gravity="center_horizontal"
android:visibility="gone"> android:visibility="gone">
<ImageView
android:layout_width="80dp"
android:layout_height="80dp"
android:src="@drawable/ic_check_circle"
android:tint="?attr/colorPrimary"
android:contentDescription="@null"
android:layout_marginBottom="24dp" />
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="🔐" android:text="@string/security_already_configured"
android:textSize="72sp"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/biometric_title"
android:textAppearance="?attr/textAppearanceHeadlineSmall" android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:textColor="?attr/colorOnSurface" android:textColor="?attr/colorOnSurface"
android:gravity="center" android:gravity="center"
android:layout_marginBottom="12dp" /> android:layout_marginBottom="8dp" />
<TextView <TextView
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/biometric_desc" android:text="@string/security_already_configured_desc"
android:textAppearance="?attr/textAppearanceBodyMedium" android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant" android:textColor="?attr/colorOnSurfaceVariant"
android:gravity="center" android:gravity="center"
android:layout_marginBottom="16dp" /> android:layout_marginBottom="32dp" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="32dp"
app:cardBackgroundColor="?attr/colorSecondaryContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="14dp"
android:text="@string/biometric_security_note"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSecondaryContainer"
android:gravity="center" />
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnEnableBiometrics" android:id="@+id/btnChangeLock"
android:layout_width="match_parent" style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_height="56dp"
android:text="@string/enable_biometrics"
android:layout_marginBottom="8dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSkipBiometrics"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/skip_biometrics" /> android:text="@string/settings_change_lock" />
</LinearLayout> </LinearLayout>
@@ -31,8 +31,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"
android:layout_marginTop="16dp" android:layout_marginTop="16dp">
android:visibility="gone">
<TextView <TextView
android:layout_width="wrap_content" android:layout_width="wrap_content"
@@ -40,7 +39,17 @@
android:text="@string/settings_biometrics" android:text="@string/settings_biometrics"
android:textAppearance="?attr/textAppearanceTitleSmall" android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurfaceVariant" android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp" /> android:layout_marginBottom="4dp" />
<TextView
android:id="@+id/tvBiometricsHint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_biometrics_unavailable"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp"
android:visibility="gone" />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
+13 -41
View File
@@ -1,20 +1,16 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="12dp" android:orientation="vertical">
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:padding="20dp" android:paddingHorizontal="16dp"
android:paddingVertical="14dp"
android:gravity="center_vertical" android:gravity="center_vertical"
android:foreground="?attr/selectableItemBackground"> android:foreground="?attr/selectableItemBackground">
@@ -43,7 +39,7 @@
</LinearLayout> </LinearLayout>
<!-- Right: segmented pill (bank | type | profile) + balance --> <!-- Right: segmented pill (bank | type) + balance -->
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -58,41 +54,11 @@
android:gravity="center_vertical" android:gravity="center_vertical"
android:background="@drawable/pill_segment_bg"> android:background="@drawable/pill_segment_bg">
<TextView
android:id="@+id/tvPillBank"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="12dp"
android:paddingEnd="10dp"
android:paddingVertical="6dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurface" />
<View
android:layout_width="1dp"
android:layout_height="16dp"
android:background="?attr/colorOutline" />
<TextView <TextView
android:id="@+id/tvPillType" android:id="@+id/tvPillType"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingHorizontal="10dp" android:paddingHorizontal="12dp"
android:paddingVertical="6dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurface" />
<View
android:layout_width="1dp"
android:layout_height="16dp"
android:background="?attr/colorOutline" />
<TextView
android:id="@+id/tvPillProfile"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="10dp"
android:paddingEnd="12dp"
android:paddingVertical="6dp" android:paddingVertical="6dp"
android:textAppearance="?attr/textAppearanceLabelSmall" android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurface" /> android:textColor="?attr/colorOnSurface" />
@@ -111,4 +77,10 @@
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> <View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginHorizontal="16dp"
android:background="?attr/colorOutlineVariant" />
</LinearLayout>
@@ -92,6 +92,7 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="16dp"> android:layout_marginTop="16dp">
<LinearLayout <LinearLayout
@@ -165,6 +166,20 @@
</LinearLayout> </LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnHeaderTransfer"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:insetTop="0dp"
android:insetBottom="0dp"
android:minWidth="0dp"
android:minHeight="0dp"
app:icon="@drawable/ic_send"
app:iconSize="20dp"
app:iconGravity="textStart"
app:iconPadding="0dp" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
+26 -20
View File
@@ -1,35 +1,27 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView <LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="12dp" android:orientation="vertical">
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:padding="20dp" android:paddingHorizontal="16dp"
android:paddingVertical="14dp"
android:gravity="center_vertical" android:gravity="center_vertical"
android:foreground="?attr/selectableItemBackground"> android:foreground="?attr/selectableItemBackground">
<!-- Brand chip --> <!-- Brand logo -->
<TextView <ImageView
android:id="@+id/tvCardBrand" android:id="@+id/ivCardBrand"
android:layout_width="wrap_content" android:layout_width="48dp"
android:layout_height="wrap_content" android:layout_height="32dp"
android:paddingHorizontal="10dp"
android:paddingVertical="6dp"
android:layout_marginEnd="16dp" android:layout_marginEnd="16dp"
android:textAppearance="?attr/textAppearanceLabelSmall" android:scaleType="fitCenter"
android:textColor="@android:color/white" android:contentDescription="Card brand" />
android:fontFamily="monospace"
android:background="@drawable/chip_background" />
<!-- Card name + number --> <!-- Card name + number -->
<LinearLayout <LinearLayout
@@ -54,6 +46,14 @@
android:layout_marginTop="2dp" android:layout_marginTop="2dp"
android:fontFamily="monospace" /> android:fontFamily="monospace" />
<TextView
android:id="@+id/tvCardProduct"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="2dp" />
</LinearLayout> </LinearLayout>
<!-- Status pill + balance (prepaid only) --> <!-- Status pill + balance (prepaid only) -->
@@ -89,4 +89,10 @@
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> <View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginHorizontal="16dp"
android:background="?attr/colorOutlineVariant" />
</LinearLayout>
+3 -1
View File
@@ -3,12 +3,14 @@
<string name="app_name">BasedBank</string> <string name="app_name">BasedBank</string>
<!-- Onboarding --> <!-- Onboarding -->
<string name="onboarding_supported_services">ހިދުމަތްތައް</string>
<string name="select_language">ބަސް ހިޔާލު ކުރޭ</string>
<string name="onboarding_title_1">ތިޔަ ބޭންކްތައް، އެއް އެޕެއްގައި</string> <string name="onboarding_title_1">ތިޔަ ބޭންކްތައް، އެއް އެޕެއްގައި</string>
<string name="onboarding_desc_1">BasedBank ގެ ސަބަބުން ތިޔަ ދިވެހި ބޭންކު އެކައުންޓްތައް، ހަމައެއް ތަނަކުން ބެލޭ. ބެލެންސް ބެލޭ، ތަފާތު ތަންތަން ބެލޭ — ތަފާތު އެޕްތަކަށް ބަދަލު ނުވެ.</string> <string name="onboarding_desc_1">BasedBank ގެ ސަބަބުން ތިޔަ ދިވެހި ބޭންކު އެކައުންޓްތައް، ހަމައެއް ތަނަކުން ބެލޭ. ބެލެންސް ބެލޭ، ތަފާތު ތަންތަން ބެލޭ — ތަފާތު އެޕްތަކަށް ބަދަލު ނުވެ.</string>
<string name="onboarding_title_2">އިތުރު ބޭންކްތައް ހިމެނެނީ</string> <string name="onboarding_title_2">އިތުރު ބޭންކްތައް ހިމެނެނީ</string>
<string name="onboarding_desc_2">އިތުރު ބޭންކްތަކަށް ސަޕޯޓް ލިބޭ ގޮތަށް ތައްޔާރުވަމުން ދަނީ. ދިވެހިރާއްޖޭގެ ބޭންކްތަކަށް ސަޕޯޓް ފަހި ވަމުން ދިޔަ ވަރަކަށް ހިމަނެމުން ދޭ.</string> <string name="onboarding_desc_2">އިތުރު ބޭންކްތަކަށް ސަޕޯޓް ލިބޭ ގޮތަށް ތައްޔާރުވަމުން ދަނީ. ދިވެހިރާއްޖޭގެ ބޭންކްތަކަށް ސަޕޯޓް ފަހި ވަމުން ދިޔަ ވަރަކަށް ހިމަނެމުން ދޭ.</string>
<string name="onboarding_title_3">ފެށޭ ގޮތަށް ތައްޔާރު</string> <string name="onboarding_title_3">ފެށޭ ގޮތަށް ތައްޔާރު</string>
<string name="onboarding_desc_3">ތިޔަ ބޭންކު ކްރެޑެންޝަލް ޖެހި، ތިޔަ އެކައުންޓްތައް ބަލާ. ތިޔަ ޑޭޓާ ހިފެހެއްޓޭ ތަނަކީ ހަމައެކަނި ތިޔަ ފޯނު.</string> <string name="onboarding_desc_3">ތިޔަ ބޭންކު ކްރެޑެންޝަލް ޖެހި، ތިޔަ އެކައުންޓްތައް ބަލާ. ތިޔަ ޑޭޓާ ހިފެހެއްޓޭ ތަނަކީ ހަމައެކަނި ތިޔަ ފޯނު.\n\nDhiraagu އާއި Ooredoo ގެ API ބޭނުންކޮށްގެން ފޯން ނަންބަރުގެ ތަފްސީލު ބެލޭ.\n\nމި އެޕް ތިޔައާ ބެހޭ ތަފްސީލެއް ނެހެދޭ، ޑިވެލޮޕަރަށް ވެސް ނުފޮނުވާ. ހުރިހާ ޑޭޓާ ހިފެހެއްޓޭ ތަނަކީ ހަމައެކަނި ތިޔަ ފޯނު.</string>
<string name="coming_soon">ފަހުން ލިބޭ</string> <string name="coming_soon">ފަހުން ލިބޭ</string>
<string name="next">ދެން</string> <string name="next">ދެން</string>
<string name="get_started">ފަށާ</string> <string name="get_started">ފަށާ</string>
+8 -2
View File
@@ -2,12 +2,14 @@
<string name="app_name">BasedBank</string> <string name="app_name">BasedBank</string>
<!-- Onboarding --> <!-- Onboarding -->
<string name="onboarding_supported_services">Supported services</string>
<string name="select_language">Select Language</string>
<string name="onboarding_title_1">Your Banks, One App</string> <string name="onboarding_title_1">Your Banks, One App</string>
<string name="onboarding_desc_1">BasedBank brings all your Maldivian bank accounts together in one place. Check balances, view accounts, and more — without switching between apps.</string> <string name="onboarding_desc_1">BasedBank brings all your Maldivian bank accounts together in one place. Check balances, view accounts, and more — without switching between apps.</string>
<string name="onboarding_title_2">More Banks Coming</string> <string name="onboarding_title_2">More Banks Coming</string>
<string name="onboarding_desc_2">Support for additional banks is on the way. Stay tuned as we expand coverage across the Maldives.</string> <string name="onboarding_desc_2">Support for additional banks is on the way. Stay tuned as we expand coverage across the Maldives.</string>
<string name="onboarding_title_3">Get Started</string> <string name="onboarding_title_3">Before You Begin</string>
<string name="onboarding_desc_3">Add your bank credentials and start viewing your accounts. Your data stays on your device.</string> <string name="onboarding_desc_3">BasedBank is an independent, third-party app. It is not affiliated with, endorsed by, or officially supported by any bank or financial institution.\n\nThis app works by logging into your internet banking services directly using your credentials and communicating with bank APIs. It is built using techniques derived from reverse-engineered official bank apps. Behaviour may change or break without notice if banks update their systems.\n\nDhiraagu and Ooredoo APIs are used to look up details about phone numbers.\n\nThis app does not collect any analytics, telemetry, or personal data, and does not transmit any information about you or your usage to the developer. Everything stays entirely on your device.\n\nBy tapping Get Started, you acknowledge and accept that:\n\n• Errors, failures, or service interruptions may occur at any time\n• Your bank may detect third-party access and apply restrictions or take other actions against your account\n• The developer of this app is not liable for any loss, damage, or consequences arising from your use of this app\n• You use this app entirely at your own risk</string>
<string name="coming_soon">Coming Soon</string> <string name="coming_soon">Coming Soon</string>
<string name="next">Next</string> <string name="next">Next</string>
<string name="get_started">Get Started</string> <string name="get_started">Get Started</string>
@@ -49,6 +51,9 @@
<!-- Security setup --> <!-- Security setup -->
<string name="security_setup">Secure Your App</string> <string name="security_setup">Secure Your App</string>
<string name="security_setup_desc">Choose how you want to lock BasedBank when you\'re away.</string> <string name="security_setup_desc">Choose how you want to lock BasedBank when you\'re away.</string>
<string name="security_already_configured">App Lock Configured</string>
<string name="security_already_configured_desc">Your app lock is set up.</string>
<string name="method_pin">PIN Code</string> <string name="method_pin">PIN Code</string>
<string name="method_pin_desc">48 digit numeric PIN</string> <string name="method_pin_desc">48 digit numeric PIN</string>
<string name="method_pattern">Draw Pattern</string> <string name="method_pattern">Draw Pattern</string>
@@ -104,6 +109,7 @@
<string name="settings_security">Security</string> <string name="settings_security">Security</string>
<string name="settings_change_lock">Change PIN / Pattern</string> <string name="settings_change_lock">Change PIN / Pattern</string>
<string name="settings_biometrics">Use biometrics</string> <string name="settings_biometrics">Use biometrics</string>
<string name="settings_biometrics_unavailable">No biometrics enrolled on this device</string>
<string name="settings_biometrics_unlock">To unlock app</string> <string name="settings_biometrics_unlock">To unlock app</string>
<string name="settings_biometrics_transfer">Confirm transfer</string> <string name="settings_biometrics_transfer">Confirm transfer</string>
<string name="biometric_transfer_title">Confirm Transfer</string> <string name="biometric_transfer_title">Confirm Transfer</string>
+1 -1
View File
@@ -15,5 +15,5 @@
<exclude domain="sharedpref" path="foreign_limits_cache.xml"/> <exclude domain="sharedpref" path="foreign_limits_cache.xml"/>
<exclude domain="sharedpref" path="recents_cache.xml"/> <exclude domain="sharedpref" path="recents_cache.xml"/>
<exclude domain="sharedpref" path="lock_attempts.xml"/> <exclude domain="sharedpref" path="lock_attempts.xml"/>
<exclude domain="cache" path="."/> <exclude domain="root" path="cache/"/>
</full-backup-content> </full-backup-content>
@@ -14,7 +14,7 @@
<exclude domain="sharedpref" path="foreign_limits_cache.xml"/> <exclude domain="sharedpref" path="foreign_limits_cache.xml"/>
<exclude domain="sharedpref" path="recents_cache.xml"/> <exclude domain="sharedpref" path="recents_cache.xml"/>
<exclude domain="sharedpref" path="lock_attempts.xml"/> <exclude domain="sharedpref" path="lock_attempts.xml"/>
<exclude domain="cache" path="."/> <exclude domain="root" path="cache/"/>
</cloud-backup> </cloud-backup>
<device-transfer> <device-transfer>
<exclude domain="sharedpref" path="credential_store.xml"/> <exclude domain="sharedpref" path="credential_store.xml"/>
@@ -26,6 +26,6 @@
<exclude domain="sharedpref" path="foreign_limits_cache.xml"/> <exclude domain="sharedpref" path="foreign_limits_cache.xml"/>
<exclude domain="sharedpref" path="recents_cache.xml"/> <exclude domain="sharedpref" path="recents_cache.xml"/>
<exclude domain="sharedpref" path="lock_attempts.xml"/> <exclude domain="sharedpref" path="lock_attempts.xml"/>
<exclude domain="cache" path="."/> <exclude domain="root" path="cache/"/>
</device-transfer> </device-transfer>
</data-extraction-rules> </data-extraction-rules>
+1 -1
View File
@@ -1,5 +1,5 @@
[versions] [versions]
agp = "8.9.1" agp = "8.7.3"
kotlin = "2.1.21" kotlin = "2.1.21"
coreKtx = "1.10.1" coreKtx = "1.10.1"
junit = "4.13.2" junit = "4.13.2"