Compare commits

..

5 Commits

Author SHA1 Message Date
ahusan 045eb1b1a2 ui: pass 3 — skeleton loading, a11y, snackbars, dark mode
Skeleton loading state:
  * Wire item_skeleton_transaction (previously dormant in res) into
    AccountHistoryFragment and TransferHistoryFragment. When a load
    starts with no cached transactions, 8 skeleton rows appear under
    the search bar instead of a blank screen. Skeleton hides as soon
    as transactions arrive or the load completes.
  * Decision: ActivitiesFragment loads from local storage synchronously,
    so no skeleton there — would only flash for a frame.

Accessibility:
  * Decorative icons in item_more_nav, item_nav_slot, item_picker_row,
    and item_account_dropdown now declare importantForAccessibility="no"
    so TalkBack skips them (the adjacent text label conveys meaning).
  * Also added tnum to item_picker_row.tvBalance and
    item_account_dropdown.tvDropdownBalance — missed amounts from Pass 2.

Snackbar:
  * ContactsFragment.deleteContact's success/failure feedback is now
    a Snackbar instead of Toast — the fragment stays visible, so a
    bottom-anchored Snackbar fits better than a floating Toast.
  * Other Toasts are either followed by navigation away (Snackbar
    couldn't display), or purely informational ("copied", "press back
    to exit") where Toast is the right primitive. Left as-is.

Dark mode:
  * Connectivity banner in activity_home no longer hardcodes
    #C62828 / #FFFFFF — uses colorErrorContainer / colorOnErrorContainer
    so it adapts to the user's theme.
  * Other hex literals (card-artwork text, receipt screens that
    intentionally emulate paper) are intentional and stay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:02:50 +05:00
ahusan 22806da1b3 ui: pass 2 — pill style, tabular figures, ripple feedback, empty strings
Status pills:
  * New Widget.App.StatusChip style bundling the 10dp/4dp padding +
    chip_background + label-small text used in 3 places (item_bml_loan,
    item_finance_deal, item_profile_header). The 3 layouts now apply
    the style instead of repeating 4 attributes each.
  * Extract the bindCardStatus card-overlay pill background from inline
    GradientDrawable code into a real drawable resource
    (card_overlay_pill_bg.xml). PayWithCardFragment.bindCardStatus just
    sets a background resource now.

Tabular figures (android:fontFeatureSettings="tnum"):
  * Applied to amount TextViews on the highest-traffic surfaces —
    transactions list, accounts list, card list, dashboard balance /
    credit / blocked / overdue / pending finances totals, and the
    account history header (available / balance / blocked).
  * Digits become same-width, so right-aligned amounts in a column
    line up.

Ripple feedback:
  * item_finance_deal, item_bml_loan, item_foreign_limit get
    android:clickable="true" android:focusable="true" so their
    MaterialCardView roots show the M3 ripple when the adapter
    attaches a click listener. All three are clickable from code
    but were missing the ripple cue.

Empty-state strings:
  * Hardcoded "No recent transfers" / "No transactions found" in
    fragment_activities, fragment_transfer_history, and
    fragment_account_history moved to strings.xml as
    activities_empty / transactions_empty, matching the existing
    accounts_empty / contacts_empty / financing_empty pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:48:23 +05:00
ahusan 29ac4352ff ui: swap list-item and screen-margin paddings onto tokens
Replaces hardcoded paddings in the three layouts that share the
standard 16dp/14dp list-row pattern (item_account, item_card,
item_more_nav) with @dimen/list_item_padding_h / list_item_padding_v.

Dashboard root content padding becomes @dimen/screen_margin. Card
content paddings inside the dashboard are left as raw 16dp — they're
a separate semantic (card-interior padding, not screen edge) and
will be addressed once we either need a distinct token or have a
reason to change them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:32:18 +05:00
ahusan df892ec8d5 ui: tokenize card surfaces in dimens.xml and sweep layouts
Introduces a small set of design tokens for card surfaces so the same
look is enforced everywhere instead of being hand-tuned per layout:

  card_radius_lg / card_radius_md       — 16dp / 12dp two-tier radius
  card_elevation_flat / *_raised        — 0dp outlined / 1dp subtle raise

Sweeps the existing layouts onto these tokens. Two-tier policy:

  lg → hero/standalone cards (account history header, bml loan,
       finance deal, otp card, bank selection tiles, security setup
       options)
  md → repeating row/summary cards (dashboard summary cards,
       credentials inset, foreign limit, add-contact sheet)

Card-visual representations (item_card_dashboard / _stack / _wallet
/ _settings_entry) deliberately keep their bespoke radius/elevation
so they continue to read as credit cards — the tokens are for
content cards only. Tight inset frames inside fragment_bml_qr_pay
and fragment_transfer also remain on 4dp/0dp as form details.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:20:16 +05:00
shihaam 5cba468781 debug builds has debug suffix and different launcher icon color
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-28 17:11:55 +05:00
103 changed files with 596 additions and 5437 deletions
-21
View File
@@ -87,24 +87,3 @@ jobs:
--data-binary "@${ASSET_PATH}"
echo "Uploaded asset: $ASSET_NAME"
- name: Send APK to Telegram
env:
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
TG_CHAT_ID: ${{ vars.TG_CHAT_ID }}
run: |
if [ -z "$TG_BOT_TOKEN" ] || [ -z "$TG_CHAT_ID" ]; then
echo "TG_BOT_TOKEN or TG_CHAT_ID not set, skipping Telegram upload."
exit 0
fi
APP_NAME="${{ gitea.repository }}"
APP_NAME="${APP_NAME##*/}"
TAG="${{ gitea.ref_name }}"
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
CAPTION="${APP_NAME} ${TAG}"
curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument" \
-F "chat_id=${TG_CHAT_ID}" \
-F "document=@${ASSET_PATH}" \
-F "caption=${CAPTION}"
+2 -2
View File
@@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-28T18:41:19.777722821Z">
<DropdownSelection timestamp="2026-05-22T00:11:32.873305232Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=4254e2f" />
<DeviceId pluginId="PhysicalDevice" identifier="serial=683a9830" />
</handle>
</Target>
</DropdownSelection>
+3 -8
View File
@@ -14,17 +14,12 @@ A native Android client for Maldivian banking services. It is a pure client: req
- Existing accounts with MIB, BML, or Fahipay
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
## Download APK
[Gitea Releases](https://git.shihaam.dev/shihaam/thijooree/releases)
[Telegram Channel](https://t.me/s/thijooreeapks)
## Download
[Download latest APK](https://git.shihaam.dev/shihaam/ISODroid/releases/latest)
## Privacy
No data ever leaves your device except the API calls to the banking services themselves. See the [security audit](docs/thijooree/AI_SECURITY_CHECK.md) for a full list of every server the app connects to.
## Documentation
API reverse-engineering notes and app internals are in [`docs/`](docs/README.md).
No data ever leaves your device except the API calls to the banking services themselves. See the [security audit](docs/AI_SECURITY_CHECK.md) for a full list of every server the app connects to.
## Disclaimer
+2 -5
View File
@@ -11,8 +11,8 @@ android {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 36
versionCode = 13
versionName = "1.0.14"
versionCode = 8
versionName = "1.0.9"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -91,9 +91,6 @@ dependencies {
// Biometric authentication
implementation("androidx.biometric:biometric:1.1.0")
// Encrypted SharedPreferences (HCE token store)
implementation("androidx.security:security-crypto:1.1.0-alpha06")
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
-43
View File
@@ -1,43 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
<shortcut
android:shortcutId="transfer"
android:enabled="true"
android:icon="@drawable/ic_shortcut_transfer"
android:shortcutShortLabel="@string/transfer"
android:shortcutLongLabel="@string/transfer">
<intent
android:action="sh.sar.basedbank.OPEN_TRANSFER"
android:targetPackage="sh.sar.basedbank.debug"
android:targetClass="sh.sar.basedbank.MainActivity" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
<shortcut
android:shortcutId="scan_qr"
android:enabled="true"
android:icon="@drawable/ic_shortcut_scan_qr"
android:shortcutShortLabel="@string/transfer_scan_qr"
android:shortcutLongLabel="@string/transfer_scan_qr">
<intent
android:action="sh.sar.basedbank.OPEN_SCAN_QR"
android:targetPackage="sh.sar.basedbank.debug"
android:targetClass="sh.sar.basedbank.MainActivity" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
<shortcut
android:shortcutId="tap_to_pay"
android:enabled="true"
android:icon="@drawable/ic_shortcut_pay_card"
android:shortcutShortLabel="@string/card_pay_nfc"
android:shortcutLongLabel="@string/card_pay_nfc">
<intent
android:action="sh.sar.basedbank.TAP_TO_PAY"
android:targetPackage="sh.sar.basedbank.debug"
android:targetClass="sh.sar.basedbank.MainActivity" />
<categories android:name="android.shortcut.conversation" />
</shortcut>
</shortcuts>
-36
View File
@@ -7,9 +7,6 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.NFC" />
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
<application
android:name=".BasedBankApp"
@@ -62,39 +59,6 @@
android:exported="false"
android:screenOrientation="portrait" />
<activity
android:name=".nfc.BmlTapToPayActivity"
android:exported="false"
android:launchMode="singleTop"
android:theme="@style/Theme.BasedBank" />
<service
android:name=".nfc.BmlHostCardEmulatorService"
android:exported="true"
android:permission="android.permission.BIND_NFC_SERVICE">
<intent-filter>
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data
android:name="android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/bml_aid_list" />
</service>
<!-- Share-sheet alias: "Scan to Pay" receives shared images and decodes their QR code -->
<activity-alias
android:name=".ScanToPayActivity"
android:targetActivity=".MainActivity"
android:exported="true"
android:label="@string/transfer_scan_qr"
android:icon="@drawable/ic_qr_scan">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="image/*" />
</intent-filter>
</activity-alias>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -115,11 +115,7 @@ class BasedBankApp : Application() {
override fun onCreate() {
super.onCreate()
// Only apply wallpaper-based dynamic colors in system theme mode.
// Light/dark modes use content-based accent colors applied per-activity via ThemeHelper.
DynamicColors.applyToActivitiesIfAvailable(this) { _, _ ->
getSharedPreferences("prefs", MODE_PRIVATE).getString("theme", "system") == "system"
}
DynamicColors.applyToActivitiesIfAvailable(this)
val theme = getSharedPreferences("prefs", MODE_PRIVATE).getString("theme", "system")
AppCompatDelegate.setDefaultNightMode(when (theme) {
@@ -21,7 +21,6 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.databinding.ActivityLockBinding
import sh.sar.basedbank.ui.home.HomeActivity
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.ThemeHelper
import sh.sar.basedbank.BasedBankApp
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
@@ -47,7 +46,6 @@ class LockActivity : AppCompatActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.applyAccent(this)
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityLockBinding.inflate(layoutInflater)
@@ -57,9 +55,6 @@ class LockActivity : AppCompatActivity() {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
ta.recycle()
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
@@ -278,13 +273,9 @@ class LockActivity : AppCompatActivity() {
}
val navDest = intent.getIntExtra("nav_destination", -1)
val autoScan = intent.getBooleanExtra("auto_scan", false)
val autoTapMode = intent.getBooleanExtra("auto_tap_mode", false)
val shareQrText = intent.getStringExtra("share_qr_text")
startActivity(Intent(this, HomeActivity::class.java).apply {
if (navDest != -1) putExtra("nav_destination", navDest)
if (autoScan) putExtra("auto_scan", true)
if (autoTapMode) putExtra("auto_tap_mode", true)
if (shareQrText != null) putExtra("share_qr_text", shareQrText)
})
finish()
}
@@ -12,28 +12,6 @@ import sh.sar.basedbank.R
class MainActivity : AppCompatActivity() {
private fun decodeQrFromSharedImage(uri: android.net.Uri): String? {
return try {
val bitmap = contentResolver.openInputStream(uri)?.use {
android.graphics.BitmapFactory.decodeStream(it)
} ?: return null
val opts = de.markusfisch.android.zxingcpp.ZxingCpp.ReaderOptions(
tryHarder = true, tryRotate = true, tryInvert = true,
tryDownscale = true, maxNumberOfSymbols = 1,
textMode = de.markusfisch.android.zxingcpp.ZxingCpp.TextMode.PLAIN
)
val result = (de.markusfisch.android.zxingcpp.ZxingCpp.readBitmap(
bitmap, 0, 0, bitmap.width, bitmap.height, 0,
opts.apply { binarizer = de.markusfisch.android.zxingcpp.ZxingCpp.Binarizer.LOCAL_AVERAGE }
) ?: de.markusfisch.android.zxingcpp.ZxingCpp.readBitmap(
bitmap, 0, 0, bitmap.width, bitmap.height, 0,
opts.apply { binarizer = de.markusfisch.android.zxingcpp.ZxingCpp.Binarizer.GLOBAL_HISTOGRAM }
))?.firstOrNull()?.text
bitmap.recycle()
result
} catch (_: Exception) { null }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -43,26 +21,13 @@ class MainActivity : AppCompatActivity() {
val store = CredentialStore(this)
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
// Image shared via "Scan to Pay" — decode QR here while we still hold the URI permission
val shareQrText: String? = if (intent?.action == Intent.ACTION_SEND &&
intent.type?.startsWith("image/") == true) {
val uri: android.net.Uri? =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU)
intent.getParcelableExtra(Intent.EXTRA_STREAM, android.net.Uri::class.java)
else
@Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_STREAM)
if (uri != null) decodeQrFromSharedImage(uri) else null
} else null
val navDestination = when (intent?.action) {
"sh.sar.basedbank.OPEN_TRANSFER" -> R.id.nav_transfer
"sh.sar.basedbank.OPEN_SCAN_QR" -> R.id.nav_transfer
"sh.sar.basedbank.OPEN_PAY_WITH_CARD" -> R.id.nav_pay_with_card
"sh.sar.basedbank.TAP_TO_PAY" -> R.id.nav_pay_with_card
else -> -1
}
val autoScan = intent?.action == "sh.sar.basedbank.OPEN_SCAN_QR"
val autoTapMode = intent?.action == "sh.sar.basedbank.TAP_TO_PAY"
val target = when {
!onboardingDone -> OnboardingActivity::class.java
@@ -78,8 +43,6 @@ class MainActivity : AppCompatActivity() {
startActivity(Intent(this, target).apply {
if (navDestination != -1) putExtra("nav_destination", navDestination)
if (autoScan) putExtra("auto_scan", true)
if (autoTapMode) putExtra("auto_tap_mode", true)
if (shareQrText != null) putExtra("share_qr_text", shareQrText)
})
finish()
}
@@ -82,15 +82,6 @@ data class BmlQrPayResult(
val errorMessage: String = ""
)
data class BmlWalletToken(
val token: String,
val expiry: String,
val appCode: String, // AID hex, e.g. "A0000000031010"
val serviceCode: String,
val data: String,
val validUntil: String // "YYYY-MM-DD HH:mm:ss.SSS"
)
data class BmlForeignLimit(
val type: String,
val used: Double,
@@ -1,79 +0,0 @@
package sh.sar.basedbank.api.bml
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
class BmlTapToPayClient {
private val client = newBmlApiClient()
/**
* Fetches up to [quantity] single-use payment tokens for [cardId].
* [otp] is a TOTP code generated from the stored BML OTP seed.
*
* Flow:
* 1. POST → code 99 (OTP required) or 0 (direct, unlikely)
* 2. POST with channel=token → code 22 (OTP generated on BML side, but we use TOTP)
* 3. POST with otp=TOTP → code 0, payload = token list
*/
fun fetchTokens(
session: BmlSession,
cardId: String,
otp: String,
quantity: Int = 3
): List<BmlWalletToken> {
val url = "$BML_BASE_URL/api/mobile/walletpayments/gettoken"
// Step 1: initiate
val base = JSONObject().apply {
put("type", "track2")
put("cardid", cardId)
put("quantity", quantity)
}
val step1 = post(session, url, base)
if (step1.optInt("code") == 0) return parseTokens(step1.optJSONArray("payload"))
if (step1.optInt("code") != 99) throw Exception(step1.optString("message", "Token request failed"))
// Step 2: request OTP channel (triggers BML to validate we can use TOTP)
val body2 = JSONObject(base.toString()).apply { put("channel", "token") }
val step2 = post(session, url, body2)
if (step2.optInt("code") != 22) throw Exception(step2.optString("message", "OTP channel request failed"))
// Step 3: submit TOTP
val body3 = JSONObject(body2.toString()).apply { put("otp", otp) }
val step3 = post(session, url, body3)
if (step3.optInt("code") != 0) throw Exception(step3.optString("message", "Token fetch failed"))
return parseTokens(step3.optJSONArray("payload"))
}
private fun post(session: BmlSession, url: String, body: JSONObject): JSONObject {
val req = okhttp3.Request.Builder()
.url(url)
.post(body.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.build()
return client.newCall(req).execute().use { resp ->
JSONObject(resp.body?.string() ?: throw Exception("Empty response"))
}
}
private fun parseTokens(arr: JSONArray?): List<BmlWalletToken> {
arr ?: return emptyList()
return (0 until arr.length()).map { i ->
val o = arr.getJSONObject(i)
BmlWalletToken(
token = o.getString("token"),
expiry = o.getString("expiry"),
appCode = o.getString("app_code"),
serviceCode = o.getString("service_code"),
data = o.optString("data", ""),
validUntil = o.optString("valid_until", "")
)
}
}
}
@@ -1,185 +0,0 @@
package sh.sar.basedbank.nfc
import android.content.Intent
import android.nfc.cardemulation.HostApduService
import android.os.Bundle
import android.util.Log
import sh.sar.basedbank.api.bml.BmlWalletToken
/**
* HCE service that emulates a BML contactless payment card.
*
* Implements the minimal EMV mag-stripe contactless flow:
* SELECT PPSE → SELECT AID → GET PROCESSING OPTIONS → READ RECORD
*
* Each BmlWalletToken is single-use and is set via [setToken] before tapping.
* After READ RECORD is sent the [onTransactionComplete] callback fires.
*/
class BmlHostCardEmulatorService : HostApduService() {
private var gpoSent = false
override fun processCommandApdu(commandApdu: ByteArray?, extras: Bundle?): ByteArray {
if (commandApdu == null) return SW_UNKNOWN_ERROR
val apdu = Apdu(commandApdu)
if (apdu.isError) return apdu.errorResponse()
return when (apdu.ins) {
INS_SELECT -> handleSelect(apdu)
INS_GPO -> handleGpo()
INS_READ -> handleReadRecord()
else -> SW_INS_NOT_SUPPORTED
}
}
override fun onDeactivated(reason: Int) {
if (!gpoSent) onTransactionComplete?.invoke(false)
gpoSent = false
}
// ── APDU handlers ──────────────────────────────────────────────────────────
private fun handleSelect(apdu: Apdu): ByteArray {
val data = apdu.data ?: return SW_UNKNOWN_ERROR
if (data.contentEquals(PPSE_BYTES)) {
val token = activeToken ?: run { launchPromptActivity(); return SW_UNKNOWN_ERROR }
return hexToBytes(buildSelectPpseResponse(token.appCode, applicationLabel(token.appCode), "01"))
}
val token = activeToken ?: return SW_UNKNOWN_ERROR
return if (data.contentEquals(hexToBytes(token.appCode))) {
hexToBytes(buildSelectAidResponse(token.appCode, applicationLabel(token.appCode)))
} else {
SW_UNKNOWN_ERROR
}
}
private fun launchPromptActivity() {
val intent = Intent(applicationContext, BmlTapToPayActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK
}
startActivity(intent)
}
private fun handleGpo(): ByteArray {
gpoSent = true
// AIP=0080 (mag-stripe mode), AFL=08010100 (SFI=1, record 1-1, offline 0)
val miscData = "008008010100"
val body = tlv("80", miscData)
return hexToBytes(body + SW_OK_HEX)
}
private fun handleReadRecord(): ByteArray {
val token = activeToken ?: return SW_UNKNOWN_ERROR
val track2 = buildTrack2(token)
val body = tlv("70", tlv("57", track2))
val response = hexToBytes(body + SW_OK_HEX)
onTransactionComplete?.invoke(true)
return response
}
// ── TLV / APDU response builders ───────────────────────────────────────────
private fun buildSelectPpseResponse(aid: String, label: String, priority: String): String {
val priorityTlv = tlv("87", priority) // tag 87
val aidTlv = tlv("4F", aid) // tag 4F (ADF Name)
val appEntry = tlv("61", aidTlv + priorityTlv) // tag 61
val ppseTlv = tlv("84", PPSE_HEX) // tag 84 (DF Name)
val inner = tlv("BF0C", appEntry) // tag BF0C
val propTemplate = tlv("A5", inner) // tag A5
val fci = tlv("6F", ppseTlv + propTemplate) // tag 6F
return fci + SW_OK_HEX
}
private fun buildSelectAidResponse(aid: String, label: String): String {
val aidTlv = tlv("84", aid) // tag 84
val labelTlv = tlv("50", asciiToHex(label)) // tag 50
val pdolTlv = tlv("9F38", "9F6602") // PDOL: TTQ 2 bytes
val propTemplate = tlv("A5", labelTlv + pdolTlv) // tag A5
val fci = tlv("6F", aidTlv + propTemplate) // tag 6F
return fci + SW_OK_HEX
}
private fun buildTrack2(token: BmlWalletToken): String {
var t2 = "${token.token}D${token.expiry}${token.serviceCode}${token.data}"
if (t2.length % 2 != 0) t2 += "F"
return t2
}
// ── Helpers ─────────────────────────────────────────────────────────────────
/** Build BER-TLV: tag (hex string, 1 or 2 bytes) + DER length + data (hex string). */
private fun tlv(tagHex: String, dataHex: String): String {
val lenBytes = dataHex.length / 2
val lenHex = when {
lenBytes <= 0x7F -> lenBytes.toHexByte()
lenBytes <= 0xFF -> "81" + lenBytes.toHexByte()
else -> "82" + (lenBytes shr 8).toHexByte() + (lenBytes and 0xFF).toHexByte()
}
return tagHex + lenHex + dataHex
}
private fun Int.toHexByte(): String = toString(16).padStart(2, '0').uppercase()
private fun asciiToHex(s: String): String =
s.toByteArray(Charsets.US_ASCII).joinToString("") { "%02X".format(it) }
private fun hexToBytes(hex: String): ByteArray {
val s = hex.uppercase()
return ByteArray(s.length / 2) { i ->
s.substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
}
// ── APDU parser ─────────────────────────────────────────────────────────────
private inner class Apdu(raw: ByteArray) {
val isError: Boolean
val ins: Int
val data: ByteArray?
init {
if (raw.size < 5) {
isError = true; ins = -1; data = null
} else {
isError = false
ins = raw[1].toInt() and 0xFF
val lc = if (raw.size > 4) raw[4].toInt() and 0xFF else 0
data = if (lc > 0 && raw.size >= 5 + lc) raw.copyOfRange(5, 5 + lc) else null
}
}
fun errorResponse() = SW_UNKNOWN_ERROR
}
companion object {
private const val TAG = "BmlHCE"
private const val INS_SELECT = 0xA4
private const val INS_GPO = 0xA8
private const val INS_READ = 0xB2
private val PPSE_HEX = "325041592E5359532E4444463031" // "2PAY.SYS.DDF01"
private val PPSE_BYTES = byteArrayOf(
0x32,0x50,0x41,0x59,0x2E,0x53,0x59,0x53,0x2E,0x44,0x44,0x46,0x30,0x31
)
private const val SW_OK_HEX = "9000"
private val SW_UNKNOWN_ERROR = byteArrayOf(0x6F.toByte(), 0x00.toByte())
private val SW_INS_NOT_SUPPORTED = byteArrayOf(0x6D.toByte(), 0x00.toByte())
@Volatile var activeToken: BmlWalletToken? = null
@Volatile var onTransactionComplete: ((success: Boolean) -> Unit)? = null
fun setToken(token: BmlWalletToken) { activeToken = token }
fun clearToken() { activeToken = null }
fun applicationLabel(aidHex: String): String = when {
aidHex.startsWith("A0000000031010", ignoreCase = true) -> "VISA"
aidHex.startsWith("A0000000041010", ignoreCase = true) -> "MASTERCARD"
aidHex.startsWith("A000000025", ignoreCase = true) -> "AMEX"
else -> "BML"
}
}
}
@@ -1,20 +0,0 @@
package sh.sar.basedbank.nfc
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import sh.sar.basedbank.MainActivity
/**
* Fallback entry point — redirects to MainActivity which routes to the in-app tap-to-pay screen.
*/
class BmlTapToPayActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
startActivity(Intent(this, MainActivity::class.java).apply {
action = "sh.sar.basedbank.TAP_TO_PAY"
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
})
finish()
}
}
@@ -37,12 +37,7 @@ class AccountHistoryAdapter(
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
var onIconUrlNeeded: ((url: String) -> Unit)? = null
var onTransferClick: ((BankAccount) -> Unit)? = null
var onDefaultToggle: ((Boolean) -> Unit)? = null
private var hideAmounts: Boolean = false
var showDefaultToggle: Boolean = false
set(value) { if (field == value) return; field = value; notifyItemChanged(0) }
var isDefaultAccount: Boolean = false
set(value) { if (field == value) return; field = value; notifyItemChanged(0) }
fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return
@@ -179,20 +174,6 @@ class AccountHistoryAdapter(
b.llHeaderBlocked.visibility = View.GONE
}
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) }
if (showDefaultToggle) {
b.dividerDefaultAccount.visibility = View.VISIBLE
b.llDefaultAccountRow.visibility = View.VISIBLE
b.switchDefaultAccount.setOnCheckedChangeListener(null)
b.switchDefaultAccount.isChecked = isDefaultAccount
b.switchDefaultAccount.setOnCheckedChangeListener { _, checked ->
isDefaultAccount = checked
onDefaultToggle?.invoke(checked)
}
} else {
b.dividerDefaultAccount.visibility = View.GONE
b.llDefaultAccountRow.visibility = View.GONE
}
}
}
@@ -29,9 +29,7 @@ import sh.sar.basedbank.api.models.BankTransaction
import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
import sh.sar.basedbank.util.AccountHistoryParser
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.HistoryFetcher
import sh.sar.basedbank.util.MerchantIconCache
@@ -82,23 +80,6 @@ class AccountHistoryFragment : Fragment() {
}
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
// Show default account toggle only for non-card accounts
val isCard = AccountListParser.from(account)?.isCard ?: false
if (!isCard) {
val store = CredentialStore(requireContext())
adapter.showDefaultToggle = true
adapter.isDefaultAccount = store.getDefaultAccountNumber() == account.accountNumber
adapter.onDefaultToggle = { isChecked ->
if (isChecked) {
store.setDefaultAccountNumber(account.accountNumber)
} else {
if (store.getDefaultAccountNumber() == account.accountNumber) {
store.setDefaultAccountNumber(null)
}
}
}
}
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
@@ -150,12 +131,7 @@ class AccountHistoryFragment : Fragment() {
override fun onResume() {
super.onResume()
if (::account.isInitialized) {
requireActivity().title = account.accountBriefName
if (adapter.showDefaultToggle) {
adapter.isDefaultAccount = CredentialStore(requireContext()).getDefaultAccountNumber() == account.accountNumber
}
}
if (::account.isInitialized) requireActivity().title = account.accountBriefName
}
private fun filterAndDisplay() {
@@ -166,6 +142,8 @@ class AccountHistoryFragment : Fragment() {
}
adapter.setTransactions(filtered)
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
binding.skeletonContainer.visibility =
if (allTransactions.isEmpty() && isLoading) View.VISIBLE else View.GONE
}
private fun resetAndReload() {
@@ -192,6 +170,9 @@ class AccountHistoryFragment : Fragment() {
if (firstPageDone && allTransactions.isNotEmpty()) {
adapter.showLoadingFooter = true
} else if (allTransactions.isEmpty()) {
binding.skeletonContainer.visibility = View.VISIBLE
binding.emptyView.visibility = View.GONE
}
val app = requireActivity().application as BasedBankApp
@@ -211,6 +192,7 @@ class AccountHistoryFragment : Fragment() {
isLoading = false
if (_binding == null) return@launch
binding.skeletonContainer.visibility = View.GONE
if (!firstPageDone) {
firstPageDone = true
@@ -33,8 +33,6 @@ import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentBmlQrPayBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache
import sh.sar.basedbank.util.Totp
class BmlQrPayFragment : Fragment() {
@@ -152,19 +150,6 @@ class BmlQrPayFragment : Fragment() {
return@launch
}
merchantInfo = info
if (info.amount == 0.0) {
val qrUrl = arguments?.getString(ARG_QR_URL)
if (qrUrl != null) {
RecentsCache.save(requireContext(), RecentPick(
accountNumber = "bmlqr:$qrUrl",
displayName = info.merchantName,
subtitle = info.merchantAddress.ifBlank { "BML Merchant" },
colorHex = "#0066A1",
imageHash = null,
isProfileImage = false
))
}
}
populateMerchant(info)
}
}
@@ -168,10 +168,6 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
when {
accountNumber.startsWith("bmlqr:") -> {
bundle.putString(KEY_SUBTITLE, "BML QR Merchant")
bundle.putString(KEY_COLOR, "#0066A1")
}
account != null -> {
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
bundle.putString(KEY_COLOR, "#FE860E")
@@ -9,6 +9,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.widget.addTextChangedListener
@@ -178,7 +179,7 @@ class ContactsFragment : Fragment() {
colorHex = contact.bankColor,
imageHash = contact.imageHash
)
(requireActivity() as HomeActivity).showWithBackStack(fragment)
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
}
private fun confirmDelete(contact: ContactDisplay) {
@@ -194,10 +195,10 @@ class ContactsFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch {
val success = withContext(Dispatchers.IO) { ContactManager.delete(contact, app) }
if (success) {
Toast.makeText(requireContext(), R.string.contact_deleted, Toast.LENGTH_SHORT).show()
removeFromViewModel(contact)
Snackbar.make(binding.root, R.string.contact_deleted, Snackbar.LENGTH_SHORT).show()
} else {
Toast.makeText(requireContext(), R.string.contact_delete_failed, Toast.LENGTH_SHORT).show()
Snackbar.make(binding.root, R.string.contact_delete_failed, Snackbar.LENGTH_LONG).show()
}
}
}
@@ -4,13 +4,13 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
@@ -25,8 +25,6 @@ import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.PaymvQrParser
import kotlin.math.abs
import sh.sar.basedbank.databinding.FragmentDashboardBinding
import sh.sar.basedbank.databinding.ItemForeignLimitBinding
@@ -36,35 +34,21 @@ class DashboardFragment : Fragment() {
private var _binding: FragmentDashboardBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private var pendingQrCardNumber: String? = null
private var pendingQrAccountNumber: String? = null
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
raw.startsWith("https://pay.bml.com.mv/app/")) {
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, pendingQrAccountNumber)
)
} else {
val qr = PaymvQrParser.parse(raw)
if (qr?.accountNumber != null) {
Toast.makeText(requireContext(), R.string.card_qr_paymv_unsupported, Toast.LENGTH_SHORT).show()
val defaultFrom = CredentialStore(requireContext()).getDefaultAccountNumber()
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromQr(
accountNumber = qr.accountNumber,
displayName = qr.merchantName ?: qr.accountNumber,
amount = qr.amount,
remarks = qr.purpose,
fromAccountNumber = defaultFrom
)
)
} else {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
}
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
}
pendingQrAccountNumber = null
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@@ -112,22 +96,13 @@ class DashboardFragment : Fragment() {
LinearSnapHelper().attachToRecyclerView(binding.rvCards)
val updateCardList = {
val credStore = CredentialStore(requireContext())
val hidden = credStore.getHiddenDashboardCardNumbers()
val mibItems = (viewModel.mibCards.value ?: emptyList())
.filter { !hidden.contains(it.maskedCardNumber) }
.map { CardItem.Mib(it) }
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
val bmlItems = (viewModel.accounts.value ?: emptyList())
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) }
.map { CardItem.Bml(it) }
val all = mibItems + bmlItems
val defaultNum = credStore.getDefaultCardAccountNumber()
val ordered = if (defaultNum != null) {
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
if (def != null) listOf(def) + all.filter { it !== def } else all
} else all
cardAdapter.update(ordered)
binding.sectionCards.visibility = if (ordered.isNotEmpty()) View.VISIBLE else View.GONE
cardAdapter.update(all)
binding.sectionCards.visibility = if (all.isNotEmpty()) View.VISIBLE else View.GONE
}
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
@@ -145,30 +120,10 @@ class DashboardFragment : Fragment() {
override fun onResume() {
super.onResume()
val isBottom = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getBoolean("bottom_nav", false)
if (isBottom) {
requireActivity().title = getString(R.string.app_name)
val size = (28 * resources.displayMetrics.density).toInt()
val gap = (8 * resources.displayMetrics.density).toInt()
val icon = requireContext().packageManager.getApplicationIcon(requireContext().packageName)
val bmp = android.graphics.Bitmap.createBitmap(size + gap, size, android.graphics.Bitmap.Config.ARGB_8888)
val canvas = android.graphics.Canvas(bmp)
icon.setBounds(0, 0, size, size)
icon.draw(canvas)
requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar).logo =
android.graphics.drawable.BitmapDrawable(resources, bmp)
} else {
requireActivity().title = getString(R.string.nav_dashboard)
}
requireActivity().title = getString(R.string.nav_dashboard)
refreshQuickActions()
}
override fun onPause() {
super.onPause()
requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar).logo = null
}
private fun refreshQuickActions() {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val isBottom = prefs.getBoolean("bottom_nav", false)
@@ -323,23 +278,26 @@ class DashboardFragment : Fragment() {
.groupBy({ it.first }, { it.second })
.mapValues { (_, vs) -> vs.sum() }
val blockedMvr = blockedByCurrency["MVR"] ?: 0.0
val blockedUsd = blockedByCurrency["USD"] ?: 0.0
val blockedTotal = blockedByCurrency.values.sum()
if (blockedTotal > 0.0) {
// Primary line: prefer MVR if present, otherwise the first currency.
val primaryCcy = if ("MVR" in blockedByCurrency) "MVR" else blockedByCurrency.keys.first()
val primaryAmt = blockedByCurrency.getValue(primaryCcy)
binding.tvBlockedTotal.text = if (hide) "$primaryCcy ••••••" else "$primaryCcy %,.2f".format(primaryAmt)
if (blockedMvr > 0.0) {
binding.tvBlockedMvr.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(blockedMvr)
binding.cardBlockedMvr.visibility = View.VISIBLE
val secondary = blockedByCurrency.filterKeys { it != primaryCcy }
if (secondary.isNotEmpty()) {
binding.tvBlockedSecondary.text = secondary.entries.joinToString(" · ") { (ccy, amt) ->
if (hide) "$ccy ••••••" else "$ccy %,.2f".format(amt)
}
binding.tvBlockedSecondary.visibility = View.VISIBLE
} else {
binding.tvBlockedSecondary.visibility = View.GONE
}
binding.cardBlocked.visibility = View.VISIBLE
} else {
binding.cardBlockedMvr.visibility = View.GONE
binding.cardBlocked.visibility = View.GONE
}
if (blockedUsd > 0.0) {
binding.tvBlockedUsd.text = if (hide) "USD ••••••" else "USD %,.2f".format(blockedUsd)
binding.cardBlockedUsd.visibility = View.VISIBLE
} else {
binding.cardBlockedUsd.visibility = View.GONE
}
binding.rowBlocked.visibility = if (blockedTotal > 0.0) View.VISIBLE else View.GONE
// Overdue: MIB finance deals + BML loan details (assumed MVR — matches existing Pending Finances).
val mibOverdue = (viewModel.financing.value ?: emptyList()).sumOf { it.overdueAmount }
@@ -352,7 +310,8 @@ class DashboardFragment : Fragment() {
binding.cardOverdue.visibility = View.GONE
}
binding.rowAttention.visibility = if (overdueTotal > 0.0) View.VISIBLE else View.GONE
binding.rowAttention.visibility =
if (blockedTotal > 0.0 || overdueTotal > 0.0) View.VISIBLE else View.GONE
}
private fun updatePendingFinances() {
@@ -416,7 +375,7 @@ class DashboardFragment : Fragment() {
if (isMib) {
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
} else {
pendingQrCardNumber = (item as CardItem.Bml).account.accountNumber
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
}
@@ -424,15 +383,8 @@ class DashboardFragment : Fragment() {
val nfcSupported = nfcAdapter != null
btnPayNfc.isEnabled = nfcSupported
btnPayNfc.setOnClickListener {
if (isMib) {
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
} else {
val accountNumber = (item as CardItem.Bml).account.accountNumber
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_pay_with_card,
CardsFragment.newInstanceWithAutoTapMode(accountNumber)
)
}
val msg = if (isMib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
}
}
}
@@ -68,7 +68,6 @@ import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.FinancingCache
import sh.sar.basedbank.util.ForeignLimitsCache
import sh.sar.basedbank.util.ThemeHelper
class HomeActivity : AppCompatActivity() {
@@ -76,7 +75,6 @@ class HomeActivity : AppCompatActivity() {
private val viewModel: HomeViewModel by viewModels()
private lateinit var toggle: ActionBarDrawerToggle
private var suppressBottomNavCallback = false
private var cachedTransferFragment: TransferFragment? = null
private var backPressedOnce = false
private val backPressHandler = Handler(Looper.getMainLooper())
@@ -108,7 +106,6 @@ class HomeActivity : AppCompatActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.applyAccent(this)
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityHomeBinding.inflate(layoutInflater)
@@ -121,9 +118,6 @@ class HomeActivity : AppCompatActivity() {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
ta.recycle()
// Auth guard: HomeActivity must only be reachable after LockActivity or fresh login.
// Using loadSecurityHash() (EncryptedSharedPreferences) instead of plain prefs so
// a rooted device cannot bypass this by editing security_method in plain prefs.
@@ -157,7 +151,7 @@ class HomeActivity : AppCompatActivity() {
R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
R.id.nav_transfer -> TransferFragment()
R.id.nav_pay_mv_qr -> PayMvQrFragment()
R.id.nav_more -> MoreFragment()
R.id.nav_activities -> ActivitiesFragment()
@@ -238,26 +232,14 @@ class HomeActivity : AppCompatActivity() {
if (savedInstanceState == null) {
val navDest = intent.getIntExtra("nav_destination", -1)
val autoScan = intent.getBooleanExtra("auto_scan", false)
val autoTapMode = intent.getBooleanExtra("auto_tap_mode", false)
val shareQrText = intent.getStringExtra("share_qr_text")
when {
shareQrText != null -> {
show(DashboardFragment())
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
routeSharedQrText(shareQrText)
}
navDest != -1 -> {
val fragment = when {
autoScan && navDest == R.id.nav_transfer -> TransferFragment.newInstanceWithAutoScan()
autoTapMode && navDest == R.id.nav_pay_with_card -> CardsFragment.newInstanceWithAutoTapMode()
else -> null
}
navigateTo(navDest, fragment)
}
else -> {
show(DashboardFragment())
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
}
if (navDest != -1) {
val fragment = if (autoScan && navDest == R.id.nav_transfer)
TransferFragment.newInstanceWithAutoScan()
else null
navigateTo(navDest, fragment)
} else {
show(DashboardFragment())
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
}
}
@@ -268,9 +250,6 @@ class HomeActivity : AppCompatActivity() {
binding.drawerLayout.closeDrawers()
return
}
// Let CardsFragment handle back if in manage mode
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
if (currentFrag is CardsFragment && currentFrag.onBackPressed()) return
// Pop fragment back stack if there's anything on it (e.g. showWithBackStack)
if (supportFragmentManager.backStackEntryCount > 0) {
supportFragmentManager.popBackStack()
@@ -279,6 +258,7 @@ class HomeActivity : AppCompatActivity() {
// In bottom nav mode, pressing back navigates up the hierarchy
val isBottomNav = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
if (isBottomNav && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
// Sub-page reached via More (e.g. Settings, Activities) — go back to More
if (binding.bottomNavigation.selectedItemId == R.id.nav_more && currentFrag !is MoreFragment) {
show(MoreFragment())
@@ -391,7 +371,7 @@ fun applyNavLabelVisibility() {
R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
R.id.nav_transfer -> TransferFragment()
R.id.nav_pay_mv_qr -> PayMvQrFragment()
R.id.nav_activities -> ActivitiesFragment()
R.id.nav_transfer_history -> TransferHistoryFragment()
@@ -446,27 +426,6 @@ fun applyNavLabelVisibility() {
.commit()
}
private fun routeSharedQrText(text: String) {
val store = CredentialStore(this)
val bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text)
if (text.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: text, store.getDefaultCardAccountNumber()))
return
}
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
if (qr?.accountNumber != null) {
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromQr(
accountNumber = qr.accountNumber,
displayName = qr.merchantName ?: qr.accountNumber,
amount = qr.amount,
remarks = qr.purpose,
fromAccountNumber = store.getDefaultAccountNumber()
))
} else {
Toast.makeText(this, R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
}
}
override fun onResume() {
super.onResume()
// Returning from LockActivity — refresh sessions since they may have expired.
@@ -553,8 +512,9 @@ fun applyNavLabelVisibility() {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.toolbar_menu, menu)
val eyeEnabled = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_sensitive_info", false)
val eyeItem = menu.findItem(R.id.action_hide_amounts)
eyeItem?.isVisible = true
eyeItem?.isVisible = eyeEnabled
val hidden = viewModel.hideAmounts.value ?: false
eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility)
return true
@@ -1,14 +1,9 @@
package sh.sar.basedbank.ui.home
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
@@ -47,16 +42,6 @@ class OtpFragment : Fragment() {
override fun onBindViewHolder(holder: VH, position: Int) {
holder.b.tvOtpLabel.text = entries[position].label
update(holder.b, entries[position].seed)
holder.b.root.setOnClickListener {
val code = holder.b.tvOtpCode.text.toString().replace(" ", "")
if (code.isNotEmpty()) {
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
Toast.makeText(it.context, "OTP copied", Toast.LENGTH_SHORT).show()
}
}
}
}
fun tick() {
@@ -7,10 +7,13 @@ import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.app.Activity
import android.content.Intent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.FileProvider
import androidx.core.view.ViewCompat
@@ -33,9 +36,9 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
import java.io.File
@@ -52,6 +55,31 @@ class PayMvQrFragment : Fragment() {
private var generateJob: Job? = null
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
// BML card/gateway QR — hand off to dedicated payment screen
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
raw.startsWith("https://pay.bml.com.mv/app/")) {
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw))
return@registerForActivityResult
}
val qr = PaymvQrParser.parse(raw)
if (qr == null || qr.accountNumber == null) {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
return@registerForActivityResult
}
val activity = requireActivity() as HomeActivity
activity.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromQr(
accountNumber = qr.accountNumber,
displayName = qr.merchantName ?: qr.accountNumber,
amount = qr.amount,
remarks = qr.purpose
))
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
@@ -70,23 +98,19 @@ class PayMvQrFragment : Fragment() {
}
setupDropdown()
binding.etAmount.addTextChangedListener { scheduleGenerate() }
binding.etReference.addTextChangedListener { scheduleGenerate() }
binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() }
binding.btnShare.isEnabled = false
binding.btnSave.isEnabled = false
binding.btnShare.setOnClickListener { shareQr() }
binding.btnSave.setOnClickListener { saveQr() }
binding.btnScanQr.setOnClickListener {
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceWithAutoScan())
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
}
private fun setupDropdown() {
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
val eligible = accounts.filter {
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" &&
it.bank != "MIB" && // TODO: MIB does not support PayMV QR
!(it.bank == "BML" && it.currencyName.contains("USD", ignoreCase = true)) // TODO: BML USD not supported by MMA
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN"
}
val adapter = QrAccountAdapter(requireContext(), eligible)
binding.actvAccount.setAdapter(adapter)
@@ -95,20 +119,6 @@ class PayMvQrFragment : Fragment() {
selectedAccount = picked
scheduleGenerate()
}
// Auto-select default account if none is selected yet
if (selectedAccount == null) {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
if (defaultNum != null) {
val defaultAcc = eligible.firstOrNull { it.accountNumber == defaultNum }
if (defaultAcc != null) {
selectedAccount = defaultAcc
val prefix = if (defaultAcc.bank == "BML" && defaultAcc.profileName.isNotBlank()) "${defaultAcc.profileName} · " else ""
binding.actvAccount.setText("$prefix${defaultAcc.accountBriefName}", false)
scheduleGenerate()
}
}
}
}
}
@@ -135,28 +145,8 @@ class PayMvQrFragment : Fragment() {
?.let { "%.2f".format(it) }
val ctx = requireContext()
val includePhone = binding.switchIncludePhone.isChecked
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
val store = CredentialStore(ctx)
val mobile = if (includePhone) {
when (account.bank) {
"BML" -> store.loadBmlUserProfile(loginId)?.mobile
"FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile
else -> null
}?.let { m ->
when {
m.startsWith("+") -> m
m.length == 7 -> "+960$m"
else -> m
}
}
} else null
val purpose = binding.etReference.text?.toString()?.trim()
?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default)
val bmp = withContext(Dispatchers.Default) {
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted, mobile, purpose)
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted)
renderQrCard(ctx, account, payload, amountFormatted)
}
if (_binding == null) return
@@ -174,9 +164,7 @@ class PayMvQrFragment : Fragment() {
accountNumber: String,
accountName: String,
acquirer: String,
amountStr: String?,
mobile: String?,
purpose: String
amountStr: String?
): String {
fun tlv(tag: String, value: String): String {
val len = value.length
@@ -186,30 +174,17 @@ class PayMvQrFragment : Fragment() {
val poi = tlv("01", "11")
val sub00 = tlv("00", "mv.favara.mpqr")
val sub01 = tlv("01", acquirer)
val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app
val sub03 = tlv("03", accountNumber)
val sub05 = if (!mobile.isNullOrBlank()) tlv("05", mobile) else ""
val sub10 = tlv("10", "IPAY")
val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10)
val mcc = tlv("52", "0000")
val merchantAcct = tlv("26", sub00 + sub01 + sub03 + sub10)
val currency = tlv("53", "462")
val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else ""
val country = tlv("58", "MV")
val name = tlv("59", accountName.take(25))
val ref = generateReference()
val addlData = tlv("62", tlv("05", ref) + tlv("08", purpose))
val timestamp = java.time.LocalDateTime.now()
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.00000"))
val tag80 = tlv("80", tlv("00", "mv.favara.mpqr") + tlv("01", timestamp))
val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + addlData + tag80 + "6304"
val prefix = format + poi + merchantAcct + currency + amountTLV + country + name + "6304"
return prefix + crc16(prefix)
}
private fun generateReference(): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return (1..9).map { chars.random() }.joinToString("")
}
private fun crc16(data: String): String {
var crc = 0xFFFF
for (c in data) {
@@ -452,7 +427,7 @@ class PayMvQrFragment : Fragment() {
} else {
b.tvDropdownAccountType.visibility = View.GONE
}
b.tvDropdownBalance.visibility = View.GONE
b.tvDropdownBalance.text = displayData?.balance ?: ""
b.root.alpha = 1f
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
@@ -3,7 +3,6 @@ package sh.sar.basedbank.ui.home
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.activity.result.contract.ActivityResultContracts
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
@@ -14,41 +13,20 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.doOnNextLayout
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView
import android.animation.ValueAnimator
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.view.Gravity
import androidx.biometric.BiometricManager
import androidx.biometric.BiometricPrompt
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton
import com.google.android.material.color.MaterialColors
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlTapToPayClient
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.databinding.FragmentCardsBinding
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.Totp
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.PaymvQrParser
import kotlin.math.abs
class CardsFragment : Fragment() {
@@ -60,52 +38,21 @@ class CardsFragment : Fragment() {
private var cards: List<CardItem> = emptyList()
private var currentCardPosition: Int = 0
private var cardWidth: Int = 0
private var pendingQrCardNumber: String? = null
private var isManageMode: Boolean = false
private var pendingQrAccountNumber: String? = null
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
raw.startsWith("https://pay.bml.com.mv/app/")) {
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, pendingQrAccountNumber)
)
} else {
val qr = PaymvQrParser.parse(raw)
if (qr?.accountNumber != null) {
Toast.makeText(requireContext(), R.string.card_qr_paymv_unsupported, Toast.LENGTH_SHORT).show()
val defaultFrom = store.getDefaultAccountNumber()
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromQr(
accountNumber = qr.accountNumber,
displayName = qr.merchantName ?: qr.accountNumber,
amount = qr.amount,
remarks = qr.purpose,
fromAccountNumber = defaultFrom
)
)
} else {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
}
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
}
pendingQrAccountNumber = null
}
private var isTapMode: Boolean = false
private var tapAnimView: NfcTapAnimationView? = null
private var autoTapModeTriggered = false
// Carousel snapshot captured on enter, used to reverse the exit animation
private var carouselCardLayoutTop = 0f // card layout top relative to contentLayout
private var carouselCardCenterX = 0f // card center X relative to contentLayout
private var carouselTextLayoutTop = 0f // tvSelectedCardType layout top relative to contentLayout
// Swipe-to-dismiss tracking
private var swipeDragStartRawY = 0f
private var swipeIsDragging = false
private lateinit var stackAdapter: CardStackAdapter
private val store by lazy { CredentialStore(requireContext()) }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCardsBinding.inflate(inflater, container, false)
@@ -117,7 +64,7 @@ class CardsFragment : Fragment() {
val peekPx = screenW / 8
cardWidth = screenW - 2 * peekPx
stackAdapter = CardStackAdapter(cardWidth)
val stackAdapter = CardStackAdapter(cardWidth)
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
binding.rvCards.adapter = stackAdapter
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
@@ -154,8 +101,24 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
insets
}
viewModel.mibCards.observe(viewLifecycleOwner) { rebuildCards() }
viewModel.accounts.observe(viewLifecycleOwner) { rebuildCards() }
val updateCardList = {
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
val bmlItems = (viewModel.accounts.value ?: emptyList())
.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
.map { CardItem.Bml(it) }
cards = mibItems + bmlItems
stackAdapter.update(cards)
binding.loadingView.visibility = View.GONE
val empty = cards.isEmpty()
binding.emptyView.visibility = if (empty) View.VISIBLE else View.GONE
binding.contentLayout.visibility = if (empty) View.GONE else View.VISIBLE
if (!empty) {
buildDots(cards.size, currentCardPosition)
updateCardInfo(currentCardPosition)
}
}
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
val cached = CardsCache.load(requireContext())
if (cached.isNotEmpty()) {
@@ -165,60 +128,12 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
}
(activity as? HomeActivity)?.triggerRefreshCards()
binding.btnManageCard.setOnClickListener {
setManageMode(!isManageMode)
}
// Swipe-down on the manage card to dismiss manage mode
binding.manageCardView.root.setOnTouchListener { _, event ->
if (!isManageMode && !isTapMode) return@setOnTouchListener false
val mgr = binding.manageCardView.root
when (event.action) {
android.view.MotionEvent.ACTION_DOWN -> {
mgr.animate().cancel()
binding.tvSelectedCardType.animate().cancel()
swipeDragStartRawY = event.rawY
swipeIsDragging = false
true
}
android.view.MotionEvent.ACTION_MOVE -> {
val dy = (event.rawY - swipeDragStartRawY).coerceAtLeast(0f)
if (dy > 12f || swipeIsDragging) {
swipeIsDragging = true
mgr.translationY = dy
binding.tvSelectedCardType.translationY = dy * 0.6f
val scale = 1f - (dy / (binding.contentLayout.height * 2.5f)).coerceIn(0f, 0.12f)
mgr.scaleX = scale
mgr.scaleY = scale
true
} else false
}
android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> {
if (swipeIsDragging) {
val dy = (event.rawY - swipeDragStartRawY).coerceAtLeast(0f)
swipeIsDragging = false
if (dy > 130f) {
if (isTapMode) setTapMode(false) else setManageMode(false)
} else {
// Snap back
mgr.animate().translationY(0f).scaleX(1f).scaleY(1f)
.setDuration(280).setInterpolator(DecelerateInterpolator()).start()
binding.tvSelectedCardType.animate().translationY(0f)
.setDuration(280).setInterpolator(DecelerateInterpolator()).start()
}
true
} else false
}
else -> false
}
}
binding.btnScanToPay.setOnClickListener {
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
if (item is CardItem.Mib) {
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
} else {
pendingQrCardNumber = (item as CardItem.Bml).account.accountNumber
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
}
@@ -227,17 +142,8 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
binding.btnTapToPay.isEnabled = nfcAvailable
binding.btnTapToPay.setOnClickListener {
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
if (item is CardItem.Mib) {
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
val bmlItem = item as CardItem.Bml
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
showBiometricPromptForTap(bmlItem)
} else {
setTapMode(true, bmlItem)
}
val msg = if (item is CardItem.Mib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
}
val wip = View.OnClickListener {
@@ -248,484 +154,6 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
binding.btnBlock.setOnClickListener(wip)
}
private fun setManageMode(enabled: Boolean) {
isManageMode = enabled
requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card)
if (enabled) enterManageMode() else exitManageMode()
}
private fun enterManageMode() {
val item = cards.getOrNull(currentCardPosition) ?: return
// Bind card data
val cv = binding.manageCardView
when (item) {
is CardItem.Mib -> {
cv.tvCardOwner.text = item.card.cardHolderName
cv.tvCardNumber.text = formatMasked(item.card.maskedCardNumber)
val assetPath = cardImageAsset(item.card)
if (assetPath != null) loadCardImage(cv.ivCardImage, assetPath)
else cv.ivCardImage.setImageDrawable(null)
bindCardStatus(cv.tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
cv.root.alpha = 1f
}
is CardItem.Bml -> {
cv.tvCardOwner.text = item.account.accountBriefName
cv.tvCardNumber.text = formatMasked(item.account.accountNumber)
loadCardImage(cv.ivCardImage, BmlCardParser.cardImageAsset(item.account))
val isActive = item.account.statusDesc.equals("Active", ignoreCase = true)
bindCardStatus(cv.tvCardStatus, item.account.statusDesc.takeUnless { isActive })
cv.root.alpha = if (isActive) 1f else 0.45f
}
}
// Capture positions BEFORE layout changes (for enter animation + exit animation later)
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
val lm = binding.rvCards.layoutManager as? LinearLayoutManager
val srcView = lm?.findViewByPosition(currentCardPosition)
val srcLoc = IntArray(2).also { srcView?.getLocationOnScreen(it) ?: run { it[0] = contentLoc[0]; it[1] = contentLoc[1] } }
val srcScreenTop = (srcLoc[1] - contentLoc[1]).toFloat()
val srcCenterX = (srcLoc[0] - contentLoc[0]).toFloat() + cardWidth / 2f
val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
val textSrcScreenTop = (textLoc[1] - contentLoc[1]).toFloat()
// Apply layout changes
binding.btnManageCard.visibility = View.GONE
binding.topSpacer.visibility = View.GONE
binding.rvCards.visibility = View.GONE
binding.pageIndicator.visibility = View.GONE
binding.llPayButtons.visibility = View.GONE
binding.llManageButtons.visibility = View.VISIBLE
binding.llDefaultCardRow.visibility = View.VISIBLE
binding.llHideDashboardRow.visibility = View.VISIBLE
binding.manageCardView.root.visibility = View.VISIBLE
// Set switch state (clear listener first to avoid triggering on programmatic set)
val isBml = item is CardItem.Bml
binding.switchDefaultCard.setOnCheckedChangeListener(null)
binding.switchDefaultCard.isChecked = isBml && store.getDefaultCardAccountNumber() == (item as? CardItem.Bml)?.account?.accountNumber
binding.switchDefaultCard.setOnCheckedChangeListener { _, isChecked ->
if (item is CardItem.Mib) {
// MIB doesn't support NFC/QR pay — same toast as scan/tap to pay
binding.switchDefaultCard.setOnCheckedChangeListener(null)
binding.switchDefaultCard.isChecked = false
binding.switchDefaultCard.setOnCheckedChangeListener { _, c ->
handleDefaultCardToggle(c)
}
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
} else {
handleDefaultCardToggle(isChecked)
}
}
val accountNumber = (item as? CardItem.Bml)?.account?.accountNumber
?: (item as? CardItem.Mib)?.card?.maskedCardNumber
binding.switchHideFromDashboard.setOnCheckedChangeListener(null)
binding.switchHideFromDashboard.isChecked = accountNumber != null &&
store.getHiddenDashboardCardNumbers().contains(accountNumber)
binding.switchHideFromDashboard.setOnCheckedChangeListener { _, isChecked ->
if (accountNumber != null) {
store.setCardHiddenFromDashboard(accountNumber, isChecked)
}
}
// After layout pass, compute offsets, save carousel snapshot, and animate
binding.contentLayout.doOnNextLayout {
val mgr = binding.manageCardView.root
val dstLoc = IntArray(2).also { mgr.getLocationOnScreen(it) }
val dstTop = (dstLoc[1] - contentLoc[1]).toFloat()
val dstCenterX = (dstLoc[0] - contentLoc[0]).toFloat() + mgr.width / 2f
val scaleStart = if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f
val transXStart = srcCenterX - dstCenterX
val transYStart = srcScreenTop - dstTop
// Save the carousel card's position (relative to contentLayout) for the exit animation
carouselCardLayoutTop = srcScreenTop
carouselCardCenterX = srcCenterX
carouselTextLayoutTop = textSrcScreenTop
val textDstLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
val textDstTop = (textDstLoc[1] - contentLoc[1]).toFloat()
mgr.pivotX = mgr.width / 2f
mgr.pivotY = 0f
mgr.scaleX = scaleStart
mgr.scaleY = scaleStart
mgr.translationX = transXStart
mgr.translationY = transYStart
mgr.animate()
.scaleX(1f).scaleY(1f)
.translationX(0f).translationY(0f)
.setDuration(380)
.setInterpolator(DecelerateInterpolator())
.start()
binding.tvSelectedCardType.translationY = textSrcScreenTop - textDstTop
binding.tvSelectedCardType.animate()
.translationY(0f)
.setDuration(380)
.setInterpolator(DecelerateInterpolator())
.start()
}
}
private fun handleDefaultCardToggle(isChecked: Boolean) {
val item = cards.getOrNull(currentCardPosition) as? CardItem.Bml ?: return
store.setDefaultCardAccountNumber(if (isChecked) item.account.accountNumber else null)
rebuildCards()
}
private fun exitManageMode() {
binding.manageCardView.root.animate().cancel()
binding.tvSelectedCardType.animate().cancel()
val mgr = binding.manageCardView.root
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
// Compute layout top of manage card (strip current translationY which may be from a swipe drag)
val mgrLoc = IntArray(2).also { mgr.getLocationOnScreen(it) }
val mgrLayoutTop = (mgrLoc[1] - contentLoc[1]).toFloat() - mgr.translationY
val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
val textLayoutTop = (textLoc[1] - contentLoc[1]).toFloat() - binding.tvSelectedCardType.translationY
// Target: animate card back to carousel position
val scaleEnd = if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f
val mgrLayoutCenterX = (mgrLoc[0] - contentLoc[0]).toFloat() - mgr.translationX + mgr.width / 2f
val targetTransX = carouselCardCenterX - mgrLayoutCenterX
val targetTransY = carouselCardLayoutTop - mgrLayoutTop
val targetTextTransY = carouselTextLayoutTop - textLayoutTop
mgr.pivotX = mgr.width / 2f
mgr.pivotY = 0f
mgr.animate()
.scaleX(scaleEnd).scaleY(scaleEnd)
.translationX(targetTransX)
.translationY(targetTransY)
.setDuration(320)
.setInterpolator(AccelerateInterpolator())
.withEndAction {
mgr.scaleX = 1f; mgr.scaleY = 1f
mgr.translationX = 0f; mgr.translationY = 0f
mgr.visibility = View.GONE
binding.tvSelectedCardType.translationY = 0f
binding.btnManageCard.visibility = View.VISIBLE
binding.topSpacer.visibility = View.VISIBLE
binding.rvCards.visibility = View.VISIBLE
binding.llPayButtons.visibility = View.VISIBLE
binding.llManageButtons.visibility = View.GONE
binding.llDefaultCardRow.visibility = View.GONE
binding.llHideDashboardRow.visibility = View.GONE
binding.switchDefaultCard.setOnCheckedChangeListener(null)
binding.switchHideFromDashboard.setOnCheckedChangeListener(null)
buildDots(cards.size, currentCardPosition)
}
.start()
binding.tvSelectedCardType.animate()
.translationY(targetTextTransY)
.setDuration(320)
.setInterpolator(AccelerateInterpolator())
.withEndAction { binding.tvSelectedCardType.translationY = 0f }
.start()
}
// ── Tap-to-pay mode ────────────────────────────────────────────────────────
private fun setTapMode(enabled: Boolean, item: CardItem.Bml? = null) {
isTapMode = enabled
requireActivity().title = getString(if (enabled) R.string.card_pay_nfc else R.string.nav_pay_with_card)
if (enabled) enterTapMode(item!!) else exitTapMode()
}
private fun showBiometricPromptForTap(item: CardItem.Bml) {
val bmgr = BiometricManager.from(requireContext())
if (bmgr.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) != BiometricManager.BIOMETRIC_SUCCESS) {
setTapMode(true, item)
return
}
val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
setTapMode(true, item)
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { }
})
prompt.authenticate(
BiometricPrompt.PromptInfo.Builder()
.setTitle(getString(R.string.card_pay_nfc))
.setSubtitle(item.account.accountBriefName)
.setNegativeButtonText(getString(R.string.cancel))
.build()
)
}
private fun enterTapMode(item: CardItem.Bml) {
// Bind card data to the shared manage card view
val cv = binding.manageCardView
cv.tvCardOwner.text = item.account.accountBriefName
cv.tvCardNumber.text = formatMasked(item.account.accountNumber)
loadCardImage(cv.ivCardImage, BmlCardParser.cardImageAsset(item.account))
val isActive = item.account.statusDesc.equals("Active", ignoreCase = true)
bindCardStatus(cv.tvCardStatus, item.account.statusDesc.takeUnless { isActive })
cv.root.alpha = if (isActive) 1f else 0.45f
// Snapshot carousel card position before layout changes (for animation)
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
val lm = binding.rvCards.layoutManager as? LinearLayoutManager
val srcView = lm?.findViewByPosition(currentCardPosition)
val srcLoc = IntArray(2).also {
srcView?.getLocationOnScreen(it) ?: run { it[0] = contentLoc[0]; it[1] = contentLoc[1] }
}
val srcScreenTop = (srcLoc[1] - contentLoc[1]).toFloat()
val srcCenterX = (srcLoc[0] - contentLoc[0]).toFloat() + cardWidth / 2f
val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
val textSrcScreenTop = (textLoc[1] - contentLoc[1]).toFloat()
carouselCardLayoutTop = srcScreenTop
carouselCardCenterX = srcCenterX
carouselTextLayoutTop = textSrcScreenTop
// Apply layout changes
binding.btnManageCard.visibility = View.GONE
binding.topSpacer.visibility = View.GONE
binding.rvCards.visibility = View.GONE
binding.pageIndicator.visibility = View.GONE
binding.divider.visibility = View.GONE
binding.llPayButtons.visibility = View.GONE
binding.llManageButtons.visibility = View.GONE
binding.llDefaultCardRow.visibility = View.GONE
binding.manageCardView.root.visibility = View.VISIBLE
binding.flTapMode.visibility = View.VISIBLE
// Build tap mode content: animation view + cancel button
binding.flTapMode.removeAllViews()
val animView = NfcTapAnimationView(requireContext())
tapAnimView = animView
val dp = resources.displayMetrics.density
val cancelBtn = MaterialButton(requireContext(), null,
com.google.android.material.R.attr.materialButtonOutlinedStyle
).apply { setText(R.string.cancel); setOnClickListener { setTapMode(false) } }
val cancelWrapper = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
setPadding(0, 0, 0, (24 * dp).toInt())
addView(cancelBtn)
}
val container = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
addView(View(requireContext()).apply { // spacer pushes content below card
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)
})
addView(animView.apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 3f)
})
addView(cancelWrapper.apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
})
}
binding.flTapMode.addView(container)
// Animate card up from carousel position (same as manage mode)
binding.contentLayout.doOnNextLayout {
val mgr = binding.manageCardView.root
val dstLoc = IntArray(2).also { mgr.getLocationOnScreen(it) }
val dstTop = (dstLoc[1] - contentLoc[1]).toFloat()
val dstCenterX = (dstLoc[0] - contentLoc[0]).toFloat() + mgr.width / 2f
mgr.pivotX = mgr.width / 2f
mgr.pivotY = 0f
mgr.scaleX = if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f
mgr.scaleY = mgr.scaleX
mgr.translationX = srcCenterX - dstCenterX
mgr.translationY = srcScreenTop - dstTop
mgr.animate()
.scaleX(1f).scaleY(1f)
.translationX(0f).translationY(0f)
.setDuration(380).setInterpolator(DecelerateInterpolator()).start()
val textDstLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
binding.tvSelectedCardType.translationY = textSrcScreenTop - (textDstLoc[1] - contentLoc[1]).toFloat()
binding.tvSelectedCardType.animate()
.translationY(0f)
.setDuration(380).setInterpolator(DecelerateInterpolator()).start()
}
fetchAndArmToken(item)
}
private fun exitTapMode() {
tapAnimView?.stopAnimation()
tapAnimView = null
BmlHostCardEmulatorService.clearToken()
BmlHostCardEmulatorService.onTransactionComplete = null
binding.manageCardView.root.animate().cancel()
binding.tvSelectedCardType.animate().cancel()
val mgr = binding.manageCardView.root
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
val mgrLoc = IntArray(2).also { mgr.getLocationOnScreen(it) }
val mgrLayoutTop = (mgrLoc[1] - contentLoc[1]).toFloat() - mgr.translationY
val mgrLayoutCenterX = (mgrLoc[0] - contentLoc[0]).toFloat() - mgr.translationX + mgr.width / 2f
val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
val textLayoutTop = (textLoc[1] - contentLoc[1]).toFloat() - binding.tvSelectedCardType.translationY
mgr.pivotX = mgr.width / 2f
mgr.pivotY = 0f
mgr.animate()
.scaleX(if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f)
.scaleY(if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f)
.translationX(carouselCardCenterX - mgrLayoutCenterX)
.translationY(carouselCardLayoutTop - mgrLayoutTop)
.setDuration(320)
.setInterpolator(AccelerateInterpolator())
.withEndAction {
mgr.scaleX = 1f; mgr.scaleY = 1f
mgr.translationX = 0f; mgr.translationY = 0f
mgr.visibility = View.GONE
binding.tvSelectedCardType.translationY = 0f
binding.flTapMode.visibility = View.GONE
binding.flTapMode.removeAllViews()
binding.btnManageCard.visibility = View.VISIBLE
binding.topSpacer.visibility = View.VISIBLE
binding.rvCards.visibility = View.VISIBLE
binding.divider.visibility = View.VISIBLE
binding.llPayButtons.visibility = View.VISIBLE
buildDots(cards.size, currentCardPosition)
}
.start()
binding.tvSelectedCardType.animate()
.translationY(carouselTextLayoutTop - textLayoutTop)
.setDuration(320)
.setInterpolator(AccelerateInterpolator())
.withEndAction { binding.tvSelectedCardType.translationY = 0f }
.start()
}
private fun fetchAndArmToken(item: CardItem.Bml) {
val app = requireActivity().application as BasedBankApp
viewLifecycleOwner.lifecycleScope.launch {
val loginId = item.account.loginTag.removePrefix("bml_")
val session = app.bmlSessionFor(item.account)
val otpSeed = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
if (session == null || otpSeed == null) {
if (isTapMode) {
Toast.makeText(requireContext(),
if (session == null) getString(R.string.transfer_session_unavailable)
else "OTP unavailable",
Toast.LENGTH_SHORT).show()
setTapMode(false)
}
return@launch
}
(activity as? HomeActivity)?.setRefreshing(true)
val otp = Totp.generate(otpSeed)
val result = withContext(Dispatchers.IO) {
runCatching { BmlTapToPayClient().fetchTokens(session, item.account.internalId, otp) }
}
(activity as? HomeActivity)?.setRefreshing(false)
val token = result.getOrNull()?.firstOrNull()
if (!isTapMode) return@launch // user cancelled while we were fetching
if (token == null) {
if (isTapMode) {
Toast.makeText(requireContext(),
result.exceptionOrNull()?.message ?: "Failed to get payment token",
Toast.LENGTH_SHORT).show()
setTapMode(false)
}
return@launch
}
BmlHostCardEmulatorService.setToken(token)
BmlHostCardEmulatorService.onTransactionComplete = { success ->
view?.post {
if (!isTapMode) return@post
setTapMode(false)
if (success) Toast.makeText(requireContext(), "Payment complete", Toast.LENGTH_SHORT).show()
}
}
}
}
private fun rebuildCards() {
// Remember which card is currently selected by identity so we can restore position after reorder
val currentCard = cards.getOrNull(currentCardPosition)
val defaultNum = store.getDefaultCardAccountNumber()
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
val bmlItems = (viewModel.accounts.value ?: emptyList())
.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
.map { CardItem.Bml(it) }
val all: List<CardItem> = mibItems + bmlItems
// Move default BML card to front
cards = if (defaultNum != null) {
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
if (def != null) listOf(def) + all.filter { it !== def } else all
} else all
// Restore position to follow the same card after reorder
if (currentCard != null) {
val newPos = cards.indexOf(currentCard)
if (newPos >= 0 && newPos != currentCardPosition) {
currentCardPosition = newPos
binding.rvCards.scrollToPosition(newPos)
}
}
stackAdapter.update(cards)
binding.loadingView.visibility = View.GONE
val empty = cards.isEmpty()
binding.emptyView.visibility = if (empty) View.VISIBLE else View.GONE
binding.contentLayout.visibility = if (empty) View.GONE else View.VISIBLE
if (!empty) {
buildDots(cards.size, currentCardPosition)
updateCardInfo(currentCardPosition)
}
// Auto-enter tap mode when launched from shortcut, NFC prompt, or dashboard
if (!autoTapModeTriggered && arguments?.getBoolean(ARG_AUTO_TAP_MODE) == true) {
val targetAccount = arguments?.getString(ARG_AUTO_TAP_ACCOUNT)
val targetCard = if (targetAccount != null)
cards.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == targetAccount }
else
cards.filterIsInstance<CardItem.Bml>().firstOrNull()
if (targetCard != null) {
autoTapModeTriggered = true
// Scroll to the target card first
val pos = cards.indexOf(targetCard)
if (pos >= 0) {
currentCardPosition = pos
binding.rvCards.scrollToPosition(pos)
}
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
showBiometricPromptForTap(targetCard)
} else {
setTapMode(true, targetCard)
}
}
}
}
private fun applyCardScales() {
val rv = binding.rvCards
val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f
@@ -745,7 +173,6 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
}
private fun buildDots(count: Int, selected: Int) {
if (isManageMode || isTapMode) return
binding.pageIndicator.removeAllViews()
if (count <= 1) {
binding.pageIndicator.visibility = View.GONE
@@ -780,36 +207,12 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
}
}
fun onBackPressed(): Boolean {
if (isTapMode) {
setTapMode(false)
return true
}
if (isManageMode) {
setManageMode(false)
return true
}
return false
}
override fun onPause() {
super.onPause()
if (isTapMode) {
BmlHostCardEmulatorService.clearToken()
BmlHostCardEmulatorService.onTransactionComplete = null
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_pay_with_card)
}
override fun onDestroyView() {
tapAnimView?.stopAnimation()
tapAnimView = null
BmlHostCardEmulatorService.clearToken()
BmlHostCardEmulatorService.onTransactionComplete = null
super.onDestroyView()
_binding = null
}
@@ -871,103 +274,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
}
}
// ── NFC animation view ─────────────────────────────────────────────────────
private inner class NfcTapAnimationView(context: Context) : View(context) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val animator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = 1600
repeatCount = ValueAnimator.INFINITE
repeatMode = ValueAnimator.RESTART
addUpdateListener { invalidate() }
start()
}
fun stopAnimation() = animator.cancel()
override fun onDraw(canvas: Canvas) {
val w = width.toFloat(); val h = height.toFloat()
if (w <= 0f || h <= 0f) return
val dp = resources.displayMetrics.density
val progress = animator.animatedFraction
val cx = w / 2f; val cy = h / 2f + 24 * dp
val colorOnSurface = MaterialColors.getColor(this,
com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK)
val colorPrimary = MaterialColors.getColor(this,
com.google.android.material.R.attr.colorPrimary, android.graphics.Color.BLUE)
val colorSurfaceVariant = MaterialColors.getColor(this,
com.google.android.material.R.attr.colorSurfaceVariant, android.graphics.Color.LTGRAY)
// POS terminal (top center)
val posW = 44 * dp; val posH = 72 * dp
val posX = cx - posW / 2f; val posY = cy - 170 * dp
// Phone (bottom center)
val phoneW = 52 * dp; val phoneH = 90 * dp
val phoneX = cx - phoneW / 2f; val phoneY = cy + 30 * dp
// POS terminal body
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
canvas.drawRoundRect(posX, posY, posX + posW, posY + posH, 7 * dp, 7 * dp, paint)
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * dp; paint.color = colorOnSurface
canvas.drawRoundRect(posX, posY, posX + posW, posY + posH, 7 * dp, 7 * dp, paint)
// POS screen
paint.style = Paint.Style.FILL; paint.color = colorPrimary; paint.alpha = 70
canvas.drawRoundRect(posX + 4 * dp, posY + 6 * dp,
posX + posW - 4 * dp, posY + posH * 0.45f, 4 * dp, 4 * dp, paint)
paint.alpha = 255
// POS card slot
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2 * dp; paint.color = colorOnSurface
canvas.drawLine(posX + 6 * dp, posY + posH * 0.72f, posX + posW - 6 * dp, posY + posH * 0.72f, paint)
// Phone body
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
canvas.drawRoundRect(phoneX, phoneY, phoneX + phoneW, phoneY + phoneH, 8 * dp, 8 * dp, paint)
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * dp; paint.color = colorOnSurface
canvas.drawRoundRect(phoneX, phoneY, phoneX + phoneW, phoneY + phoneH, 8 * dp, 8 * dp, paint)
// Phone screen
paint.style = Paint.Style.FILL; paint.color = colorPrimary; paint.alpha = 70
canvas.drawRoundRect(phoneX + 4 * dp, phoneY + 10 * dp,
phoneX + phoneW - 4 * dp, phoneY + phoneH - 15 * dp, 4 * dp, 4 * dp, paint)
paint.alpha = 255
// Animated NFC rings originating from phone top, travelling upward toward POS
val gapTop = posY + posH + 4 * dp
val originY = phoneY
val maxR = (originY - gapTop) - 4 * dp
paint.style = Paint.Style.STROKE; paint.strokeWidth = 3 * dp
for (i in 0..2) {
val p = ((progress + i / 3f) % 1f)
val r = (p * maxR + 6 * dp).coerceAtMost(maxR)
paint.color = colorPrimary; paint.alpha = ((1f - p) * 200).toInt().coerceIn(0, 255)
canvas.drawArc(RectF(cx - r, originY - r, cx + r, originY + r), -160f, 140f, false, paint)
}
paint.alpha = 255
// Label
paint.style = Paint.Style.FILL; paint.color = colorOnSurface; paint.alpha = 160
paint.textSize = 15 * dp; paint.textAlign = Paint.Align.CENTER
canvas.drawText(context.getString(R.string.card_pay_nfc), cx, phoneY + phoneH + 28 * dp, paint)
paint.alpha = 255; paint.textAlign = Paint.Align.LEFT
}
}
companion object {
private const val ARG_AUTO_TAP_MODE = "auto_tap_mode"
private const val ARG_AUTO_TAP_ACCOUNT = "auto_tap_account"
fun newInstanceWithAutoTapMode(accountNumber: String? = null) = CardsFragment().apply {
arguments = Bundle().apply {
putBoolean(ARG_AUTO_TAP_MODE, true)
if (accountNumber != null) putString(ARG_AUTO_TAP_ACCOUNT, accountNumber)
}
}
fun cardImageAsset(card: MibCard): String? = when (card.cardType) {
"51" -> "cards/mib/faisa_card.png"
"53" -> "cards/mib/visa_black_platinum.png"
@@ -998,12 +305,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
if (statusLabel == null) { tv.visibility = View.GONE; return }
tv.visibility = View.VISIBLE
tv.text = statusLabel
val dp = tv.context.resources.displayMetrics.density
tv.background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = 12 * dp
setColor(0xCC212121.toInt())
}
tv.setBackgroundResource(R.drawable.card_overlay_pill_bg)
}
fun formatMasked(masked: String): String {
@@ -2,9 +2,7 @@ package sh.sar.basedbank.ui.home
import android.content.Context
import android.content.SharedPreferences
import android.graphics.Color
import android.os.Bundle
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -12,7 +10,6 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
import androidx.core.os.LocaleListCompat
@@ -21,11 +18,8 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentSettingsAppearanceBinding
import sh.sar.basedbank.util.ThemeHelper
import java.util.Collections
class SettingsAppearanceFragment : Fragment() {
@@ -109,45 +103,8 @@ class SettingsAppearanceFragment : Fragment() {
}
prefs.edit().putString("theme", key).apply()
AppCompatDelegate.setDefaultNightMode(mode)
updateAccentState(key == "system")
updatePitchBlackState(key == "dark")
}
// Pitch black
binding.switchPitchBlack.isChecked = prefs.getBoolean("pitch_black", false)
binding.switchPitchBlack.setOnCheckedChangeListener { _, checked ->
prefs.edit().putBoolean("pitch_black", checked).apply()
requireActivity().recreate()
}
val isDark = prefs.getString("theme", "system") == "dark"
updatePitchBlackState(isDark)
// Accent color
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
binding.accentToggle.check(when (savedPreset) {
ThemeHelper.PRESET_RED -> R.id.btnAccentOrange
ThemeHelper.PRESET_GREEN -> R.id.btnAccentGreen
ThemeHelper.PRESET_CUSTOM -> R.id.btnAccentCustom
else -> R.id.btnAccentBlue
})
binding.accentToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener
val preset = when (checkedId) {
R.id.btnAccentOrange -> ThemeHelper.PRESET_RED
R.id.btnAccentGreen -> ThemeHelper.PRESET_GREEN
R.id.btnAccentCustom -> ThemeHelper.PRESET_CUSTOM
else -> ThemeHelper.PRESET_BLUE
}
if (preset == ThemeHelper.PRESET_CUSTOM) {
showCustomColorPicker()
} else {
prefs.edit().putString("accent_preset", preset).apply()
requireActivity().recreate()
}
}
val isSystem = prefs.getString("theme", "system") == "system"
updateAccentState(isSystem)
// Language
val currentLocales = AppCompatDelegate.getApplicationLocales()
val currentLang = if (currentLocales.isEmpty) "en" else currentLocales[0]?.language ?: "en"
@@ -199,68 +156,6 @@ class SettingsAppearanceFragment : Fragment() {
slotAdapter.notifyDataSetChanged()
}
private fun updatePitchBlackState(isDark: Boolean) {
binding.rowPitchBlack.alpha = if (isDark) 1f else 0.38f
binding.switchPitchBlack.isEnabled = isDark
}
private fun updateAccentState(isSystem: Boolean) {
binding.sectionAccentColor.alpha = if (isSystem) 0.38f else 1f
for (i in 0 until binding.accentToggle.childCount) {
binding.accentToggle.getChildAt(i)?.isEnabled = !isSystem
}
}
private fun showCustomColorPicker() {
val ctx = requireContext()
val currentHex = prefs.getString("accent_custom_color", "") ?: ""
val inputLayout = TextInputLayout(ctx).apply {
hint = getString(R.string.accent_custom_hint)
val pad = (16 * resources.displayMetrics.density).toInt()
setPadding(pad, pad / 2, pad, 0)
}
val input = TextInputEditText(ctx).apply {
setText(currentHex)
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
setSingleLine(true)
}
inputLayout.addView(input)
val dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.accent_custom_pick)
.setView(inputLayout)
.setPositiveButton(android.R.string.ok, null)
.setNegativeButton(R.string.cancel) { _, _ -> revertAccentToggle() }
.setOnCancelListener { revertAccentToggle() }
.show()
dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val raw = input.text.toString().trim()
val hex = if (raw.startsWith("#")) raw else "#$raw"
try {
Color.parseColor(hex)
prefs.edit()
.putString("accent_preset", ThemeHelper.PRESET_CUSTOM)
.putString("accent_custom_color", hex)
.apply()
dialog.dismiss()
requireActivity().recreate()
} catch (_: Exception) {
Toast.makeText(ctx, R.string.accent_invalid_color, Toast.LENGTH_SHORT).show()
}
}
}
private fun revertAccentToggle() {
val saved = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
binding.accentToggle.check(when (saved) {
ThemeHelper.PRESET_RED -> R.id.btnAccentOrange
ThemeHelper.PRESET_GREEN -> R.id.btnAccentGreen
ThemeHelper.PRESET_CUSTOM -> R.id.btnAccentCustom
else -> R.id.btnAccentBlue
})
}
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
val isBottom = prefs.getBoolean("bottom_nav", false)
if (items === slots && !isBottom) return
@@ -273,7 +168,6 @@ class SettingsAppearanceFragment : Fragment() {
LayoutInflater.from(ctx).inflate(R.layout.item_more_nav, listLayout, false).also { row ->
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
row.findViewById<TextView>(R.id.tvDescription).visibility = View.GONE
listLayout.addView(row)
}
}
@@ -1,10 +1,7 @@
package sh.sar.basedbank.ui.home
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.provider.Settings as AndroidSettings
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
@@ -18,7 +15,6 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@@ -81,29 +77,6 @@ class SettingsLoginsFragment : Fragment() {
handlePickedImage(bitmap)
}
private val cameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) cameraLauncher.launch(cameraPhotoUri ?: return@registerForActivityResult)
else showCameraPermissionRationale()
}
private fun showCameraPermissionRationale() {
val ctx = requireContext()
MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.qr_camera_permission_title)
.setMessage(R.string.camera_permission_profile_message)
.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() }
.setPositiveButton(R.string.go_to_settings) { _, _ ->
startActivity(
Intent(AndroidSettings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", ctx.packageName, null)
}
)
}
.show()
}
private fun loadAndScaleBitmap(uri: Uri): Bitmap? {
return try {
val ctx = requireContext()
@@ -186,11 +159,7 @@ class SettingsLoginsFragment : Fragment() {
val photoFile = File(ctx.cacheDir, "profile_photo_tmp.jpg")
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", photoFile)
cameraPhotoUri = uri
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
cameraLauncher.launch(uri)
} else {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
cameraLauncher.launch(uri)
}
if (target is PendingImageTarget.Mib || currentBitmap != null || hasSavedImage(ctx, target)) {
items += Triple(R.drawable.ic_delete, getString(R.string.profile_image_remove)) {
@@ -86,6 +86,17 @@ class SettingsSecurityFragment : Fragment() {
(activity as? HomeActivity)?.resetAutolockTimer()
}
// Hide sensitive information (enables/disables the eye icon in toolbar)
val viewModel = (requireActivity() as HomeActivity).let {
androidx.lifecycle.ViewModelProvider(it)[HomeViewModel::class.java]
}
binding.switchHideAmounts.isChecked = prefs.getBoolean("hide_sensitive_info", false)
binding.switchHideAmounts.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("hide_sensitive_info", isChecked).apply()
if (!isChecked) viewModel.hideAmounts.value = false
requireActivity().invalidateOptionsMenu()
}
// Block screenshots
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
binding.switchBlockScreenshots.isChecked = blockScreenshots
@@ -92,18 +92,9 @@ class TransferFragment : Fragment() {
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
private var selectedFahipayService: String? = null
// Form state preserved across view destroy/create when the fragment instance is cached
private var savedAmount = ""
private var savedRemarks = ""
private var savedToText = ""
private var savedToSubtitle = ""
private var savedToColorHex = "#607D8B"
private var savedToImageHash: String? = null
// BML QR merchant payment mode (set when navigated from a card QR scan)
private var bmlQrInfo: BmlQrPayInfo? = null
private var bmlGatewayQr = false // true for pay.bml.com.mv QRs (requires pre-initiate step)
private var bmlQrLookupAttempted = false // prevents re-lookup after user clears the merchant
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
// BML business profile OTP flow state
@@ -134,12 +125,9 @@ class TransferFragment : Fragment() {
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
// BML card/gateway QR — hand off to dedicated payment screen
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
val fromCard = selectedAccount?.takeIf {
it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT"
}
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, fromCard?.accountNumber))
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
raw.startsWith("https://pay.bml.com.mv/app/")) {
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw))
return@registerForActivityResult
}
@@ -148,28 +136,6 @@ class TransferFragment : Fragment() {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
return@registerForActivityResult
}
// Cards can't pay PayMV QR — fall back to default account or clear selection
val isCard = selectedAccount?.let {
it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT"
} ?: false
if (isCard) {
Toast.makeText(requireContext(), R.string.card_qr_paymv_unsupported, Toast.LENGTH_SHORT).show()
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
val defaultAcc = defaultNum?.let { num -> viewModel.accounts.value?.firstOrNull { it.accountNumber == num } }
selectedAccount = defaultAcc
binding.tilAmount.prefixText = null
if (defaultAcc != null) {
updateAmountPrefix(defaultAcc)
showFromCard(defaultAcc)
} else {
binding.cardFromInfo.visibility = View.GONE
binding.tilFrom.visibility = View.VISIBLE
binding.actvFrom.setText("", false)
}
updateTransferButton()
}
if (qr.amount != null) binding.etAmount.setText(qr.amount)
if (qr.purpose != null) binding.etRemarks.setText(qr.purpose)
prefillToFromContact(qr.accountNumber, "")
@@ -222,15 +188,13 @@ class TransferFragment : Fragment() {
accountNumber: String,
displayName: String,
amount: String?,
remarks: String?,
fromAccountNumber: String? = null
remarks: String?
) = TransferFragment().apply {
arguments = Bundle().apply {
putString(ARG_ACCOUNT, accountNumber)
putString(ARG_NAME, displayName)
putString(ARG_SUBTITLE, accountNumber)
putString(ARG_COLOR, "#607D8B")
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
}
@@ -254,27 +218,11 @@ class TransferFragment : Fragment() {
childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener
if (accountNumber.startsWith("bmlqr:")) {
lookupBmlQrMerchant(accountNumber.removePrefix("bmlqr:"))
return@setFragmentResultListener
}
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash)
if (selectedAccount == null) {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
if (defaultNum != null) {
val defaultAcc = viewModel.accounts.value?.firstOrNull { it.accountNumber == defaultNum }
if (defaultAcc != null) {
selectedAccount = defaultAcc
updateAmountPrefix(defaultAcc)
showFromCard(defaultAcc)
updateTransferButton()
}
}
}
}
binding.btnPickContact.setOnClickListener {
@@ -314,37 +262,9 @@ class TransferFragment : Fragment() {
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
// Restore form state when view is recreated on the cached no-args instance
if (arguments == null) {
if (resolvedAccountNumber.isNotEmpty()) {
val ownAccount = viewModel.accounts.value?.firstOrNull { it.accountNumber == resolvedAccountNumber }
if (ownAccount != null) {
showToCard(ownAccount)
} else {
binding.tvToAccountName.text = resolvedRecipientName
binding.tvToBankBic.text = savedToSubtitle
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(resolvedRecipientName, savedToColorHex))
}
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE
if (savedToImageHash != null) loadToPhoto(savedToImageHash!!, isProfile = resolvedToOwnAccount != null)
} else if (savedToText.isNotEmpty()) {
binding.etTo.setText(savedToText)
}
if (savedAmount.isNotEmpty()) binding.etAmount.setText(savedAmount)
if (savedRemarks.isNotEmpty()) binding.etRemarks.setText(savedRemarks)
updateTransferButton()
}
}
private fun lookupBmlQrMerchant(qrUrl: String) {
bmlQrLookupAttempted = true
bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/")
val base64Url = android.util.Base64.encodeToString(
qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP)
@@ -369,35 +289,6 @@ class TransferFragment : Fragment() {
return@launch
}
bmlQrInfo = info
if (info.amount == 0.0) {
RecentsCache.save(requireContext(), RecentPick(
accountNumber = "bmlqr:$qrUrl",
displayName = info.merchantName,
subtitle = info.merchantAddress.ifBlank { "BML Merchant" },
colorHex = "#0066A1",
imageHash = null,
isProfileImage = false
))
}
// Auto-select the user's default BML card if no card was pre-selected
if (selectedAccount == null) {
val defaultNum = CredentialStore(requireContext()).getDefaultCardAccountNumber()
if (defaultNum != null) {
val allAccounts = viewModel.accounts.value ?: emptyList()
val defaultCard = allAccounts.firstOrNull {
it.accountNumber == defaultNum &&
(it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") &&
it.statusDesc.equals("Active", ignoreCase = true)
}
if (defaultCard != null) {
selectedAccount = defaultCard
updateAmountPrefix(defaultCard)
showFromCard(defaultCard)
updateTransferButton()
}
}
}
// Show merchant in the "To" card — clear button hidden (can't change recipient for QR)
binding.tvToAccountName.text = info.merchantName
@@ -406,6 +297,7 @@ class TransferFragment : Fragment() {
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(info.merchantName, "#0066A1"))
binding.btnClearToInfo.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE
// Pre-fill amount if dynamic QR
@@ -461,14 +353,6 @@ class TransferFragment : Fragment() {
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
val picked = accountDropdownAdapter?.getAccount(position) ?: return@setOnItemClickListener
if (bmlQrInfo != null) {
val isCard = picked.profileType == "BML_PREPAID" || picked.profileType == "BML_CREDIT" || picked.profileType == "BML_DEBIT"
if (!isCard) {
Toast.makeText(requireContext(), "Unsupported for BML QR — select a card", Toast.LENGTH_SHORT).show()
binding.actvFrom.setText("", false)
return@setOnItemClickListener
}
}
selectedAccount = picked
updateAmountPrefix(picked)
showFromCard(picked)
@@ -485,35 +369,6 @@ class TransferFragment : Fragment() {
updateTransferButton()
}
}
// Auto-select default account when arriving from contacts page (TO account already pre-filled)
if (selectedAccount == null && arguments?.getString(ARG_ACCOUNT) != null) {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
if (defaultNum != null) {
val defaultAcc = accounts.firstOrNull { it.accountNumber == defaultNum }
if (defaultAcc != null) {
selectedAccount = defaultAcc
updateAmountPrefix(defaultAcc)
showFromCard(defaultAcc)
updateTransferButton()
}
}
}
// On a cold start (e.g. share intent), anyBmlSession() may be null when
// onViewCreated runs. Retry the lookup once sessions are available.
val pendingBmlQrUrl = arguments?.getString(ARG_BML_QR_URL)
if (pendingBmlQrUrl != null && !bmlQrLookupAttempted) {
val app = requireActivity().application as BasedBankApp
if (app.anyBmlSession() != null) lookupBmlQrMerchant(pendingBmlQrUrl)
}
// Re-render the from card when the view is recreated on a cached instance
if (selectedAccount != null && binding.cardFromInfo.visibility != View.VISIBLE) {
updateAmountPrefix(selectedAccount!!)
showFromCard(selectedAccount!!)
updateTransferButton()
}
}
}
@@ -651,14 +506,6 @@ class TransferFragment : Fragment() {
binding.tilTo.setEndIconOnClickListener { lookupAccount() }
binding.btnClearToInfo.setOnClickListener {
if (bmlQrInfo != null) {
bmlQrInfo = null
bmlGatewayQr = false
binding.tilAmount.isEnabled = true
binding.tilRemarks.isEnabled = true
binding.tilRemarks.alpha = 1f
binding.etAmount.setText("")
}
resolvedAccountNumber = ""
resolvedRecipientName = ""
resolvedToOwnAccount = null
@@ -689,21 +536,8 @@ class TransferFragment : Fragment() {
private fun lookupAccount() {
if (selectedAccount == null) {
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
if (defaultNum != null) {
val allAccounts = viewModel.accounts.value ?: emptyList()
val defaultAcc = allAccounts.firstOrNull { it.accountNumber == defaultNum }
if (defaultAcc != null) {
selectedAccount = defaultAcc
updateAmountPrefix(defaultAcc)
showFromCard(defaultAcc)
updateTransferButton()
}
}
if (selectedAccount == null) {
Toast.makeText(requireContext(), R.string.transfer_no_from_account, Toast.LENGTH_SHORT).show()
return
}
Toast.makeText(requireContext(), R.string.transfer_select_source_first, Toast.LENGTH_SHORT).show()
return
}
val accountNumber = AccountInputParser.normalize(binding.etTo.text?.toString()?.trim() ?: "")
if (accountNumber.isBlank()) {
@@ -799,13 +633,6 @@ class TransferFragment : Fragment() {
resolvedAccountNumber = info.accountNumber
resolvedRecipientName = info.accountName
resolvedBankName = info.bankId
savedToSubtitle = "${info.accountNumber} · ${info.bankId}"
savedToColorHex = colorHex
savedToImageHash = when {
matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash
matchedCont?.customerImgHash != null -> matchedCont.customerImgHash
else -> null
}
if (matchedAcc != null) {
showToCard(matchedAcc)
@@ -938,9 +765,6 @@ class TransferFragment : Fragment() {
) {
resolvedAccountNumber = accountNumber
resolvedRecipientName = displayName
savedToSubtitle = subtitle
savedToColorHex = colorHex
savedToImageHash = imageHash
val contacts = viewModel.contacts.value ?: emptyList()
resolvedBankName = contacts.firstOrNull { it.benefAccount == accountNumber }?.benefBankName ?: ""
@@ -996,20 +820,13 @@ class TransferFragment : Fragment() {
message: String? = null,
customView: android.view.View? = null,
biometricSubtitle: String,
onConfirmed: (AlertDialog, android.widget.FrameLayout) -> Unit
onConfirmed: () -> Unit
) {
val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
imm.hideSoftInputFromWindow(requireView().windowToken, 0)
val frame = android.widget.FrameLayout(requireContext())
if (customView != null) frame.addView(customView)
val builder = MaterialAlertDialogBuilder(requireContext())
.setTitle(title)
.setPositiveButton(R.string.transfer_confirm, null)
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
.setCancelable(false)
if (customView != null) builder.setView(frame) else builder.setMessage(message)
.setPositiveButton(R.string.transfer_confirm) { _, _ -> onConfirmed() }
.setNegativeButton(android.R.string.cancel, null)
if (customView != null) builder.setView(customView) else builder.setMessage(message)
val dialog = builder.show()
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false)
@@ -1020,7 +837,8 @@ class TransferFragment : Fragment() {
val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
onConfirmed(dialog, frame)
dialog.dismiss()
onConfirmed()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (errorCode != BiometricPrompt.ERROR_CANCELED &&
@@ -1039,10 +857,6 @@ class TransferFragment : Fragment() {
.build()
)
}
} else {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
onConfirmed(dialog, frame)
}
}
}
@@ -1061,27 +875,11 @@ class TransferFragment : Fragment() {
Toast.makeText(requireContext(), R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
return
}
val qrFromTypeLabel = AccountListParser.from(src)?.typeLabel
?: BmlDashboardParser.productLabel(src.accountTypeName)
val qrFromDetail = listOfNotNull("BML", qrFromTypeLabel.ifBlank { null }).joinToString(" · ")
val qrConfirmView = buildTransferConfirmView(
amountCurrency = info.currency,
amountValue = "%.2f".format(amount),
fromName = src.accountBriefName,
fromNumber = src.accountNumber,
fromDetail = qrFromDetail,
toName = info.merchantName,
toNumber = "",
toDetail = info.merchantAddress.ifBlank { "BML Merchant" }
)
showConfirmWithBiometric(
title = getString(R.string.transfer),
customView = qrConfirmView,
message = "Pay ${info.currency} ${"%.2f".format(amount)} to ${info.merchantName}?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}",
biometricSubtitle = "${info.currency} ${"%.2f".format(amount)}${info.merchantName}",
onConfirmed = { dialog, frame ->
showProcessingInDialog(dialog, frame)
executeBmlQrPayment(src, debitAccount, info, amount, dialog, frame)
}
onConfirmed = { executeBmlQrPayment(src, debitAccount, info, amount) }
)
return
}
@@ -1136,17 +934,18 @@ class TransferFragment : Fragment() {
val isUsdToMvr = currency.equals("USD", ignoreCase = true) && destCurrency.equals("MVR", ignoreCase = true)
val isSrcCredit = src.profileType == "BML_CREDIT"
val doTransfer: (AlertDialog, android.widget.FrameLayout) -> Unit = { dialog, frame ->
val mainMsg = "Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}"
val doTransfer: () -> Unit = {
if (isBmlBusiness) {
// Business profile: async OTP channel selection flow — dismiss dialog first
dialog.dismiss()
// Business profile: async OTP channel selection flow
startBmlBusinessOtpFlow(
src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks,
isSrcCard, isDestMib, currency, allAccounts, allContacts, capturedToAvatar
)
} else {
showProcessingInDialog(dialog, frame)
binding.btnTransfer.isEnabled = false
(activity as? HomeActivity)?.setRefreshing(true)
viewLifecycleOwner.lifecycleScope.launch {
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
if (!isSrcBml) {
@@ -1156,15 +955,14 @@ class TransferFragment : Fragment() {
}
}
binding.btnTransfer.isEnabled = true
(activity as? HomeActivity)?.setRefreshing(false)
if (ok && receipt != null) {
ReceiptStore.save(requireContext(), receipt)
clearForm()
val activity = requireActivity() as HomeActivity
activity.triggerRefresh()
dialog.dismiss()
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
} else if (!ok) {
dialog.dismiss()
if (msg == "CONNECTIVITY") {
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
} else {
@@ -1175,202 +973,56 @@ class TransferFragment : Fragment() {
}
}
val fromTypeLabel = AccountListParser.from(src)?.typeLabel
?: if (src.bank == "BML") BmlDashboardParser.productLabel(src.accountTypeName)
else src.accountTypeName.ifBlank { src.profileType }
val fromBankLabel = when (src.bank) {
"BML" -> "BML"
"FAHIPAY" -> "Fahipay"
"MIB" -> "MIB"
else -> src.bank
}
val fromDetail = listOfNotNull(fromBankLabel.ifBlank { null }, fromTypeLabel.ifBlank { null }).joinToString(" · ")
val toTypeLabel = resolvedToOwnAccount?.let { acc ->
AccountListParser.from(acc)?.typeLabel
?: if (acc.bank == "BML") BmlDashboardParser.productLabel(acc.accountTypeName)
else acc.accountTypeName.ifBlank { acc.profileType }
}
val toBankLabel = resolvedToOwnAccount?.let { acc ->
when (acc.bank) {
"BML" -> "BML"
"FAHIPAY" -> "Fahipay"
"MIB" -> "MIB"
else -> acc.bank
}
} ?: when {
bankNameCapture.equals("MALBMVMV", ignoreCase = true) -> "BML"
bankNameCapture.equals("MADVMVMV", ignoreCase = true) -> "MIB"
bankNameCapture.isNotBlank() -> bankNameCapture
isDestMib -> "MIB"
else -> when (selectedFahipayService) {
"RAASTAS" -> "Ooredoo · Raastas"
"OOREDOO_BILL" -> "Ooredoo · Bill Pay"
"DHIRAAGU_RELOAD" -> "Dhiraagu · Reload"
"DHIRAAGU_BILL" -> "Dhiraagu · Bill Pay"
else -> ""
}
}
val toDetail = listOfNotNull(toBankLabel.ifBlank { null }, toTypeLabel?.ifBlank { null }).joinToString(" · ")
val warnings = buildList {
if (isUsdToMvr) add("⚠ 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!")
if (isSrcCredit) add("⚠ Transferring from a credit card is treated as a cash advance. Cash advance fees will be charged on the 10th of the month.")
}
val confirmView = buildTransferConfirmView(
amountCurrency = currency,
amountValue = "%.2f".format(amount),
fromName = src.accountBriefName,
fromNumber = src.accountNumber,
fromDetail = fromDetail,
toName = destDisplay,
toNumber = resolvedAccountNumber,
toDetail = toDetail,
warningTexts = warnings
)
showConfirmWithBiometric(
title = getString(R.string.transfer),
customView = confirmView,
biometricSubtitle = "$currency ${"%.2f".format(amount)}$destDisplay",
onConfirmed = { dialog, frame -> doTransfer(dialog, frame) }
)
}
private fun buildTransferConfirmView(
amountCurrency: String,
amountValue: String,
fromName: String,
fromNumber: String,
fromDetail: String,
toName: String,
toNumber: String,
toDetail: String,
warningTexts: List<String> = emptyList()
): android.view.View {
val ctx = requireContext()
val dp = resources.displayMetrics.density
val colorOnSurface = com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorOnSurface, Color.BLACK)
val colorMuted = com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
val colorPrimary = com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorPrimary, Color.BLUE)
val MATCH = LinearLayout.LayoutParams.MATCH_PARENT
val WRAP = LinearLayout.LayoutParams.WRAP_CONTENT
fun lp(w: Int = MATCH, h: Int = WRAP, init: LinearLayout.LayoutParams.() -> Unit = {}) =
LinearLayout.LayoutParams(w, h).apply(init)
fun accountBlock(label: String, name: String, number: String, detail: String) =
val warningView: android.view.View? = if (isUsdToMvr || isSrcCredit) {
val ctx = requireContext()
val dp = resources.displayMetrics.density
LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
layoutParams = lp()
addView(TextView(ctx).apply {
text = label
textSize = 10f
isAllCaps = true
letterSpacing = 0.12f
setTextColor(colorMuted)
gravity = Gravity.CENTER
})
addView(TextView(ctx).apply {
text = name
textSize = 16f
setTypeface(null, Typeface.BOLD)
setTextColor(colorOnSurface)
gravity = Gravity.CENTER
layoutParams = lp { topMargin = (2 * dp).toInt() }
})
if (number.isNotBlank()) addView(TextView(ctx).apply {
text = number
textSize = 13f
setTextColor(colorMuted)
gravity = Gravity.CENTER
})
if (detail.isNotBlank()) addView(TextView(ctx).apply {
text = detail
textSize = 12f
setTextColor(colorMuted)
gravity = Gravity.CENTER
alpha = 0.75f
})
}
return LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
setPadding((20 * dp).toInt(), (8 * dp).toInt(), (20 * dp).toInt(), (8 * dp).toInt())
// Currency + amount on same line, centered, baseline-aligned
addView(LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_HORIZONTAL
layoutParams = lp { bottomMargin = (20 * dp).toInt() }
addView(TextView(ctx).apply {
text = "$amountCurrency "
textSize = 16f
setTextColor(colorMuted)
layoutParams = LinearLayout.LayoutParams(WRAP, WRAP)
})
addView(TextView(ctx).apply {
text = amountValue
textSize = 34f
setTypeface(null, Typeface.BOLD)
setTextColor(colorPrimary)
})
})
addView(accountBlock("From", fromName, fromNumber, fromDetail))
// Down arrow — centered
addView(ImageView(ctx).apply {
setImageResource(R.drawable.ic_arrow_right)
rotation = 90f
setColorFilter(colorMuted)
layoutParams = lp(WRAP, WRAP) {
gravity = Gravity.CENTER_HORIZONTAL
width = (24 * dp).toInt()
height = (24 * dp).toInt()
topMargin = (12 * dp).toInt()
bottomMargin = (12 * dp).toInt()
}
})
addView(accountBlock("To", toName, toNumber, toDetail))
for (warning in warningTexts) {
addView(TextView(ctx).apply {
text = warning
setPadding((24 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt(), 0)
addView(TextView(ctx).apply { text = mainMsg })
if (isUsdToMvr) addView(TextView(ctx).apply {
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!"
setTextColor(Color.RED)
textSize = 14f
setTypeface(null, Typeface.BOLD)
layoutParams = lp { topMargin = (16 * dp).toInt() }
textSize = 16f
typeface = Typeface.DEFAULT_BOLD
setPadding(0, (16 * dp).toInt(), 0, 0)
})
if (isSrcCredit) 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)
})
}
}
} else null
showConfirmWithBiometric(
title = getString(R.string.transfer),
message = if (warningView == null) mainMsg else null,
customView = warningView,
biometricSubtitle = "$currency $amountStr$destDisplay",
onConfirmed = { doTransfer() }
)
}
private fun executeBmlQrPayment(
src: BankAccount,
debitAccount: String,
info: BmlQrPayInfo,
amount: Double,
dialog: AlertDialog,
frame: android.widget.FrameLayout
amount: Double
) {
val app = requireActivity().application as BasedBankApp
val loginId = src.loginTag.removePrefix("bml_")
val session = bmlSessionFor(src) ?: run {
dialog.dismiss()
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) }
?: run { dialog.dismiss(); Toast.makeText(requireContext(), "OTP unavailable", Toast.LENGTH_SHORT).show(); return }
?: run { Toast.makeText(requireContext(), "OTP unavailable", Toast.LENGTH_SHORT).show(); return }
binding.btnTransfer.isEnabled = false
(activity as? HomeActivity)?.setRefreshing(true)
viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
@@ -1391,174 +1043,75 @@ class TransferFragment : Fragment() {
sh.sar.basedbank.api.bml.BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
}
}
(activity as? HomeActivity)?.setRefreshing(false)
if (_binding == null) return@launch
if (result == null) {
dialog.dismiss()
binding.btnTransfer.isEnabled = true
Toast.makeText(requireContext(), "Failed to initiate payment", Toast.LENGTH_LONG).show()
return@launch
}
if (result.success) {
showSuccessInDialog(
dialog, frame,
amountCurrency = result.currency.ifBlank { info.currency },
amountValue = result.amount.ifBlank { "%.2f".format(amount) },
fromName = src.accountBriefName,
toName = result.merchant.ifBlank { info.merchantName }
) {
clearForm()
(activity as? HomeActivity)?.triggerRefresh()
}
showBmlQrSuccessDialog(
merchant = result.merchant.ifBlank { info.merchantName },
amount = result.amount.ifBlank { "%.2f".format(amount) },
currency = result.currency.ifBlank { info.currency }
)
} else {
dialog.dismiss()
binding.btnTransfer.isEnabled = true
Toast.makeText(requireContext(), result.errorMessage, Toast.LENGTH_LONG).show()
}
}
}
private fun showProcessingInDialog(dialog: AlertDialog, frame: android.widget.FrameLayout) {
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
dialog.setCancelable(false)
private fun showBmlQrSuccessDialog(merchant: String, amount: String, currency: String) {
val ctx = requireContext()
val dp = resources.displayMetrics.density
val spinner = CircularProgressDrawable(ctx).apply {
setStyle(CircularProgressDrawable.LARGE)
setColorSchemeColors(com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorPrimary, Color.GRAY))
start()
val container = android.widget.LinearLayout(ctx).apply {
orientation = android.widget.LinearLayout.VERTICAL
gravity = android.view.Gravity.CENTER_HORIZONTAL
setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
}
frame.removeAllViews()
frame.addView(LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt())
addView(ImageView(ctx).apply {
setImageDrawable(spinner)
layoutParams = LinearLayout.LayoutParams((48 * dp).toInt(), (48 * dp).toInt()).apply {
gravity = Gravity.CENTER_HORIZONTAL
bottomMargin = (12 * dp).toInt()
}
})
addView(TextView(ctx).apply {
text = "Processing..."
textSize = 16f
gravity = Gravity.CENTER
setTextColor(com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorOnSurface, Color.BLACK))
})
container.addView(android.widget.ImageView(ctx).apply {
setImageResource(R.drawable.ic_check_circle)
setColorFilter(android.graphics.Color.parseColor("#4CAF50"))
layoutParams = android.widget.LinearLayout.LayoutParams(
(64 * dp).toInt(), (64 * dp).toInt()
).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL; bottomMargin = (16 * dp).toInt() }
})
}
private fun showSuccessInDialog(
dialog: AlertDialog,
frame: android.widget.FrameLayout,
amountCurrency: String,
amountValue: String,
fromName: String,
toName: String,
onDone: () -> Unit
) {
val ctx = requireContext()
val dp = resources.displayMetrics.density
val colorOnSurface = com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorOnSurface, Color.BLACK)
val colorMuted = com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
val colorPrimary = com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorPrimary, Color.BLUE)
val MATCH = LinearLayout.LayoutParams.MATCH_PARENT
val WRAP = LinearLayout.LayoutParams.WRAP_CONTENT
frame.removeAllViews()
frame.addView(LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
setPadding((24 * dp).toInt(), (20 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
// Checkmark
addView(ImageView(ctx).apply {
setImageResource(R.drawable.ic_check_circle)
setColorFilter(Color.parseColor("#4CAF50"))
layoutParams = LinearLayout.LayoutParams((64 * dp).toInt(), (64 * dp).toInt()).apply {
gravity = Gravity.CENTER_HORIZONTAL
bottomMargin = (16 * dp).toInt()
}
})
// Currency + amount
addView(LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_HORIZONTAL
layoutParams = LinearLayout.LayoutParams(MATCH, WRAP).apply {
bottomMargin = (16 * dp).toInt()
}
addView(TextView(ctx).apply {
text = "$amountCurrency "
textSize = 16f
setTextColor(colorMuted)
layoutParams = LinearLayout.LayoutParams(WRAP, WRAP)
})
addView(TextView(ctx).apply {
text = amountValue
textSize = 28f
setTypeface(null, Typeface.BOLD)
setTextColor(colorPrimary)
})
})
// From row
addView(LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_HORIZONTAL
layoutParams = LinearLayout.LayoutParams(MATCH, WRAP)
addView(TextView(ctx).apply {
text = "From "
textSize = 12f
setTextColor(colorMuted)
layoutParams = LinearLayout.LayoutParams(WRAP, WRAP)
})
addView(TextView(ctx).apply {
text = fromName
textSize = 13f
setTypeface(null, Typeface.BOLD)
setTextColor(colorOnSurface)
gravity = Gravity.CENTER
})
})
// To row
addView(LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_HORIZONTAL
layoutParams = LinearLayout.LayoutParams(MATCH, WRAP).apply {
topMargin = (4 * dp).toInt()
}
addView(TextView(ctx).apply {
text = "To "
textSize = 12f
setTextColor(colorMuted)
layoutParams = LinearLayout.LayoutParams(WRAP, WRAP)
})
addView(TextView(ctx).apply {
text = toName
textSize = 13f
setTypeface(null, Typeface.BOLD)
setTextColor(colorOnSurface)
gravity = Gravity.CENTER
})
})
container.addView(android.widget.TextView(ctx).apply {
text = "$currency $amount"
textSize = 28f
setTypeface(null, android.graphics.Typeface.BOLD)
setTextColor(com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK))
gravity = android.view.Gravity.CENTER
layoutParams = android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT,
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL; bottomMargin = (8 * dp).toInt() }
})
val okBtn = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
okBtn?.visibility = View.VISIBLE
okBtn?.text = "OK"
okBtn?.setOnClickListener { dialog.dismiss(); onDone() }
container.addView(android.widget.TextView(ctx).apply {
text = merchant
textSize = 14f
setTextColor(com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, android.graphics.Color.GRAY))
gravity = android.view.Gravity.CENTER
layoutParams = android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT,
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL }
})
MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.bml_qr_payment_success)
.setView(container)
.setPositiveButton(android.R.string.ok) { _, _ ->
requireActivity().onBackPressedDispatcher.onBackPressed()
}
.setCancelable(false)
.show()
}
private fun doMibTransfer(
src: BankAccount,
destAccount: String,
@@ -2128,17 +1681,8 @@ class TransferFragment : Fragment() {
requireActivity().title = getString(R.string.transfer)
}
override fun onDestroyView() {
super.onDestroyView()
// Persist form state so it can be restored when the view is recreated
savedAmount = binding.etAmount.text?.toString() ?: ""
savedRemarks = binding.etRemarks.text?.toString() ?: ""
savedToText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else ""
// Reset in-progress OTP flow — it cannot sensibly resume after the view is gone
bmlOtpState = BmlOtpState.NONE
pendingBmlTransfer = null
bmlOtpChannel = null
_binding = null
}
@@ -2193,7 +1737,6 @@ class TransferFragment : Fragment() {
.also { it.root.tag = it }
}
val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT" || acc.profileType == "BML_DEBIT") && !acc.statusDesc.equals("Active", ignoreCase = true)
val isCard = acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT" || acc.profileType == "BML_DEBIT"
val isBmlAccount = acc.bank == "BML"
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
val hide = viewModel.hideAmounts.value ?: false
@@ -2211,11 +1754,7 @@ class TransferFragment : Fragment() {
}
val balance = displayData?.balance ?: ""
b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance
b.root.alpha = when {
inactive -> 0.4f
bmlQrInfo != null && !isCard -> 0.35f
else -> 1f
}
b.root.alpha = if (inactive) 0.4f else 1f
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
when {
networkIcon != null -> {
@@ -181,6 +181,9 @@ class TransferHistoryFragment : Fragment() {
if (firstBatchDone && allTransactions.isNotEmpty()) {
adapter.showLoadingFooter = true
} else if (allTransactions.isEmpty()) {
binding.skeletonContainer.visibility = View.VISIBLE
binding.emptyView.visibility = View.GONE
}
val app = requireActivity().application as BasedBankApp
@@ -286,6 +289,7 @@ class TransferHistoryFragment : Fragment() {
isLoading = false
if (_binding == null) return@launch
binding.skeletonContainer.visibility = View.GONE
val raw = bannerMsg.get()
when {
@@ -329,6 +333,8 @@ class TransferHistoryFragment : Fragment() {
}
adapter.setTransactions(filtered)
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
binding.skeletonContainer.visibility =
if (allTransactions.isEmpty() && isLoading) View.VISIBLE else View.GONE
}
private fun loadContactImage(name: String) {
@@ -1,8 +1,5 @@
package sh.sar.basedbank.ui.login
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
@@ -84,17 +81,6 @@ class CredentialsFragment : Fragment() {
binding.btnLogin.isEnabled = false
binding.btnLogin.setOnClickListener { attemptLogin() }
binding.cardOtp.setOnClickListener {
val code = binding.tvOtpCode.text.toString().replace(" ", "")
if (code.isNotEmpty()) {
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) {
Toast.makeText(requireContext(), "OTP copied", Toast.LENGTH_SHORT).show()
}
}
}
val loginFieldWatcher = object : TextWatcher {
override fun afterTextChanged(s: Editable?) { updateLoginButtonState() }
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
@@ -9,14 +9,12 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.LockActivity
import sh.sar.basedbank.databinding.ActivityLoginBinding
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.ThemeHelper
class LoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginBinding
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.applyAccent(this)
super.onCreate(savedInstanceState)
// If security is configured and the user hasn't unlocked this session,
@@ -36,8 +34,5 @@ class LoginActivity : AppCompatActivity() {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
ta.recycle()
}
}
@@ -19,7 +19,6 @@ import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ActivityOnboardingBinding
import sh.sar.basedbank.ui.login.LoginActivity
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.ThemeHelper
class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
@@ -28,7 +27,6 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
private var countDownTimer: CountDownTimer? = null
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.applyAccent(this)
super.onCreate(savedInstanceState)
// If security is already configured, onboarding is complete. Redirect to lock screen
@@ -47,9 +45,6 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
ta.recycle()
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val originalBottomPadding = binding.bottomBar.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets ->
@@ -615,41 +615,6 @@ class CredentialStore(context: Context) {
} catch (_: Exception) { null }
}
// ── Default payment card ──────────────────────────────────────────────────
/** BML card account number the user has pinned as their default payment card, or null. */
fun getDefaultCardAccountNumber(): String? = prefs.getString("default_card_account_number", null)
fun setDefaultCardAccountNumber(accountNumber: String?) {
val editor = prefs.edit()
if (accountNumber == null) editor.remove("default_card_account_number")
else editor.putString("default_card_account_number", accountNumber)
editor.apply()
}
// ── Default transfer/QR account ───────────────────────────────────────────
/** Account number the user has pinned as their default source for transfers and PayMV QR, or null. */
fun getDefaultAccountNumber(): String? = prefs.getString("default_account_number", null)
fun setDefaultAccountNumber(accountNumber: String?) {
val editor = prefs.edit()
if (accountNumber == null) editor.remove("default_account_number")
else editor.putString("default_account_number", accountNumber)
editor.apply()
}
// ── Dashboard card visibility ─────────────────────────────────────────────
fun getHiddenDashboardCardNumbers(): Set<String> =
prefs.getStringSet("hidden_dashboard_cards", emptySet()) ?: emptySet()
fun setCardHiddenFromDashboard(accountNumber: String, hidden: Boolean) {
val current = getHiddenDashboardCardNumbers().toMutableSet()
if (hidden) current.add(accountNumber) else current.remove(accountNumber)
prefs.edit().putStringSet("hidden_dashboard_cards", current).apply()
}
// ── MIB profile visibility (per loginId) ─────────────────────────────────
/** Returns the set of MIB profile IDs the user has chosen to hide (for a given loginId). */
@@ -9,24 +9,6 @@ data class PaymvQrData(
object PaymvQrParser {
/**
* Returns the BML gateway URL if [raw] is or contains one, otherwise null.
* Handles both plain URL QRs and combined EMV QRs (e.g. Fahipay+BML card QR).
* For combined EMV QRs the URL is parsed from TLV (root tag 35 sub-tag 20 sub-sub-tag 01)
* rather than via regex, to avoid greedily consuming subsequent EMV tag bytes.
*/
fun extractBmlGatewayUrl(raw: String): String? {
if (raw.startsWith("https://pay.bml.com.mv/app/")) return raw
return try {
val root = parseTlv(raw)
val bmlMerchantInfo = root["35"]?.let { parseTlv(it) } ?: return null
val inner = bmlMerchantInfo["20"]?.let { parseTlv(it) } ?: return null
inner["01"]?.takeIf { it.startsWith("https://pay.bml.com.mv/app/") }
} catch (_: Exception) {
null
}
}
fun parse(raw: String): PaymvQrData? {
return try {
val root = parseTlv(raw)
@@ -41,7 +23,7 @@ object PaymvQrParser {
PaymvQrData(
accountNumber = merchantInfo?.get("03"),
amount = root["54"]?.takeIf { it != "***" },
amount = root["54"],
purpose = additionalData?.get("08"),
merchantName = root["59"]
)
@@ -1,78 +0,0 @@
package sh.sar.basedbank.util
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Color
import android.os.Build
import androidx.appcompat.app.AppCompatActivity
import com.google.android.material.color.DynamicColors
import com.google.android.material.color.DynamicColorsOptions
import sh.sar.basedbank.R
object ThemeHelper {
const val PRESET_BLUE = "blue"
const val PRESET_RED = "red"
const val PRESET_GREEN = "green"
const val PRESET_CUSTOM = "custom"
fun isSystemTheme(context: Context): Boolean =
context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getString("theme", "system") == "system"
fun getAccentPreset(context: Context): String =
context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getString("accent_preset", PRESET_BLUE) ?: PRESET_BLUE
fun getCustomColor(context: Context): Int? {
val hex = context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getString("accent_custom_color", null) ?: return null
return try { Color.parseColor(hex) } catch (_: Exception) { null }
}
fun presetSeedColor(context: Context, preset: String): Int = when (preset) {
PRESET_RED -> Color.parseColor("#D32F2F")
PRESET_GREEN -> Color.parseColor("#4CAF50")
PRESET_CUSTOM -> getCustomColor(context) ?: Color.parseColor("#3F65AD")
else -> Color.parseColor("#3F65AD")
}
fun isPitchBlackEnabled(context: Context): Boolean =
context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getBoolean("pitch_black", false)
/**
* Apply the user-chosen accent theme to the given activity.
* Must be called BEFORE super.onCreate() so window-level attributes
* (status bar color, etc.) are resolved from the correct overlay.
* Has no effect when the theme is set to "system" (dynamic colors are
* handled by BasedBankApp via DynamicColors.applyToActivitiesIfAvailable).
*/
fun applyAccent(activity: AppCompatActivity) {
if (isSystemTheme(activity)) return
val preset = getAccentPreset(activity)
val seed = presetSeedColor(activity, preset)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
bitmap.setPixel(0, 0, seed)
DynamicColors.applyToActivityIfAvailable(
activity,
DynamicColorsOptions.Builder().setContentBasedSource(bitmap).build()
)
} else {
// API < 31: apply a simple style overlay (partial palette, but functional)
val styleRes = when (preset) {
PRESET_RED -> R.style.ThemeOverlay_Accent_Orange
PRESET_GREEN -> R.style.ThemeOverlay_Accent_Green
else -> R.style.ThemeOverlay_Accent_Blue
}
activity.theme.applyStyle(styleRes, true)
}
val prefs = activity.getSharedPreferences("prefs", Context.MODE_PRIVATE)
val isDark = prefs.getString("theme", "system") == "dark"
if (isDark && prefs.getBoolean("pitch_black", false)) {
activity.theme.applyStyle(R.style.ThemeOverlay_PitchBlack, true)
}
}
}
+10 -11
View File
@@ -5,20 +5,19 @@
android:viewportWidth="60"
android:viewportHeight="60">
<!-- White background -->
<path
android:fillColor="#FFFFFF"
android:pathData="M0 59.776h59.785V0H0z"/>
<!-- Red background -->
<path
android:fillColor="#E21B23"
android:pathData="M0 60h60V0H0z"/>
android:pathData="M3.297 56.421h53.191V3.356H3.298z"/>
<!-- White logo mark -->
<group
android:scaleX="0.85"
android:scaleY="0.85"
android:pivotX="30"
android:pivotY="30">
<path
android:fillColor="#FFFFFF"
android:fillType="evenOdd"
android:pathData="M37.421 6.708v34.059h-3.7V20.853L22.763 40.767H18.65l18.77-34.06zM18.517 51.073l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.03c-.613.313-1.228.626-1.88.626-.65 0-1.265-.313-1.879-.626-.553-.283-1.108-.564-1.624-.564-.514 0-1.069.28-1.62.564-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.068.28-1.62.564c-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.069.28-1.622.564c-.614.313-1.228.626-1.879.626-.6 0-1.167-.265-1.73-.55v-1.446zm0-2.816l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.028c-.613.315-1.228.627-1.88.627-.65 0-1.265-.312-1.879-.627-.553-.281-1.108-.562-1.624-.562-.514 0-1.069.28-1.62.562-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.068.28-1.62.562c-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.069.28-1.622.562c-.614.315-1.228.627-1.879.627-.6 0-1.167-.264-1.73-.55v-1.445zm12.428-6.042h12.41v3.969h-12.41v-.001H18.531l-2.1-3.968h14.514z"/>
</group>
<path
android:fillColor="#FFFFFF"
android:fillType="evenOdd"
android:pathData="M37.421 6.708v34.059h-3.7V20.853L22.763 40.767H18.65l18.77-34.06zM18.517 51.073l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.03c-.613.313-1.228.626-1.88.626-.65 0-1.265-.313-1.879-.626-.553-.283-1.108-.564-1.624-.564-.514 0-1.069.28-1.62.564-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.068.28-1.62.564c-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.069.28-1.622.564c-.614.313-1.228.626-1.879.626-.6 0-1.167-.265-1.73-.55v-1.446zm0-2.816l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.028c-.613.315-1.228.627-1.88.627-.65 0-1.265-.312-1.879-.627-.553-.281-1.108-.562-1.624-.562-.514 0-1.069.28-1.62.562-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.068.28-1.62.562c-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.069.28-1.622.562c-.614.315-1.228.627-1.879.627-.6 0-1.167-.264-1.73-.55v-1.445zm12.428-6.042h12.41v3.969h-12.41v-.001H18.531l-2.1-3.968h14.514z"/>
</vector>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Translucent dark pill used as a status overlay on card-visual surfaces
(item_card_dashboard / _stack / _wallet / _settings_entry). Theme-independent
by design: it sits on top of card artwork, not the surface color. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="12dp" />
<solid android:color="#CC212121" />
</shape>
@@ -1,39 +0,0 @@
<?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">
<!-- Card icon -->
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M20,4H4C2.89,4 2.01,4.89 2.01,6L2,18c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2V6C22,4.89 21.11,4 20,4zM20,18H4v-6h16V18zM20,8H4V6h16V8z" />
<!-- Background circle separating gear from card -->
<path
android:fillColor="?attr/colorSurface"
android:pathData="M12.5,18 A5.5,5.5 0 1 0 23.5,18 A5.5,5.5 0 1 0 12.5,18 Z" />
<!--
Simple 6-tooth gear, scaled 0.5x and placed at (18,18) in the icon.
The gear path is drawn in a 24×24 space (center 12,12); the group
transform maps it: point(x,y) → (x·0.5+12, y·0.5+12).
Outer radius=10, root radius=7, hub radius=4.
Teeth are rectangular (straight-line) so they stay crisp at small scale.
Outer ring is clockwise; hub hole is counterclockwise (nonZero cutout).
-->
<group
android:scaleX="0.5"
android:scaleY="0.5"
android:translateX="12"
android:translateY="12">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="
M18.58,9.61 L21.85,10.26 L21.85,13.74 L18.58,14.39
L17.36,16.50 L18.43,19.66 L15.42,21.40 L13.22,18.89
L10.78,18.89 L8.58,21.40 L5.57,19.66 L6.64,16.50
L5.42,14.39 L2.15,13.74 L2.15,10.26 L5.42,9.61
L6.64,7.50 L5.57,4.34 L8.58,2.60 L10.78,5.11
L13.22,5.11 L15.42,2.60 L18.43,4.34 L17.36,7.50 Z
M12,8 A4,4 0 1 0 12,16 A4,4 0 1 0 12,8 Z" />
</group>
</vector>
+4 -36
View File
@@ -2,41 +2,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="48"
android:viewportHeight="48">
<!-- Phone outline -->
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#00000000"
android:strokeColor="?attr/colorOnSurface"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M6.08,8.6 L20.45,8.6 A1.58,1.58,0,0,1,22.03,10.18 L22.03,37.81 A1.58,1.58,0,0,1,20.45,39.39 L6.08,39.39 A1.58,1.58,0,0,1,4.5,37.81 L4.5,10.18 A1.58,1.58,0,0,1,6.08,8.6 Z"/>
<!-- Top notch line -->
<path
android:fillColor="#00000000"
android:strokeColor="?attr/colorOnSurface"
android:strokeWidth="2"
android:strokeLineCap="round"
android:pathData="M4.5,12.55 L22.03,12.55"/>
<!-- Bottom notch line -->
<path
android:fillColor="#00000000"
android:strokeColor="?attr/colorOnSurface"
android:strokeWidth="2"
android:strokeLineCap="round"
android:pathData="M4.5,35.45 L22.03,35.45"/>
<!-- NFC waves (outer, mid, inner) -->
<path
android:fillColor="#00000000"
android:strokeColor="?attr/colorOnSurface"
android:strokeWidth="2"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M36.07,9.29 a18.28,18.28,0,0,1,0,29.42 m-4,-24.16 a11.84,11.84,0,0,1,5,9.45 11.84,11.84,0,0,1,-5,9.45 m-3.68,-14 a5.67,5.67,0,0,1,0,9.1"/>
android:fillColor="?attr/colorOnSurface"
android:pathData="M20,2L4,2C2.9,2 2,2.9 2,4l0,16c0,1.1 0.9,2 2,2l16,0c1.1,0 2,-0.9 2,-2L22,4C22,2.9 21.1,2 20,2zM13,18l-2,0 0,-1c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5l-2,0c0,-1.65 -1.35,-3 -3,-3s-3,1.35 -3,3 1.35,3 3,3l0,-2 2,3zM19,12l-2,0c0,-2.76 -2.24,-5 -5,-5l0,-2C15.87,5 19,8.13 19,12z"/>
</vector>
+1 -1
View File
@@ -4,6 +4,6 @@
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:fillColor="?attr/colorOnSurface"
android:pathData="M9.5,6.5v3h-3v-3H9.5zM11,5L5,5v5.5h6L11,5zM9.5,14.5v3h-3v-3H9.5zM11,13L5,13v5.5h6L11,13zM17.5,6.5v3h-3v-3H17.5zM19,5h-6v5.5h6L19,5zM13,13h1.5v1.5L13,14.5zM14.5,14.5L16,14.5L16,16h-1.5zM16,13h1.5v1.5L16,14.5zM13,16h1.5v1.5L13,17.5zM14.5,17.5L16,17.5L16,19h-1.5zM16,16h1.5v1.5L16,17.5zM17.5,14.5L19,14.5L19,16h-1.5zM17.5,17.5L19,17.5L19,19h-1.5z"/>
</vector>
@@ -4,47 +4,13 @@
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:translateX="30"
android:translateY="30"
android:scaleX="1.0"
android:scaleY="1.0">
<!-- Phone outline -->
android:scaleX="2"
android:scaleY="2">
<path
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M6.08,8.6 L20.45,8.6 A1.58,1.58,0,0,1,22.03,10.18 L22.03,37.81 A1.58,1.58,0,0,1,20.45,39.39 L6.08,39.39 A1.58,1.58,0,0,1,4.5,37.81 L4.5,10.18 A1.58,1.58,0,0,1,6.08,8.6 Z"/>
<!-- Top notch line -->
<path
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:pathData="M4.5,12.55 L22.03,12.55"/>
<!-- Bottom notch line -->
<path
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:pathData="M4.5,35.45 L22.03,35.45"/>
<!-- NFC waves -->
<path
android:fillColor="#00000000"
android:strokeColor="#FFFFFF"
android:strokeWidth="2.5"
android:strokeLineCap="round"
android:strokeLineJoin="round"
android:pathData="M36.07,9.29 a18.28,18.28,0,0,1,0,29.42 m-4,-24.16 a11.84,11.84,0,0,1,5,9.45 11.84,11.84,0,0,1,-5,9.45 m-3.68,-14 a5.67,5.67,0,0,1,0,9.1"/>
android:fillColor="#FFFFFF"
android:pathData="M20,4H4C2.89,4 2.01,4.89 2.01,6L2,18c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2V6C22,4.89 21.11,4 20,4zM20,18H4v-6h16V18zM20,8H4V6h16V8z" />
</group>
</vector>
+2 -3
View File
@@ -44,8 +44,8 @@
android:id="@+id/connectivityBanner"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="#C62828"
android:textColor="#FFFFFF"
android:background="?attr/colorErrorContainer"
android:textColor="?attr/colorOnErrorContainer"
android:gravity="center"
android:paddingTop="6dp"
android:paddingBottom="6dp"
@@ -87,7 +87,6 @@ app:menu="@menu/bottom_nav_menu" />
android:layout_height="match_parent"
android:layout_gravity="start"
android:fitsSystemWindows="true"
app:headerLayout="@layout/nav_header"
app:menu="@menu/drawer_menu" />
</androidx.drawerlayout.widget.DrawerLayout>
@@ -48,12 +48,28 @@
android:paddingBottom="16dp"
android:clipToPadding="false" />
<LinearLayout
android:id="@+id/skeletonContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
</LinearLayout>
<TextView
android:id="@+id/emptyView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="No transactions found"
android:text="@string/transactions_empty"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
@@ -49,7 +49,7 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="No recent transfers"
android:text="@string/activities_empty"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
@@ -38,7 +38,7 @@
android:layout_marginBottom="16dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="16dp"
app:cardCornerRadius="@dimen/card_radius_lg"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
@@ -81,7 +81,7 @@
android:layout_marginBottom="16dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="16dp"
app:cardCornerRadius="@dimen/card_radius_lg"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
@@ -125,7 +125,7 @@
android:layout_marginBottom="16dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="16dp"
app:cardCornerRadius="@dimen/card_radius_lg"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
+18 -128
View File
@@ -14,47 +14,12 @@
android:orientation="vertical"
android:visibility="gone">
<!-- Manage Card button row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="end"
android:paddingHorizontal="12dp"
android:paddingTop="4dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnManageCard"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="0dp"
android:minHeight="0dp"
android:text="@string/card_manage"
android:textSize="12sp"
app:icon="@drawable/ic_manage_card"
app:iconSize="20dp"
app:iconPadding="4dp" />
</LinearLayout>
<!-- Top spacer: pushes card to vertical center (hidden in manage mode) -->
<!-- Top spacer: pushes card to vertical center -->
<View
android:id="@+id/topSpacer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<!-- Manage mode: selected card with overlays -->
<include
android:id="@+id/manageCardView"
layout="@layout/item_card_stack"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginTop="8dp"
android:visibility="gone" />
<!-- Horizontal card stack. Width/padding set programmatically for centering + peek. -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvCards"
@@ -93,22 +58,20 @@
<!-- Divider -->
<View
android:id="@+id/divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginHorizontal="24dp"
android:layout_marginBottom="4dp"
android:background="?attr/colorOutlineVariant" />
<!-- Primary pay actions (normal mode) -->
<!-- Primary pay actions -->
<LinearLayout
android:id="@+id/llPayButtons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingTop="8dp"
android:paddingBottom="12dp">
android:paddingBottom="4dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnScanToPay"
@@ -148,134 +111,61 @@
</LinearLayout>
<!-- Default card toggle (manage mode only) -->
<!-- Secondary card management actions -->
<LinearLayout
android:id="@+id/llDefaultCardRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="20dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:visibility="gone">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/card_set_as_default"
android:textAppearance="?attr/textAppearanceBodyMedium" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchDefaultCard"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<!-- Hide from dashboard toggle (manage mode only) -->
<LinearLayout
android:id="@+id/llHideDashboardRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="20dp"
android:paddingTop="4dp"
android:paddingBottom="4dp"
android:visibility="gone">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/card_hide_from_dashboard"
android:textAppearance="?attr/textAppearanceBodyMedium" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchHideFromDashboard"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<!-- Card management actions (manage mode only) -->
<LinearLayout
android:id="@+id/llManageButtons"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="8dp"
android:paddingTop="8dp"
android:paddingBottom="12dp"
android:visibility="gone">
android:paddingTop="4dp"
android:paddingBottom="8dp">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnChangePin"
style="@style/Widget.Material3.Button.TonalButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:minWidth="0dp"
android:minHeight="0dp"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="@string/card_action_change_pin"
android:textSize="12sp"
android:textSize="11sp"
app:icon="@drawable/ic_edit"
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
app:iconSize="16dp"
app:iconPadding="3dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnFreeze"
style="@style/Widget.Material3.Button.TonalButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:minWidth="0dp"
android:minHeight="0dp"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="@string/card_action_freeze"
android:textSize="12sp"
android:textSize="11sp"
app:icon="@drawable/ic_freeze"
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
app:iconSize="16dp"
app:iconPadding="3dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnBlock"
style="@style/Widget.Material3.Button.TonalButton"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp"
android:minWidth="0dp"
android:minHeight="0dp"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="@string/card_action_block"
android:textSize="12sp"
android:textSize="11sp"
app:icon="@drawable/ic_block"
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
app:iconSize="16dp"
app:iconPadding="3dp" />
</LinearLayout>
</LinearLayout>
<!-- Tap-to-pay overlay: shown in tap mode, sits above contentLayout -->
<FrameLayout
android:id="@+id/flTapMode"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone" />
<!-- Loading state -->
<LinearLayout
android:id="@+id/loadingView"
@@ -116,11 +116,9 @@
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
android:visibility="invisible"
android:clickable="true"
android:focusable="true"
app:cardBackgroundColor="?attr/colorSecondaryContainer"
app:cardCornerRadius="12dp"
app:cardElevation="0dp">
app:cardCornerRadius="@dimen/card_radius_md"
app:cardElevation="@dimen/card_elevation_flat">
<LinearLayout
android:layout_width="match_parent"
+95 -107
View File
@@ -21,7 +21,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
android:padding="@dimen/screen_margin">
<!-- Balance cards row -->
<LinearLayout
@@ -34,8 +34,8 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp">
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md">
<LinearLayout
android:layout_width="match_parent"
@@ -56,7 +56,8 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium" />
android:textAppearance="?attr/textAppearanceTitleMedium"
android:fontFeatureSettings="tnum" />
</LinearLayout>
@@ -67,8 +68,8 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp">
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md">
<LinearLayout
android:layout_width="match_parent"
@@ -89,7 +90,8 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="USD —"
android:textAppearance="?attr/textAppearanceTitleMedium" />
android:textAppearance="?attr/textAppearanceTitleMedium"
android:fontFeatureSettings="tnum" />
</LinearLayout>
@@ -112,8 +114,8 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp">
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md">
<LinearLayout
android:layout_width="match_parent"
@@ -134,7 +136,8 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium" />
android:textAppearance="?attr/textAppearanceTitleMedium"
android:fontFeatureSettings="tnum" />
</LinearLayout>
@@ -146,8 +149,8 @@
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp">
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md">
<LinearLayout
android:layout_width="match_parent"
@@ -168,7 +171,8 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="USD —"
android:textAppearance="?attr/textAppearanceTitleMedium" />
android:textAppearance="?attr/textAppearanceTitleMedium"
android:fontFeatureSettings="tnum" />
</LinearLayout>
@@ -176,92 +180,7 @@
</LinearLayout>
<!-- Blocked funds row: MVR + USD separate cards (hidden when no blocked amounts) -->
<LinearLayout
android:id="@+id/rowBlocked"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="16dp"
android:visibility="gone">
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardBlockedMvr"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp"
app:cardBackgroundColor="?attr/colorErrorContainer"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_blocked_mvr"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnErrorContainer" />
<TextView
android:id="@+id/tvBlockedMvr"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnErrorContainer" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardBlockedUsd"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp"
app:cardBackgroundColor="?attr/colorErrorContainer"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_blocked_usd"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnErrorContainer" />
<TextView
android:id="@+id/tvBlockedUsd"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="USD —"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnErrorContainer" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
</LinearLayout>
<!-- Overdue row (hidden when no overdue financing) -->
<!-- Attention row: Blocked + Overdue (hidden when nothing applies) -->
<LinearLayout
android:id="@+id/rowAttention"
android:layout_width="match_parent"
@@ -271,11 +190,62 @@
android:visibility="gone">
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardOverdue"
android:layout_width="match_parent"
android:id="@+id/cardBlocked"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:cardElevation="1dp"
app:cardCornerRadius="12dp"
android:layout_weight="1"
android:layout_marginEnd="8dp"
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md"
app:cardBackgroundColor="?attr/colorErrorContainer"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_blocked"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnErrorContainer" />
<TextView
android:id="@+id/tvBlockedTotal"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnErrorContainer"
android:fontFeatureSettings="tnum" />
<TextView
android:id="@+id/tvBlockedSecondary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnErrorContainer"
android:fontFeatureSettings="tnum"
android:alpha="0.75"
android:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<com.google.android.material.card.MaterialCardView
android:id="@+id/cardOverdue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md"
app:cardBackgroundColor="?attr/colorErrorContainer"
android:clickable="true"
android:focusable="true"
@@ -301,7 +271,8 @@
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnErrorContainer" />
android:textColor="?attr/colorOnErrorContainer"
android:fontFeatureSettings="tnum" />
</LinearLayout>
@@ -315,8 +286,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp"
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md"
android:clickable="true"
android:focusable="true">
@@ -339,7 +310,8 @@
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="MVR —"
android:textAppearance="?attr/textAppearanceTitleMedium" />
android:textAppearance="?attr/textAppearanceTitleMedium"
android:fontFeatureSettings="tnum" />
</LinearLayout>
@@ -361,6 +333,14 @@
android:layout_marginTop="16dp"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/nav_pay_with_card"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp"/>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvCards"
android:layout_width="match_parent"
@@ -386,6 +366,14 @@
android:paddingTop="8dp"
android:paddingBottom="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/dashboard_quick_actions"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="8dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
+1 -41
View File
@@ -64,6 +64,7 @@
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:hint="@string/paymvqr_amount_hint"
app:helperText="@string/paymvqr_amount_helper"
app:prefixText="MVR ">
<com.google.android.material.textfield.TextInputEditText
@@ -75,47 +76,6 @@
</com.google.android.material.textfield.TextInputLayout>
<!-- Reference / purpose (optional) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilReference"
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:hint="@string/paymvqr_reference_hint">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etReference"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text"
android:maxLines="1" />
</com.google.android.material.textfield.TextInputLayout>
<!-- Include phone number toggle -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="12dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/paymvqr_include_phone"
android:textAppearance="?attr/textAppearanceBodyMedium" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchIncludePhone"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checked="true" />
</LinearLayout>
<!-- Action buttons — always visible; share/save disabled until QR is rendered -->
<LinearLayout
android:id="@+id/layoutActions"
@@ -52,7 +52,7 @@
android:layout_marginBottom="12dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="16dp"
app:cardCornerRadius="@dimen/card_radius_lg"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
<LinearLayout
@@ -94,7 +94,7 @@
android:layout_height="wrap_content"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="16dp"
app:cardCornerRadius="@dimen/card_radius_lg"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutline">
<LinearLayout
@@ -156,87 +156,6 @@
</com.google.android.material.button.MaterialButtonToggleGroup>
<!-- Pitch black — enabled only in explicit dark mode -->
<LinearLayout
android:id="@+id/rowPitchBlack"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_pitch_black"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchPitchBlack"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<!-- Accent color — always shown, disabled/grayed in system theme mode -->
<LinearLayout
android:id="@+id/sectionAccentColor"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginTop="24dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_accent_color"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButtonToggleGroup
android:id="@+id/accentToggle"
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/btnAccentBlue"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/accent_blue" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAccentOrange"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/accent_orange" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAccentGreen"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/accent_green" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnAccentCustom"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/accent_custom" />
</com.google.android.material.button.MaterialButtonToggleGroup>
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@@ -194,6 +194,44 @@
android:layout_marginTop="24dp"
android:layout_marginBottom="8dp" />
<LinearLayout
android:id="@+id/rowHideAmounts"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="4dp">
<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_hide_amounts"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_hide_amounts_desc"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurfaceVariant" />
</LinearLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchHideAmounts"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp" />
</LinearLayout>
<LinearLayout
android:id="@+id/rowBlockScreenshots"
android:layout_width="match_parent"
@@ -49,12 +49,28 @@
android:paddingBottom="16dp"
android:clipToPadding="false" />
<LinearLayout
android:id="@+id/skeletonContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
<include layout="@layout/item_skeleton_transaction" />
</LinearLayout>
<TextView
android:id="@+id/emptyView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="No transactions found"
android:text="@string/transactions_empty"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurfaceVariant"
android:visibility="gone" />
+5 -3
View File
@@ -9,8 +9,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="14dp"
android:paddingHorizontal="@dimen/list_item_padding_h"
android:paddingVertical="@dimen/list_item_padding_v"
android:gravity="center_vertical"
android:foreground="?attr/selectableItemBackground">
@@ -70,7 +70,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface" />
android:textColor="?attr/colorOnSurface"
android:fontFeatureSettings="tnum" />
<TextView
android:id="@+id/tvBlocked"
@@ -79,6 +80,7 @@
android:layout_marginTop="2dp"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorError"
android:fontFeatureSettings="tnum"
android:visibility="gone" />
<ImageButton
@@ -15,6 +15,7 @@
android:scaleType="fitCenter"
android:layout_marginEnd="10dp"
android:visibility="gone"
android:importantForAccessibility="no"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle"
xmlns:app="http://schemas.android.com/apk/res-auto" />
@@ -49,7 +50,8 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodySmall"
android:textColor="?attr/colorOnSurface" />
android:textColor="?attr/colorOnSurface"
android:fontFeatureSettings="tnum" />
</LinearLayout>
@@ -7,8 +7,8 @@
android:layout_marginHorizontal="16dp"
android:layout_marginTop="12dp"
android:layout_marginBottom="8dp"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
app:cardCornerRadius="@dimen/card_radius_lg"
app:cardElevation="@dimen/card_elevation_flat"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
@@ -114,7 +114,8 @@
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="2dp" />
android:layout_marginTop="2dp"
android:fontFeatureSettings="tnum" />
</LinearLayout>
@@ -137,7 +138,8 @@
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="2dp" />
android:layout_marginTop="2dp"
android:fontFeatureSettings="tnum" />
</LinearLayout>
@@ -162,7 +164,8 @@
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorError"
android:layout_marginTop="2dp" />
android:layout_marginTop="2dp"
android:fontFeatureSettings="tnum" />
</LinearLayout>
@@ -182,55 +185,6 @@
</LinearLayout>
<!-- Default account divider + row (shown only for non-card accounts) -->
<View
android:id="@+id/dividerDefaultAccount"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="16dp"
android:background="?attr/colorOutlineVariant"
android:visibility="gone" />
<LinearLayout
android:id="@+id/llDefaultAccountRow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginTop="12dp"
android:visibility="gone">
<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="Default account"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?attr/colorOnSurface" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Auto-selected for transfers and PayMV QR"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="2dp" />
</LinearLayout>
<com.google.android.material.materialswitch.MaterialSwitch
android:id="@+id/switchDefaultAccount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
+5 -7
View File
@@ -5,8 +5,10 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="@dimen/card_radius_lg"
app:cardElevation="@dimen/card_elevation_flat"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
@@ -51,11 +53,7 @@
android:id="@+id/tvLoanStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="10dp"
android:paddingVertical="4dp"
android:background="@drawable/chip_background"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSecondaryContainer" />
style="@style/Widget.App.StatusChip" />
</LinearLayout>
+4 -3
View File
@@ -9,8 +9,8 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingHorizontal="16dp"
android:paddingVertical="14dp"
android:paddingHorizontal="@dimen/list_item_padding_h"
android:paddingVertical="@dimen/list_item_padding_v"
android:gravity="center_vertical"
android:foreground="?attr/selectableItemBackground">
@@ -83,7 +83,8 @@
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textColor="?attr/colorOnSurface"
android:layout_marginTop="6dp" />
android:layout_marginTop="6dp"
android:fontFeatureSettings="tnum" />
<ImageButton
android:id="@+id/btnTransfer"
@@ -2,7 +2,7 @@
<com.google.android.material.card.MaterialCardView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="320dp"
android:layout_width="280dp"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
app:cardCornerRadius="16dp"
+9 -9
View File
@@ -26,6 +26,15 @@
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<ImageButton
android:id="@+id/btnTransferContact"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_send"
android:padding="6dp"
android:contentDescription="@string/transfer" />
<ImageButton
android:id="@+id/btnEditContact"
android:layout_width="36dp"
@@ -45,15 +54,6 @@
android:tint="?attr/colorError"
android:contentDescription="@string/contact_delete" />
<ImageButton
android:id="@+id/btnTransferContact"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_send"
android:padding="6dp"
android:contentDescription="@string/transfer" />
</LinearLayout>
<LinearLayout
@@ -5,8 +5,10 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
app:cardCornerRadius="16dp"
app:cardElevation="0dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="@dimen/card_radius_lg"
app:cardElevation="@dimen/card_elevation_flat"
app:strokeWidth="1dp"
app:strokeColor="?attr/colorOutlineVariant">
@@ -51,11 +53,7 @@
android:id="@+id/tvStatus"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="10dp"
android:paddingVertical="4dp"
android:background="@drawable/chip_background"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSecondaryContainer" />
style="@style/Widget.App.StatusChip" />
</LinearLayout>
@@ -5,8 +5,10 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
app:cardElevation="1dp"
app:cardCornerRadius="12dp">
android:clickable="true"
android:focusable="true"
app:cardElevation="@dimen/card_elevation_raised"
app:cardCornerRadius="@dimen/card_radius_md">
<LinearLayout
android:layout_width="match_parent"
+4 -3
View File
@@ -5,15 +5,16 @@
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingHorizontal="16dp"
android:paddingVertical="14dp"
android:paddingHorizontal="@dimen/list_item_padding_h"
android:paddingVertical="@dimen/list_item_padding_v"
android:background="?attr/selectableItemBackground">
<ImageView
android:id="@+id/ivIcon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="16dp" />
android:layout_marginEnd="16dp"
android:importantForAccessibility="no" />
<LinearLayout
android:layout_width="0dp"
+2 -1
View File
@@ -13,7 +13,8 @@
android:id="@+id/ivNavIcon"
android:layout_width="28dp"
android:layout_height="28dp"
android:layout_marginBottom="6dp" />
android:layout_marginBottom="6dp"
android:importantForAccessibility="no" />
<TextView
android:id="@+id/tvNavLabel"
+2 -4
View File
@@ -6,10 +6,8 @@
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="12dp"
android:clickable="true"
android:focusable="true"
app:cardCornerRadius="16dp"
app:cardElevation="2dp">
app:cardCornerRadius="@dimen/card_radius_lg"
app:cardElevation="@dimen/card_elevation_raised">
<LinearLayout
android:layout_width="match_parent"
@@ -16,6 +16,7 @@
android:layout_width="44dp"
android:layout_height="44dp"
android:scaleType="centerCrop"
android:importantForAccessibility="no"
app:shapeAppearanceOverlay="@style/ShapeAppearance.Circle" />
<LinearLayout
@@ -53,6 +54,7 @@
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:textColor="?attr/colorOnSurface"
android:fontFeatureSettings="tnum"
android:paddingHorizontal="8dp"
android:visibility="gone" />
@@ -20,10 +20,6 @@
android:id="@+id/tvProfileType"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="10dp"
android:paddingVertical="4dp"
android:background="@drawable/chip_background"
android:textAppearance="?attr/textAppearanceLabelSmall"
android:textColor="?attr/colorOnSecondaryContainer" />
style="@style/Widget.App.StatusChip" />
</LinearLayout>
@@ -86,6 +86,7 @@
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceTitleSmall"
android:textStyle="bold"
android:fontFeatureSettings="tnum"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/fvAvatar"
app:layout_constraintBottom_toBottomOf="@id/fvAvatar" />
-25
View File
@@ -1,25 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingEnd="16dp"
android:paddingTop="24dp"
android:paddingBottom="16dp">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@mipmap/ic_launcher"
android:contentDescription="@string/app_name" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:text="@string/app_name"
android:textAppearance="?attr/textAppearanceTitleMedium" />
</LinearLayout>
@@ -65,8 +65,8 @@
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:visibility="gone"
app:cardCornerRadius="12dp"
app:cardElevation="2dp">
app:cardCornerRadius="@dimen/card_radius_md"
app:cardElevation="@dimen/card_elevation_raised">
<LinearLayout
android:layout_width="match_parent"
+33 -1
View File
@@ -5,4 +5,36 @@
<dimen name="nav_header_vertical_spacing">8dp</dimen>
<dimen name="nav_header_height">176dp</dimen>
<dimen name="fab_margin">16dp</dimen>
</resources>
<!-- ====== Design tokens ======================================== -->
<!-- Card surfaces.
Two-tier radius:
lg → hero/standalone cards (bank tiles, history header, finance deals, OTP cards)
md → repeating row/summary cards (dashboard cards, foreign limit, bottom sheets)
Card-visual representations (item_card_*) keep their own larger radius/elevation
to read as real credit cards — they are not tokenized. -->
<dimen name="card_radius_lg">16dp</dimen>
<dimen name="card_radius_md">12dp</dimen>
<!-- Card elevation. Two tiers:
flat → outlined cards (paired with a colorOutlineVariant stroke)
raised → subtle Material 3 default raise
Larger elevations belong to specific card-visual surfaces only. -->
<dimen name="card_elevation_flat">0dp</dimen>
<dimen name="card_elevation_raised">1dp</dimen>
<!-- Screen-level spacing -->
<dimen name="screen_margin">16dp</dimen>
<dimen name="section_gap">16dp</dimen>
<!-- Standard list/row padding -->
<dimen name="list_item_padding_h">16dp</dimen>
<dimen name="list_item_padding_v">14dp</dimen>
<!-- Gutters: small inline spacing units -->
<dimen name="gutter_xs">2dp</dimen>
<dimen name="gutter_sm">4dp</dimen>
<dimen name="gutter_md">8dp</dimen>
<dimen name="gutter_lg">12dp</dimen>
</resources>
+5 -21
View File
@@ -115,13 +115,11 @@
<!-- PayMV QR Generator -->
<string name="paymvqr_select_account">Select account</string>
<string name="paymvqr_amount_hint">Amount (optional)</string>
<string name="paymvqr_amount_helper">Leave empty to allow payer to enter any amount</string>
<string name="paymvqr_share">Share</string>
<string name="paymvqr_save_image">Save Image</string>
<string name="paymvqr_saved">QR saved to gallery</string>
<string name="paymvqr_save_failed">Failed to save image</string>
<string name="paymvqr_include_phone">Include phone number</string>
<string name="paymvqr_reference_hint">Reference (optional)</string>
<string name="paymvqr_reference_default">PayMV QR Transfer</string>
<!-- Toolbar -->
<string name="action_lock">Lock app</string>
@@ -150,15 +148,6 @@
<string name="theme_system">System</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
<string name="settings_pitch_black">Pitch Black</string>
<string name="settings_accent_color">Accent Color</string>
<string name="accent_blue">Blue</string>
<string name="accent_orange">Red</string>
<string name="accent_green">Green</string>
<string name="accent_custom">Custom</string>
<string name="accent_custom_pick">Custom Accent Color</string>
<string name="accent_custom_hint">#RRGGBB hex color</string>
<string name="accent_invalid_color">Invalid color — enter a valid hex code</string>
<string name="language">Language</string>
<string name="lang_english">English</string>
<string name="lang_dhivehi">ދިވެހި</string>
@@ -220,8 +209,7 @@
<string name="cards">Cards</string>
<string name="available_balance">Available Balance</string>
<string name="account_blocked_label">%1$s blocked</string>
<string name="dashboard_blocked_mvr">Blocked MVR</string>
<string name="dashboard_blocked_usd">Blocked USD</string>
<string name="dashboard_blocked">Blocked Funds</string>
<string name="dashboard_overdue">Overdue Financing</string>
<!-- Transfer -->
@@ -238,16 +226,13 @@
<string name="transfer_lookup_account">Look up account</string>
<string name="transfer_clear_recipient">Clear recipient</string>
<string name="transfer_pick_contact">Pick contact</string>
<string name="transfer_scan_qr">Scan to Pay</string>
<string name="transfer_scan_qr">Scan QR</string>
<string name="qr_pick_image">Pick image</string>
<string name="transfer_qr_invalid">Invalid or unsupported QR code</string>
<string name="card_qr_paymv_unsupported">PayMV QR is not supported for card payments — switching to transfer</string>
<string name="qr_camera_permission_title">Camera permission required</string>
<string name="qr_camera_permission_message">Camera access is needed to scan QR codes. Please grant the permission in Settings.</string>
<string name="camera_permission_profile_message">Camera access is needed to take a photo. Please grant the permission in Settings.</string>
<string name="go_to_settings">Go to Settings</string>
<string name="transfer_select_source_first">Select a source account first</string>
<string name="transfer_no_from_account">Please set a default account or select From account first</string>
<string name="transfer_enter_account_first">Enter an account number first</string>
<string name="transfer_account_not_found">Account not found</string>
<string name="transfer_session_unavailable">Session unavailable — please re-login</string>
@@ -270,6 +255,8 @@
<!-- Accounts -->
<string name="accounts_empty">No accounts found</string>
<string name="activities_empty">No recent transfers</string>
<string name="transactions_empty">No transactions found</string>
<!-- Contacts -->
<string name="contacts_empty">No contacts found</string>
@@ -332,9 +319,6 @@
<string name="card_pay_qr">Scan to Pay</string>
<string name="card_pay_nfc">Tap to Pay</string>
<string name="mib_qr_nfc_not_supported">Skill issue on MIB side, Not supported</string>
<string name="card_manage">Manage Card</string>
<string name="card_set_as_default">Set as Default Card</string>
<string name="card_hide_from_dashboard">Hide from Dashboard</string>
<string name="card_action_change_pin">Change PIN</string>
<string name="card_action_freeze">Freeze</string>
<string name="card_action_block">Block</string>
+9 -28
View File
@@ -1,37 +1,18 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="ShapeAppearance.Circle" parent="ShapeAppearance.Material3.Corner.Full" />
<!-- Reusable status chip (filled, secondary container). Apply to a TextView via style. -->
<style name="Widget.App.StatusChip" parent="">
<item name="android:paddingHorizontal">10dp</item>
<item name="android:paddingVertical">4dp</item>
<item name="android:background">@drawable/chip_background</item>
<item name="android:textAppearance">?attr/textAppearanceLabelSmall</item>
<item name="android:textColor">?attr/colorOnSecondaryContainer</item>
</style>
<style name="Theme.BasedBank" parent="Theme.Material3.DayNight.NoActionBar">
<item name="colorPrimary">@color/seed_primary</item>
<item name="colorSecondary">@color/seed_secondary</item>
<item name="android:windowSoftInputMode">adjustResize</item>
</style>
<!-- Accent overlays — applied on API < 31 as fallback when content-based dynamic colors unavailable -->
<style name="ThemeOverlay_Accent_Blue" parent="">
<item name="colorPrimary">#3F65AD</item>
<item name="colorSecondary">#9AD141</item>
</style>
<style name="ThemeOverlay_Accent_Orange" parent="">
<item name="colorPrimary">#D32F2F</item>
<item name="colorSecondary">#EF9A9A</item>
</style>
<style name="ThemeOverlay_Accent_Green" parent="">
<item name="colorPrimary">#4CAF50</item>
<item name="colorSecondary">#80CBC4</item>
</style>
<!-- Pitch black overlay — forces pure black surfaces for OLED displays -->
<style name="ThemeOverlay_PitchBlack" parent="">
<item name="android:colorBackground">#000000</item>
<item name="colorSurface">#000000</item>
<item name="colorSurfaceVariant">#0d0d0d</item>
<item name="colorSurfaceContainer">#0d0d0d</item>
<item name="colorSurfaceContainerLow">#000000</item>
<item name="colorSurfaceContainerLowest">#000000</item>
<item name="colorSurfaceContainerHigh">#1a1a1a</item>
<item name="colorSurfaceContainerHighest">#1a1a1a</item>
</style>
</resources>
-24
View File
@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
android:description="@string/app_name"
android:requireDeviceUnlock="false">
<aid-group
android:description="@string/app_name"
android:category="payment">
<!-- PPSE: 2PAY.SYS.DDF01 -->
<aid-filter android:name="325041592E5359532E4444463031" />
<!-- Visa -->
<aid-filter android:name="A0000000031010" />
<!-- Mastercard -->
<aid-filter android:name="A0000000041010" />
<!-- Amex -->
<aid-filter android:name="A000000025" />
</aid-group>
</host-apdu-service>
+4 -4
View File
@@ -28,13 +28,13 @@
</shortcut>
<shortcut
android:shortcutId="tap_to_pay"
android:shortcutId="pay_with_card"
android:enabled="true"
android:icon="@drawable/ic_shortcut_pay_card"
android:shortcutShortLabel="@string/card_pay_nfc"
android:shortcutLongLabel="@string/card_pay_nfc">
android:shortcutShortLabel="@string/nav_pay_with_card"
android:shortcutLongLabel="@string/nav_pay_with_card">
<intent
android:action="sh.sar.basedbank.TAP_TO_PAY"
android:action="sh.sar.basedbank.OPEN_PAY_WITH_CARD"
android:targetPackage="sh.sar.basedbank"
android:targetClass="sh.sar.basedbank.MainActivity" />
<categories android:name="android.shortcut.conversation" />
@@ -373,9 +373,3 @@ _None._
- **MIB Blowfish/ECB** — inherited upstream protocol weakness, not actionable without server-side changes.
- **DhiraaguClient JSON string interpolation** — low real-world risk given numeric-only input validation upstream.
- **`android:allowBackup="true"`** — flagged by automated scanners but effectively mitigated by the exclusion rules.
&nbsp;
---
[← Account Parser Architecture](19-parsers.md)
@@ -83,9 +83,3 @@ Handles both CASA accounts and prepaid/credit cards.
`AccountsAdapter` calls `AccountListParser.from(account)` once per item (skipping `null` results) and binds the resulting `AccountListDisplay` directly. The adapter has zero bank-specific logic.
The transfer screen dropdown (`TransferFragment`) also uses `AccountListParser.from(acc)?.balance` for the source account balance display.
&nbsp;
---
[← PayMV QR Format](18-paymv-qr-format.md) &nbsp;&nbsp;&nbsp; **Next →** [Transfer Flows](20-transfer-flows.md)
-21
View File
@@ -1,21 +0,0 @@
# Thijooree Documentation
---
## App Internals
| Section | Description |
|---|---|
| [thijooree/](thijooree/README.md) | UI flows, routing logic, parsers, and security audit for the Android client |
---
## Bank & Service APIs
| Section | Description |
|---|---|
| [bmlapi/](bmlapi/README.md) | Bank of Maldives — hybrid web/OAuth login, dashboard, transfers, cards, QR payments, tap-to-pay |
| [mibapi/](mibapi/README.md) | MIB Faisanet — Blowfish-encrypted API + WebView session, accounts, transfers, contacts |
| [fahipayapi/](fahipayapi/README.md) | Fahipay digital wallet — login, balance, history, contacts |
| [dhiraaguapi/](dhiraaguapi/README.md) | Dhiraagu Easy Pay — number lookup for reload / bill pay |
| [ooredooapi/](ooredooapi/README.md) | Ooredoo Quick Pay — number validation for Raastas / bill pay |
+1 -1
View File
@@ -144,4 +144,4 @@ Each channel object:
---
[← Account Validation](10-validate.md) · [Next → Tap-to-Pay](12-tap-to-pay.md)
[← Account Validation](10-validate.md)
-254
View File
@@ -1,254 +0,0 @@
# Tap-to-Pay (NFC / HCE)
BML supports contactless NFC payments via Host Card Emulation (HCE). The app fetches single-use payment tokens from the server, then emulates an EMV mag-stripe contactless card using Android's `HostApduService`.
---
## Overview
```
1. Fetch tokens → POST /api/mobile/walletpayments/gettoken (TOTP-authenticated)
2. HCE exchange → Android NFC subsystem drives the APDU exchange with the POS terminal
```
---
## Step 1 — Fetch Payment Tokens
### Endpoint
```
POST https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/gettoken
```
### Headers
| Header | Value |
|---|---|
| `Authorization` | `Bearer <access_token>` |
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
| `x-app-version` | `2.1.44.348` |
| `Content-Type` | `application/json` |
### Three-Step OTP Flow
Token retrieval requires TOTP verification and completes in three POSTs to the same endpoint.
#### Step 1a — Initiate
```json
{
"type": "track2",
"cardid": "<cardId>",
"quantity": 3
}
```
Expected response: `{ "code": 99 }` (OTP required)
If `"code": 0` is returned directly the payload contains tokens immediately (skip to parsing).
#### Step 1b — Request OTP Channel
```json
{
"type": "track2",
"cardid": "<cardId>",
"quantity": 3,
"channel": "token"
}
```
Expected response: `{ "code": 22 }` (OTP generated on BML side; TOTP is used locally)
#### Step 1c — Submit TOTP
```json
{
"type": "track2",
"cardid": "<cardId>",
"quantity": 3,
"channel": "token",
"otp": "<TOTP>"
}
```
Expected response: `{ "code": 0, "payload": [...] }`
The OTP is a standard TOTP (RFC 6238, SHA-1, 30-second window, 6 digits) derived from the stored BML authenticator seed.
### Token Response
```json
{
"code": 0,
"payload": [
{
"token": "4761360000000000",
"expiry": "2512",
"app_code": "A0000000031010",
"service_code": "000",
"data": "0960919802623742",
"valid_until": "2025-12-01 12:00:00.000"
}
]
}
```
### Token Fields
| Field | Description |
|---|---|
| `token` | PAN-equivalent single-use token (used as Track 2 primary account number) |
| `expiry` | Expiry in `YYMM` format (e.g. `"2512"` = December 2025) |
| `app_code` | AID (Application Identifier) hex string — identifies the card network |
| `service_code` | 3-digit service code for Track 2 |
| `data` | Discretionary data appended to Track 2 |
| `valid_until` | Server-side expiry timestamp for the token |
### AID to Card Network Mapping
| AID prefix | Network |
|---|---|
| `A0000000031010` | Visa |
| `A0000000041010` | Mastercard |
| `A000000025...` | Amex |
| (other) | BML |
---
## Step 2 — HCE APDU Exchange
Once a token is set, Android's NFC subsystem routes contactless commands to the app's `HostApduService`. The flow follows the EMV mag-stripe contactless profile.
### APDU Exchange Flow
```
POS Terminal Android HCE
| |
| SELECT PPSE (INS=A4) |
|--------------------------------------->|
| FCI Template (6F) + 9000 |
|<---------------------------------------|
| |
| SELECT AID (INS=A4) |
|--------------------------------------->|
| FCI Template (6F) + 9000 |
|<---------------------------------------|
| |
| GET PROCESSING OPTIONS (INS=A8) |
|--------------------------------------->|
| Response Message Template (80) + 9000 |
|<---------------------------------------|
| |
| READ RECORD (INS=B2) |
|--------------------------------------->|
| Record Template (70) + 9000 |
|<---------------------------------------|
```
### APDU Command Bytes
| INS | Hex | Command |
|---|---|---|
| `SELECT` | `0xA4` | Select PPSE or AID |
| `GET PROCESSING OPTIONS` | `0xA8` | Request AIP + AFL |
| `READ RECORD` | `0xB2` | Read Track 2 data |
### SELECT PPSE Response
PPSE AID: `2PAY.SYS.DDF01` = `325041592E5359532E4444463031`
```
6F <len>
84 <len> 325041592E5359532E4444463031 ← DF Name (PPSE)
A5 <len>
BF0C <len>
61 <len>
4F <len> <AID> ← ADF Name
87 01 01 ← Application Priority Indicator
9000
```
### SELECT AID Response
```
6F <len>
84 <len> <AID> ← Dedicated File Name
A5 <len>
50 <len> <label-ascii-as-hex> ← Application Label (e.g. "VISA")
9F38 02 9F6602 ← PDOL: TTQ (2 bytes)
9000
```
The application label is derived from the AID prefix (see mapping table above).
### GET PROCESSING OPTIONS Response
```
80 06 0080 08010100
9000
```
| Field | Value | Meaning |
|---|---|---|
| Tag `80` | — | Response Message Template 1 |
| AIP | `0080` | Mag-stripe mode |
| AFL | `08010100` | SFI=1, records 11, 0 offline auth records |
### READ RECORD Response
```
70 <len>
57 <len> <track2-data> ← Track 2 Equivalent Data
9000
```
Track 2 format:
```
{token} D {expiry} {serviceCode} {data} [F]
```
The trailing `F` nibble is appended when the total length is odd (standard Track 2 padding).
Example from a real token:
```
4761360000000000 D 2512 000 0960919802623742
→ 4761360000000000D2512000096091980262374 2F (padded)
```
### Status Words
| SW | Meaning |
|---|---|
| `9000` | Success |
| `6F00` | Generic / unknown error |
| `6D00` | Instruction not supported |
---
## TLV Encoding
All APDU responses use BER-TLV encoding. Tags are 1 or 2 bytes (hex string). Length follows DER short/long form:
| Length range | Encoding |
|---|---|
| 0127 bytes | `LL` (1 byte) |
| 128255 bytes | `81 LL` (2 bytes) |
| 25665535 bytes | `82 HH LL` (3 bytes) |
---
## Prerequisites
- Valid `access_token` from [OAuth Token Exchange](03-oauth-token.md)
- TOTP seed enrolled via BML app (same seed used for login 2FA)
- `cardId` from the dashboard — see [Dashboard](04-dashboard.md)
---
&nbsp;
---
[← Foreign Limits](11-foreign-limits.md) · [Next → QR Payment](13-qr-payment.md)
-243
View File
@@ -1,243 +0,0 @@
# QR Payment
BML supports QR-based payments via the PayMV network. There are two QR types — static merchant QRs (no preset amount) and gateway QRs (amount preset by merchant). Both are paid via the same 3-step TOTP-authenticated flow.
---
## QR Code Types
| Type code | Name | Amount |
|---|---|---|
| `QRS` | Static QR | `0.00` — user enters amount |
| `QRR` | Gateway / dynamic QR | Preset by merchant |
---
## QR Code Formats
BML QR codes appear in two formats.
### 1. Plain URL QR
```
https://pay.bml.com.mv/app/<base64-encoded-url>
```
The entire URL is base64-encoded and passed directly to the payrequest lookup API.
### 2. Combined EMV-style QR
Used in Fahipay/PayMV combo QRs that embed multiple payment networks. The BML gateway URL is embedded as a TLV value at a fixed path.
TLV path: **root tag `35` → sub-tag `20` → sub-sub-tag `01`**
The value at tag `01` is the full `https://pay.bml.com.mv/app/...` URL.
---
## PayMV QR Format (TLV)
PayMV QRs (static, PayMV-native) use a decimal TLV encoding (not BER-TLV):
```
<2-digit decimal tag><2-digit decimal length><value>...
```
### Root-level tags (key fields for scanning)
| Tag | Field |
|---|---|
| `26` | Merchant account information (container) |
| `54` | Transaction amount |
| `59` | Merchant / recipient name |
| `62` | Additional data (container) |
### Sub-tags
| Parent | Tag | Field |
|---|---|---|
| `26` | `03` | Account number |
| `62` | `08` | Payment purpose / reference |
> For the full PayMV QR format spec including generation (receive-payment QRs), acquirer BIC mapping, CRC algorithm, and all tags — see [PayMV QR Format](../thijooree/18-paymv-qr-format.md).
---
## Step 1 — Resolve QR to Merchant Details
### Endpoint
```
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/{base64Url}
```
`{base64Url}` is the full QR URL (e.g. `https://pay.bml.com.mv/app/...`) base64-encoded with standard encoding (with padding).
### Headers
| Header | Value |
|---|---|
| `Authorization` | `Bearer <access_token>` |
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
| `x-app-version` | `2.1.44.348` |
```bash
curl --request GET \
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/<base64Url>' \
--header 'Authorization: Bearer <access_token>' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
--header 'x-app-version: 2.1.44.348'
```
### Response
```json
{
"success": true,
"payload": {
"trxn_hash": "<base64Url>",
"narrative1": "Merchant Name",
"narrative2": "Address Line 1",
"narrative3": "Address Line 2",
"amount": "1.03",
"currency": "MVR"
}
}
```
### Response Fields
| Field | Description |
|---|---|
| `trxn_hash` | The base64 URL — used as `requestId` in payment steps |
| `narrative1` | Merchant name |
| `narrative2` | Merchant address line 1 |
| `narrative3` | Merchant address line 2 |
| `amount` | Payment amount (`"0.00"` for static QRS) |
| `currency` | Currency code (typically `"MVR"`) |
---
## Step 2 — Pay (3-Step TOTP Flow)
All three steps POST to the same endpoint:
```
POST https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/pay
```
### Headers
| Header | Value |
|---|---|
| `Authorization` | `Bearer <access_token>` |
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
| `x-app-version` | `2.1.44.348` |
| `Content-Type` | `application/json` |
| `Accept` | `application/json` |
### Step 2a — Initiate (no channel)
```json
{
"action": "approve",
"debitAccount": "<internalAccountId>",
"requestId": "<trxn_hash>",
"amount": 1.03,
"currency": "MVR"
}
```
Expected response: `{ "success": true, "code": 99 }` (OTP required)
> **Note:** This step may be skipped. The app proceeds directly to Step 2b if the gateway already indicates OTP is required.
### Step 2b — Request OTP Channel
```json
{
"action": "approve",
"debitAccount": "<internalAccountId>",
"requestId": "<trxn_hash>",
"amount": 1.03,
"currency": "MVR",
"channel": "token"
}
```
Expected response: `{ "success": true, "code": 22 }` (OTP generated)
### Step 2c — Confirm with TOTP
```json
{
"action": "approve",
"debitAccount": "<internalAccountId>",
"requestId": "<trxn_hash>",
"amount": 1.03,
"currency": "MVR",
"channel": "token",
"otp": "<TOTP>"
}
```
Expected response:
```json
{
"success": true,
"code": 0,
"payload": {
"merchant": "Merchant Name",
"amount": "1.03",
"currency": "MVR"
}
}
```
On failure:
```json
{
"success": false,
"message": "Payment failed"
}
```
---
## Request Fields
| Field | Type | Description |
|---|---|---|
| `action` | `string` | Always `"approve"` |
| `debitAccount` | `string` | Internal account UUID (not the display account number) — from dashboard `internalId` field |
| `requestId` | `string` | The `trxn_hash` from the payrequest lookup |
| `amount` | `number` | Payment amount as a number (e.g. `1.03`) |
| `currency` | `string` | Currency code (e.g. `"MVR"`) |
| `channel` | `string` | `"token"` — present in steps 2b and 2c only |
| `otp` | `string` | TOTP code — present in step 2c only |
> The `debitAccount` field takes the internal UUID from the dashboard response, **not** the displayed account number. See [Dashboard](04-dashboard.md) for the account object structure.
---
## OTP
The OTP is a standard TOTP (RFC 6238, SHA-1, 30-second window, 6 digits) derived from the stored BML authenticator seed — the same seed used for login 2FA.
---
## Prerequisites
- Valid `access_token` from [OAuth Token Exchange](03-oauth-token.md)
- TOTP seed enrolled via BML app
- Account `internalId` from [Dashboard](04-dashboard.md)
---
&nbsp;
---
[← Tap-to-Pay](12-tap-to-pay.md)
-2
View File
@@ -188,8 +188,6 @@ The access token expires after `expires_in` seconds (typically 3600). On a `401`
| 9 | [Contacts](09-contacts.md) | Saved beneficiaries — list, save, delete |
| 10 | [Account Validation](10-validate.md) | Validate BML accounts, aliases, and MIB accounts |
| 11 | [Foreign Limits](11-foreign-limits.md) | USD foreign transaction limits by card and channel |
| 12 | [Tap-to-Pay](12-tap-to-pay.md) | NFC HCE contactless payment — token fetch and EMV APDU exchange |
| 13 | [QR Payment](13-qr-payment.md) | PayMV QR payment — QR formats, payrequest lookup, 3-step pay flow |
---
+14 -24
View File
@@ -32,23 +32,19 @@ curl --request GET \
## Responses
All responses wrap the result in a top-level `data` object.
### Success — Prepaid
```json
{
"data": {
"custType": "PRE",
"msisdn": "9609654321"
}
"custType": "PRE",
"msisdn": "9609654321"
}
```
| Field | Type | Description |
|---|---|---|
| `data.custType` | `string` | `"PRE"` = prepaid customer |
| `data.msisdn` | `string` | The MSISDN that was queried |
| `custType` | `string` | `"PRE"` = prepaid customer |
| `msisdn` | `string` | The MSISDN that was queried |
→ Offer **Raastas** top-up only.
@@ -58,10 +54,8 @@ All responses wrap the result in a top-level `data` object.
```json
{
"data": {
"custType": "POST",
"msisdn": "9609123456"
}
"custType": "POST",
"msisdn": "9609123456"
}
```
@@ -73,10 +67,8 @@ All responses wrap the result in a top-level `data` object.
```json
{
"data": {
"custType": "HYBRID",
"msisdn": "9609789012"
}
"custType": "HYBRID",
"msisdn": "9609789012"
}
```
@@ -88,20 +80,18 @@ All responses wrap the result in a top-level `data` object.
```json
{
"data": {
"custType": null,
"errorMessage": "Data Not Found",
"msisdn": "9609000000"
}
"custType": null,
"errorMessage": "Data Not Found",
"msisdn": "9609000000"
}
```
| Field | Type | Description |
|---|---|---|
| `data.custType` | `null` | Number is not an Ooredoo subscriber |
| `data.errorMessage` | `string` | `"Data Not Found"` |
| `custType` | `null` | Number is not an Ooredoo subscriber |
| `errorMessage` | `string` | `"Data Not Found"` |
Treat `custType: null` or absent as unsupported — fall back to Dhiraagu lookup.
Treat `custType: null` as unsupported — fall back to Dhiraagu lookup.
---
-143
View File
@@ -1,143 +0,0 @@
# App Overview
Architecture overview of the app's entry point, main container, navigation system, and global session lifecycle.
---
## Entry Point — `MainActivity`
`MainActivity` is a transparent trampoline activity. On `onCreate` it reads app state and immediately forwards to the correct destination with no visible UI of its own:
| Condition | Destination |
|---|---|
| Onboarding not done | `OnboardingActivity` |
| No saved credentials | `LoginActivity` |
| Security lock configured | `LockActivity` |
| All checks pass | `HomeActivity` |
### Intent Actions
External intents (from NFC, shortcuts, or notifications) are passed through to `HomeActivity` via the same forwarding intent:
| Action | Effect |
|---|---|
| `OPEN_TRANSFER` | Opens transfer screen |
| `OPEN_SCAN_QR` | Opens QR scanner |
| `OPEN_PAY_WITH_CARD` | Opens BML card QR payment |
| `TAP_TO_PAY` | Opens BML tap-to-pay NFC flow |
`BmlTapToPayActivity` is a dedicated NFC entry point registered in the manifest. It immediately re-fires a `TAP_TO_PAY` intent to `MainActivity` and finishes.
---
## Main Container — `HomeActivity`
`HomeActivity` is the persistent shell containing all in-app screens. It owns:
- The `NavHostFragment` and `NavController`
- The `DrawerLayout` and `NavigationView`
- The `BottomNavigationView`
- The toolbar (lock icon + hide-amounts eye icon)
- The connectivity banner
- The autolock timer
- The MIB session keepAlive scheduler
### Toolbar
| Icon | Behavior |
|---|---|
| Lock icon | Immediately locks the app → `LockActivity` (animated with scale + alpha) |
| Eye icon | Toggles `hideAmounts` in `HomeViewModel`; all balance displays redact to `••••` |
### Auto-refresh
On launch and after unlock `HomeActivity.autoRefresh()` fires parallel login refresh calls for all banks with active sessions. Each bank runs independently — a failure in one bank does not block the others.
### Connectivity Banner
A persistent banner appears at the top of `HomeActivity` when network connectivity is lost. It disappears automatically when connectivity is restored. Per-bank connectivity errors (e.g., session expired) are surfaced via `HomeViewModel.connectivityErrors`.
---
## Navigation Modes
The user can choose between two navigation modes in Settings → Appearance:
### Drawer (default)
A slide-out navigation drawer containing up to 10 configurable nav items. The hamburger icon in the toolbar opens it.
### Bottom Navigation
A bottom bar with 3 configurable slots plus a fixed **Dashboard** tab (always leftmost) and a **More** tab (always rightmost). Tapping **More** opens `NavMoreSheetFragment` — a bottom sheet listing all items not assigned to the 3 visible slots.
---
## Navigation Slots
10 possible navigation destinations can be assigned to slots. The user reorders them via drag-and-drop in Settings → Appearance.
| Destination | Default slot |
|---|---|
| Accounts | 1 |
| Transfer | 2 |
| Activities | 3 |
| Contacts | 4 |
| Financing | 5 |
| OTP | 6 |
| PayMV QR | 7 |
| BML QR Pay | 8 |
| Transfer History | 9 |
| Settings | 10 |
Two **Quick Action** slots appear as FAB-style buttons on the dashboard and are independently configurable.
---
## Autolock
Autolock fires after a configurable period of user inactivity. Any touch event resets the timer.
| Timeout option |
|---|
| 30 seconds |
| 1 minute |
| 3 minutes |
| 5 minutes |
| Never |
When the timeout expires a 10-second countdown warning dialog appears. If dismissed, the timer resets. If ignored, the app calls `LockActivity` and clears `app.isUnlocked`.
---
## Global State — `BasedBankApp`
`BasedBankApp` holds all in-memory session data. Nothing is stored to disk except encrypted credentials.
| Field | Description |
|---|---|
| `isUnlocked` | Set to `true` after successful lock-screen auth; guards against process-restart bypass |
| `mibSessions` | Map of MIB profile ID → active session (cookies + DH key) |
| `bmlSessions` | Map of BML profile ID → OAuth token pair |
| `fahipaySessions` | Map of Fahipay login ID → authID + session cookie |
| `mibLoginFlows` | Active `MibLoginFlow` instances per profile |
| `bmlLoginFlows` | Active `BmlLoginFlow` instances per profile |
| `mibMutex` | Coroutine mutex — serializes all MIB API calls to prevent session corruption |
### Profile Visibility
Each stored profile has a visibility flag. Hidden profiles are excluded from the accounts list and from all API refresh cycles until re-enabled in Settings → Logins.
---
## MIB Session KeepAlive
MIB web sessions expire after approximately 30 seconds of inactivity. `HomeActivity` schedules a coroutine that calls the MIB keepAlive endpoint every 25 seconds for each active MIB session while the app is in the foreground.
---
&nbsp;
---
[← README](README.md) &nbsp;&nbsp;&nbsp; **Next →** [Onboarding](01-onboarding.md)
-83
View File
@@ -1,83 +0,0 @@
# Onboarding
Shown once on first launch. Walks the user through language selection, security setup, and appearance configuration before creating any credentials.
---
## Activity — `OnboardingActivity`
`OnboardingActivity` hosts three sequential fragments managed by a `ViewPager2` with manual paging (swipe disabled). Progress dots are shown below the pager.
Each fragment has a **Continue** button that is only enabled after the user satisfies a completion requirement. Scrolling to the bottom of a slide is required before Continue activates on content slides.
---
## Slide 1 — Language & Welcome (`OnboardingFragment`)
- Displays a welcome illustration and app name
- Language selector chip group (English / Dhivehi)
- Selecting a language immediately updates the app locale
- Continue button becomes active once a language is selected (or immediately if system locale is already supported)
---
## Slide 2 — Security Setup (`SecuritySetupFragment`)
The user chooses a lock method to protect the app.
### Lock Methods
| Method | Description |
|---|---|
| PIN | 48 digit numeric PIN |
| Pattern | Grid pattern draw (minimum 4 nodes) |
### PIN Entry
- Two `EditText` fields: PIN + confirm PIN
- Continue activates only when both fields match and length ≥ 4
### Pattern Entry
- Custom `PatternView` widget
- Draws connecting lines between touched grid nodes in real time
- Two-phase: draw → confirm (must match first drawing)
- Continue activates after a valid matching pattern is confirmed
### Key Derivation
The chosen PIN or pattern string is hardened with **PBKDF2-HMAC-SHA256** (100 000 iterations, random 16-byte salt) before storage. The derived key is stored in encrypted `SharedPreferences` via `CredentialStore`.
### Biometric Option
After setting a PIN or pattern an optional **Enable Biometrics** toggle appears. If enabled, biometric authentication (fingerprint / face — `BIOMETRIC_WEAK`) can be used as an alternative to the PIN/pattern at the lock screen and optionally for transfer confirmation.
---
## Slide 3 — Configure (`OnboardingConfigureFragment`)
Appearance and navigation preferences, set before first login.
### Options
| Setting | Choices |
|---|---|
| Navigation mode | Drawer / Bottom Navigation |
| Theme | System default / Light / Dark |
| Accent colour | Chip selector (several Material colours) |
All preferences are written to `CredentialStore` / `SharedPreferences` immediately on selection so that `HomeActivity` inherits them on first launch.
---
## Completion
When the user taps Continue on slide 3, `OnboardingActivity` sets the `onboardingDone` flag and finishes. `MainActivity` then routes to `LoginActivity` (no credentials yet) on the next launch or immediately via `startActivity`.
---
&nbsp;
---
[← App Overview](00-app-overview.md) &nbsp;&nbsp;&nbsp; **Next →** [Lock Screen](02-lock-screen.md)
-82
View File
@@ -1,82 +0,0 @@
# Lock Screen
`LockActivity` is shown whenever the app is locked — on cold start (when credentials exist), after the autolock timer fires, or when the user taps the lock icon in the toolbar.
---
## Authentication Methods
The app attempts authentication in priority order:
1. **Biometrics** — if enrolled and enabled, `BiometricPrompt` is presented automatically on open
2. **PIN** — numeric keypad
3. **Pattern**`PatternView` grid
The user can switch between biometric and PIN/pattern manually.
---
## Biometric Authentication
Uses Android `BiometricPrompt` with `BIOMETRIC_WEAK` (fingerprint or face depending on device). A successful biometric result sets `app.isUnlocked = true` and calls `MainActivity` to route to `HomeActivity`.
On biometric failure or cancellation the screen falls back to PIN/pattern entry.
---
## PIN Entry
- A custom on-screen numeric keypad (09 + backspace + confirm)
- The entered digits are shown as filled/unfilled circles (no digit echo)
- Confirm fires verification immediately when the correct number of digits is entered
---
## Pattern Entry
- The same `PatternView` widget used in onboarding, in verify-only mode
- The drawn pattern is hashed and compared against the stored derived key
---
## Verification
The entered PIN or pattern is run through **PBKDF2-HMAC-SHA256** with the stored salt and compared to the stored hash. On match:
1. `app.isUnlocked = true`
2. `LockActivity` finishes
3. `MainActivity` routes to `HomeActivity`
On mismatch the attempt counter increments and an error shake animation plays.
---
## Brute-Force Protection
| Threshold | Behaviour |
|---|---|
| 14 wrong attempts | Error label shown, counter visible |
| 5 wrong attempts | 30-second lockout; keypad/pattern disabled |
| After lockout | Counter resets; user may try again |
The attempt counter and lockout timestamp are stored in **plain** `SharedPreferences` (not encrypted) — a known limitation documented in the security audit. The app does not wipe credentials after repeated failures.
---
## `app.isUnlocked` Guard
`app.isUnlocked` is an in-memory flag that is `false` on every process start. Even if an attacker bypasses `LockActivity` via `adb`, `HomeActivity` checks this flag and re-fires `LockActivity` on resume if it is `false`. This prevents cold-start bypass.
---
## Screenshot Protection
`FLAG_SECURE` is set on `LockActivity`'s window, preventing screenshots and screen recording. This is always on for the lock screen regardless of the user's global screenshots setting.
---
&nbsp;
---
[← Onboarding](01-onboarding.md) &nbsp;&nbsp;&nbsp; **Next →** [Login](03-login.md)
-96
View File
@@ -1,96 +0,0 @@
# Login
`LoginActivity` handles adding bank accounts. It is shown on first launch (after onboarding) and also opened from Settings → Logins → Add Account.
---
## Fragment Flow
```
LoginActivity
└─ BankSelectionFragment ← pick a bank
└─ CredentialsFragment ← enter credentials for that bank
```
---
## Bank Selection — `BankSelectionFragment`
A scrollable list of supported banks presented as selectable cards:
| Bank | Notes |
|---|---|
| MIB (Maldives Islamic Bank) | Username + password |
| BML (Bank of Maldives) | Username + password |
| Fahipay | Mobile number + password |
Tapping a card navigates to `CredentialsFragment` with the selected bank pre-set.
---
## Credentials — `CredentialsFragment`
### MIB Login
Fields:
- Username
- Password
Flow on submit:
1. `MibLoginFlow.login()` — performs Diffie-Hellman key exchange, then authenticates with Blowfish/ECB-encrypted credentials
2. On success, fetches `operatingProfiles` — the list of CIF profiles (Individual, Sole Propr, etc.)
3. Each profile is stored as a `MibAccount` with `bank = "MIB"` and `cifType` from the API
4. Sessions are stored in `BasedBankApp.mibSessions`
### BML Login
Fields:
- Username (customer ID)
- Password
Flow on submit:
1. `BmlLoginFlow.login()` — OAuth password grant → access token + refresh token
2. Fetches dashboard → list of CASA accounts + cards
3. Each account/card stored as `MibAccount` with `bank = "BML"`
4. Tokens stored in `BasedBankApp.bmlSessions`
### Fahipay Login
Fields:
- Mobile number (7-digit local, auto-prefixed with +960)
- Password
Flow on submit:
1. `FahipayLoginFlow.login()` — authenticates against Fahipay API
2. On success, stores `authID` + `__Secure-sess` cookie
3. Single wallet account stored with `bank = "FAHIPAY"`
---
## Multi-Profile Support
Each MIB login can have multiple CIF profiles (e.g., an individual and a business account under the same username). Each profile appears as a separate entry in the accounts list and can be toggled independently in Settings → Logins.
BML and Fahipay each yield a single profile per login.
Adding the same bank login a second time merges its profiles into the existing login rather than creating a duplicate.
---
## Credential Storage
All credentials (username, password, tokens, session cookies) are encrypted via `CredentialStore`, which uses Android `EncryptedSharedPreferences` backed by a hardware-keystore key where available.
---
## After Login
`CredentialsFragment` calls `app.autoRefresh()` after a successful login, then navigates back to `LoginActivity`'s result which routes to `HomeActivity` (or back to Settings if called from there).
---
&nbsp;
---
[← Lock Screen](02-lock-screen.md) &nbsp;&nbsp;&nbsp; **Next →** [Accounts](04-accounts.md)
-68
View File
@@ -1,68 +0,0 @@
# Accounts
The accounts screen is typically the default home destination. It shows all active bank accounts and cards grouped by bank and profile.
---
## Fragment — `AccountsFragment`
Hosts a `RecyclerView` driven by `AccountsAdapter`. Observes `HomeViewModel.accounts` (a `LiveData<List<MibAccount>>`). The list is filtered to only include accounts whose profile visibility flag is enabled.
A **pull-to-refresh** gesture triggers `HomeActivity.autoRefresh()`, which re-fetches all bank dashboards in parallel.
---
## List Structure — `AccountsAdapter`
The adapter renders a mixed list of section headers and account rows.
### Section Headers
Accounts are grouped by bank + CIF type (for MIB) or bank name (for BML/Fahipay). Each group starts with a header row showing:
- Bank name and logo
- For MIB: `cifType` (e.g., `"Individual"`, `"Sole Propr"`) — never hardcoded, always from API
- Profile image (circular avatar, if set)
### Account / Card Rows
Each row is bound from an `AccountListDisplay` object produced by `AccountListParser.from(account)`. See [Account Parser Architecture](PARSERS.md) for mapping details.
| Field | Row element |
|---|---|
| `name` | Account or card name |
| `number` | Masked account/card number |
| `typeLabel` | Product type chip (e.g., `"Savings"`, `"Visa Platinum"`) |
| `balance` | Balance string (hidden as `••••` when hide-amounts is active) |
| `isCard` | Switches between account layout and card layout |
| `cardBrandIcon` | Visa / Mastercard / Amex logo drawable |
| `statusLabel` | Shown as an amber chip if non-null (e.g., `"Inactive"`) |
### Quick-Transfer Shortcut
Each account row has a **Send** button. Tapping it opens `TransferFragment` with the source account pre-selected.
---
## Account Tap — History
Tapping any account row navigates to `AccountHistoryFragment` for that account.
---
## Hide Amounts
When the toolbar eye icon is toggled (or `HomeViewModel.hideAmounts` is `true`), all balance strings in the adapter are replaced with `"••••"` without re-fetching data.
---
## Empty State
If no accounts are loaded (either no credentials or all profiles hidden), the screen shows an empty-state illustration with a prompt to add an account.
---
&nbsp;
---
[← Login](03-login.md) &nbsp;&nbsp;&nbsp; **Next →** [Account History](05-account-history.md)
-72
View File
@@ -1,72 +0,0 @@
# Account History
Displays the transaction history for a single account. Opened by tapping an account row in the accounts list.
---
## Fragment — `AccountHistoryFragment`
Receives the selected `MibAccount` via navigation arguments.
---
## Data Loading
On open, the fragment calls the appropriate bank API to fetch the first page of transactions:
| Bank | API |
|---|---|
| MIB | MIB transaction history endpoint (Blowfish-encrypted) |
| BML | BML transaction history endpoint (OAuth Bearer) |
| Fahipay | Fahipay wallet transaction list |
Results are mapped to a common display model and shown in a `RecyclerView`.
---
## Infinite Scroll
The list supports **infinite scroll** (pagination). When the user scrolls near the bottom of the loaded items, the next page is automatically fetched and appended. A loading spinner appears at the bottom while a page is in flight.
Page state (current page, total pages) is tracked in the fragment's `ViewModel`. If the last page has been reached the spinner is hidden and no further requests are made.
---
## Search / Filter
A search bar at the top of the screen filters the loaded transaction list by:
- Description / narrative text
- Amount string
Filtering is performed locally on already-loaded pages — it does not trigger a new API call. Clearing the search bar restores the full list.
---
## Transaction Rows
Each row shows:
- Transaction date and time
- Description / merchant name
- Debit or credit indicator
- Amount (hidden as `••••` when hide-amounts is active)
- Running balance (where available from the bank API)
---
## Image Loading
Some MIB transaction entries include merchant logo URLs. These are loaded asynchronously into the row's image view with a generic fallback icon. Images are cached in memory for the session.
---
## Empty State
If no transactions exist (new account or API returned empty list) an empty-state message is shown.
---
&nbsp;
---
[← Accounts](04-accounts.md) &nbsp;&nbsp;&nbsp; **Next →** [Transfer History](06-transfer-history.md)
-55
View File
@@ -1,55 +0,0 @@
# Transfer History
Shows a merged, chronologically sorted list of outgoing transfers across all connected bank accounts.
---
## Fragment — `TransferHistoryFragment`
Observes `HomeViewModel` for loaded account data and triggers parallel history fetches.
---
## Data Loading
On open, the fragment launches parallel coroutines — one per active bank session — to fetch transfer/payment history from each bank's API. Results arrive independently and are merged into a single sorted list as each bank completes.
| Bank | Source |
|---|---|
| MIB | MIB transfer history endpoint |
| BML | BML payment history endpoint |
| Fahipay | Fahipay payment history |
A per-bank loading indicator is shown while that bank's data is in flight. If one bank fails (session expired, network error) its section shows an error row rather than crashing the whole list.
---
## List Display
The merged list is sorted by date descending (newest first). Each row shows:
- Bank logo / icon
- Recipient name or account number
- Date and time
- Amount (hidden as `••••` when hide-amounts is active)
- Transfer status (where available)
---
## Pull-to-Refresh
A pull-to-refresh gesture re-fires all parallel fetches and rebuilds the merged list.
---
## Empty State
If no transfers are found across any bank, an empty-state illustration is shown.
---
&nbsp;
---
[← Account History](05-account-history.md) &nbsp;&nbsp;&nbsp; **Next →** [Transfer](07-transfer.md)
-106
View File
@@ -1,106 +0,0 @@
# Transfer
The transfer screen initiates account-to-account fund transfers. It supports MIB, BML, and Fahipay as source banks and handles all bank-specific authentication and OTP steps.
---
## Fragment — `TransferFragment`
Opened via:
- Navigation menu
- Quick-transfer button on an account row (source pre-selected)
- `OPEN_TRANSFER` intent action
- QR scan result (recipient and optional amount pre-filled)
---
## Source Account Selection
A dropdown lists all visible accounts parsed via `AccountListParser.from(acc)?.balance`. The selected source account determines which bank's transfer flow is used.
---
## Recipient Entry
The user can specify a recipient in three ways:
1. **Manual entry** — type an account number directly
2. **Contact picker** — opens `ContactPickerSheetFragment` to select a saved contact
3. **QR scan** — opens the camera scanner; a PayMV QR result pre-fills the account number, amount, and remarks
---
## Fields
| Field | Notes |
|---|---|
| Source account | Dropdown; balance shown below |
| Recipient account number | Text input or filled from contact/QR |
| Recipient name | Auto-looked up from bank API after account number entry |
| Amount | Numeric; pre-filled from QR if available |
| Remarks / purpose | Free text; pre-filled from QR if available |
---
## Recipient Lookup
After the user finishes entering a recipient account number, the app calls the source bank's name-lookup API:
- **MIB**: account name lookup via MIB API
- **BML**: beneficiary lookup via BML API
- **Fahipay**: account name resolution via Fahipay API
The resolved name is displayed below the account number field for the user to confirm.
---
## Biometric Gate
If biometric-for-transfers is enabled in Settings → Security, `BiometricPrompt` is shown before the transfer is submitted. A failed or cancelled biometric blocks submission.
---
## Bank-Specific Flows
### MIB Transfer
1. Validates fields
2. (If biometric gate) prompts biometrics
3. Submits transfer via `MibLoginFlow` using active MIB session (serialized through `mibMutex`)
4. On success, shows `TransferReceiptFragment`
### BML Transfer
1. Validates fields
2. (If biometric gate) prompts biometrics
3. Initiates BML transfer — server responds with OTP required
4. Navigates to `OtpFragment` to collect the TOTP
5. Re-submits with OTP
6. On success, shows `TransferReceiptFragment`
### Fahipay Transfer
1. Validates fields
2. (If biometric gate) prompts biometrics
3. Submits via Fahipay API using stored `authID` + session cookie
4. On success, shows `TransferReceiptFragment`
---
## Transfer Receipt
On success the fragment navigates to `TransferReceiptFragment` passing the completed transfer details.
---
## Error Handling
All bank API errors are shown as a `Snackbar` or inline error message. Session expiry triggers a re-authentication prompt rather than a crash.
---
&nbsp;
---
[← Transfer History](06-transfer-history.md) &nbsp;&nbsp;&nbsp; **Next →** [Contacts](08-contacts.md)
-80
View File
@@ -1,80 +0,0 @@
# Contacts
The contacts screen stores and manages frequently used transfer recipients. Contacts are local to the app and never synced externally.
---
## Fragment — `ContactsFragment`
Displays the full contact list as a `RecyclerView`. Observes `HomeViewModel.contacts` and `HomeViewModel.contactCategories`.
---
## Contact List
Each contact row shows:
- Circular avatar (profile image if set, otherwise initials placeholder)
- Display name
- Account number(s)
- Category chip (if assigned)
Tapping a contact row opens `AddContactSheetFragment` in edit mode.
Tapping the **Transfer** button on a contact row opens `TransferFragment` with the recipient pre-filled.
---
## Categories
Contacts can be assigned to user-defined categories (e.g., "Family", "Business"). Categories appear as filter chips at the top of the list. Tapping a chip filters the list to that category. Tapping again clears the filter.
---
## Add Contact — `AddContactSheetFragment`
A bottom sheet for creating or editing a contact.
### Fields
| Field | Notes |
|---|---|
| Name | Display name |
| Account number | Primary transfer account number |
| Bank | Optional — for display only |
| Category | Optional; selectable from existing categories or create new |
| Profile image | Optional; select from gallery or camera |
### Profile Image
The pencil icon next to the avatar opens a chooser:
- **Gallery** — pick from device gallery
- **Camera** — capture a new photo (temp file in `cacheDir`)
The image is stored locally in `filesDir/profile_images/` via `ProfileImageStore` with key `"contact_{id}"`.
### Save
On save, the contact is persisted to the local database and `HomeViewModel.contacts` is refreshed.
### Delete
A delete button (with confirmation dialog) removes the contact and its profile image.
---
## Contact Picker — `ContactPickerSheetFragment`
A compact bottom sheet version of the contact list, used by `TransferFragment` when the user taps **Choose Contact**.
- Shows all contacts with avatar and name
- Search bar filters by name or account number
- Tapping a contact returns the selection to `TransferFragment` and dismisses the sheet
- Profile images use a `"local:{key}"` synthetic hash prefix to identify locally stored images
---
&nbsp;
---
[← Transfer](07-transfer.md) &nbsp;&nbsp;&nbsp; **Next →** [Activities](09-activities.md)
-65
View File
@@ -1,65 +0,0 @@
# Activities
The activities screen shows a local log of completed transfers initiated within the app, along with receipt viewing and sharing.
---
## Fragment — `ActivitiesFragment`
Displays a chronological `RecyclerView` of locally stored transfer records. These records are written by the app at transfer completion time — they are not fetched from bank APIs.
---
## Activity List
Each row shows:
- Bank logo
- Recipient name and account number
- Transfer amount (hidden as `••••` when hide-amounts is active)
- Date and time
- Status badge (Completed / Failed)
Tapping a row opens `TransferReceiptFragment` for that record.
---
## Transfer Receipt — `TransferReceiptFragment`
A full-screen receipt view shown immediately after a successful transfer and accessible later from the activities list.
### Receipt Fields
| Field | Notes |
|---|---|
| Bank | Source bank logo and name |
| From account | Sender account number |
| To account | Recipient account number |
| Recipient name | As resolved at transfer time |
| Amount | Formatted with currency |
| Remarks | Transfer purpose text |
| Reference number | Bank-issued transaction reference |
| Date and time | Transfer timestamp |
| Status | Completed / Failed |
### Actions
- **Share** — generates a text or image summary of the receipt and opens the system share sheet
- **Save to Gallery** — renders the receipt as a bitmap and saves it to the device's Pictures folder (requires `WRITE_EXTERNAL_STORAGE` on API < 29, or `MediaStore` on API 29+)
### Screenshot Note
If `FLAG_SECURE` is active (user has enabled the screenshots restriction), the Save to Gallery action uses an off-screen rendering path that bypasses the restriction for the explicit save action only.
---
## Empty State
If no local transfer records exist, an empty-state illustration is shown with a prompt to make a transfer.
---
&nbsp;
---
[← Contacts](08-contacts.md) &nbsp;&nbsp;&nbsp; **Next →** [OTP Screen](10-otp-screen.md)
-62
View File
@@ -1,62 +0,0 @@
# OTP Screen
Displays the current TOTP (Time-based One-Time Password) code for each enrolled bank authenticator. Used when confirming transfers, QR payments, or other 2FA-protected operations.
---
## Fragment — `OtpFragment`
Hosts one card per enrolled bank authenticator. Banks with no stored TOTP seed are not shown.
---
## TOTP Display
Each card shows:
- Bank logo and name
- The current 6-digit TOTP code (large text)
- A circular countdown ring showing time remaining in the current 30-second window
- The code refreshes automatically when the window expires — no user interaction needed
### Algorithm
Standard RFC 6238 TOTP:
- Hash: SHA-1
- Window: 30 seconds
- Digits: 6
- Seed: stored per-bank in `CredentialStore` (encrypted)
---
## Supported Banks
| Bank | Seed source |
|---|---|
| BML | Enrolled via BML app setup; seed stored in `CredentialStore` |
| MIB | MIB business/corporate OTP seed (if applicable) |
---
## Background Name Refresh
When the screen opens, the fragment may fire a background API call to refresh the account holder name associated with each seed. This is a best-effort call — failure does not affect OTP display.
---
## Usage
The OTP screen is informational — the user copies the displayed code manually and enters it wherever required (e.g., in `TransferFragment`'s OTP dialog, or in an external portal). The code is never submitted automatically from this screen.
---
## Security
The TOTP seeds are stored encrypted in `CredentialStore`. They are never logged or included in error reports.
---
&nbsp;
---
[← Activities](09-activities.md) &nbsp;&nbsp;&nbsp; **Next →** [PayMV QR Screen](11-paymv-qr-screen.md)
-66
View File
@@ -1,66 +0,0 @@
# PayMV QR Screen
Handles both sides of PayMV/Favara QR payments: generating a receive-payment QR code and scanning a QR code to initiate a transfer.
---
## Fragment — `PayMvQrFragment`
Two tabs: **Receive** (generate QR) and **Send** (scan QR).
---
## Receive Tab — Generate QR
Generates a static PayMV QR code that others can scan to pay the user.
### Fields
| Field | Notes |
|---|---|
| Source account | Dropdown of all visible accounts; determines acquirer BIC |
| Amount | Optional — leave blank for open-amount QR |
| Purpose | Optional free-text payment purpose |
| Name | Auto-filled from the selected account's holder name |
### Generation
On tap **Generate**, the fragment builds a decimal TLV payload per the [PayMV QR Format](18-paymv-qr-format.md) spec:
1. Assembles all TLV fields (format indicator, point-of-initiation, tag 26 container, MCC, currency, amount if set, country, name, tag 62 container, tag 80 container)
2. Selects the acquirer BIC from the source account's bank (`MALBMVMV` / `MADVMVMV` / `FAHIMVMV`)
3. Appends `"6304"` and computes CRC-16/CCITT-FALSE over the full string
4. Renders the complete string as a QR code bitmap using the ZXing encoder
5. Displays the QR code full-screen with the account number and name below
### Share
A **Share** button exports the QR bitmap via the system share sheet (image/png).
---
## Send Tab — Scan QR
Scans a QR code and pre-fills the transfer screen.
### Scanner
Opens the device camera with a QR viewfinder overlay. Supported QR formats:
| QR type | Handling |
|---|---|
| PayMV / Favara decimal TLV | Parse account number (tag 26→03), amount (tag 54), name (tag 59), purpose (tag 62→08); navigate to `TransferFragment` pre-filled |
| BML plain URL (`https://pay.bml.com.mv/app/...`) | Navigate to `BmlQrPayFragment` |
| BML embedded in combined QR (root tag 35 → sub 20 → sub-sub 01) | Extract URL; navigate to `BmlQrPayFragment` |
### Error Handling
If the scanned code is not a recognized format, a `Snackbar` error is shown and the scanner remains open.
---
&nbsp;
---
[← OTP Screen](10-otp-screen.md) &nbsp;&nbsp;&nbsp; **Next →** [BML QR Pay](12-bml-qr-pay.md)
-87
View File
@@ -1,87 +0,0 @@
# BML QR Pay
Handles BML gateway QR payments — scanning a merchant QR code and completing the 3-step TOTP-authenticated payment flow.
---
## Fragment — `BmlQrPayFragment`
Opened via:
- Scanning a BML plain URL QR in `PayMvQrFragment`
- Scanning a combined Fahipay/PayMV QR that embeds a BML gateway URL
- The `OPEN_PAY_WITH_CARD` intent action
---
## Step 1 — Resolve QR
The fragment receives a BML gateway URL (e.g., `https://pay.bml.com.mv/app/<base64>`).
It calls the BML `payrequest` lookup API (see [QR Payment](../bmlapi/13-qr-payment.md)) to resolve the URL to merchant details:
- Merchant name (narrative1)
- Merchant address (narrative2, narrative3)
- Amount (`"0.00"` for static QRS, or preset amount for QRR)
- Currency
The resolved details are displayed on screen for the user to review before paying.
---
## Source Account Selection
A dropdown lists all BML accounts. The selected account's internal UUID (`internalId`) is used as `debitAccount` in the payment request.
---
## Amount Entry
- If the QR is a **static QRS** (amount `"0.00"`), the amount field is editable and required
- If the QR is a **dynamic QRR**, the amount is pre-filled and read-only
---
## Step 2 — TOTP Payment
The payment uses the standard 3-step BML TOTP flow:
### 2a — Initiate
POST to `/walletpayments/pay` with `action: "approve"`, `debitAccount`, `requestId` (the `trxn_hash`), `amount`, `currency`. Expected response: `code: 99` (OTP required).
> This step may be skipped if the gateway already indicates OTP is required.
### 2b — Request OTP Channel
Same POST with `channel: "token"` added. Expected response: `code: 22` (OTP generated and sent to the authenticator).
### 2c — Confirm with TOTP
The fragment presents an OTP input dialog. The user opens the [OTP Screen](12-otp-screen.md) or reads the TOTP from their authenticator app, then enters the 6-digit code.
Same POST with `channel: "token"` and `otp: "<code>"`. On success: `code: 0` with merchant and amount in `payload`.
---
## Success
On successful payment a confirmation card is shown:
- Merchant name
- Amount paid
- Currency
A **Done** button dismisses the fragment.
---
## Error Handling
Failed payments (`success: false`) display the `message` field from the API response. The user may retry with a fresh TOTP code.
---
&nbsp;
---
[← PayMV QR Screen](11-paymv-qr-screen.md) &nbsp;&nbsp;&nbsp; **Next →** [Financing](13-financing.md)
-62
View File
@@ -1,62 +0,0 @@
# Financing
Aggregates financing products across banks — MIB promotional deals and BML loan details — in a single screen.
---
## Fragment — `FinancingFragment`
Observes `HomeViewModel.financing` (MIB deals) and `HomeViewModel.bmlLoanDetails`.
---
## MIB Deals Section
Displays current MIB promotional financing offers fetched from the MIB API. Each deal card shows:
- Deal name / product title
- Key terms (profit rate, tenure, minimum/maximum amount)
- A **Learn More** action that opens the deal detail in an in-app WebView or external browser
Data is loaded once on fragment creation and refreshed on pull-to-refresh.
---
## BML Loans Section
Displays the user's active BML loan/financing accounts. Each card shows:
- Loan product name
- Outstanding balance
- Next instalment amount and due date
- Loan account number
Data comes from `HomeViewModel.bmlLoanDetails`, which is fetched from the BML loans API using the active BML session.
---
## Card Limits Section (BML)
`HomeViewModel.bmlLimits` provides credit card limit information for BML card accounts. Displayed alongside the loan section:
- Card name
- Total limit
- Available limit
- Used amount
---
## Pull-to-Refresh
Refreshes both MIB deals and BML loan/limit data independently. Each section shows its own loading indicator.
---
## Empty State
If no MIB session is active or no BML financing accounts exist, the respective section shows an empty-state message with a prompt to add the corresponding bank account.
---
&nbsp;
---
[← BML QR Pay](12-bml-qr-pay.md) &nbsp;&nbsp;&nbsp; **Next →** [Settings](14-settings.md)
-71
View File
@@ -1,71 +0,0 @@
# Settings
The settings hub and the logins management screen.
---
## Settings Hub — `SettingsFragment`
A simple preference-list screen with navigation links to sub-sections:
| Entry | Destination |
|---|---|
| Logins | `SettingsLoginsFragment` |
| Security | `SettingsSecurityFragment` |
| Appearance | `SettingsAppearanceFragment` |
| Storage | `SettingsStorageFragment` |
---
## Logins — `SettingsLoginsFragment`
Manages all connected bank accounts and profiles.
### Account List
Each connected login is shown as a card. Within each login card, individual profiles (CIF profiles for MIB, or the single account for BML/Fahipay) are listed.
Each profile entry shows:
- Profile name / CIF type
- Account number(s)
- Profile image (circular avatar)
- Visibility toggle switch
### Visibility Toggle
Toggling a profile off hides it from the accounts list and excludes it from API refresh cycles. The session is kept alive — the profile can be re-enabled without re-logging in.
### Profile Image
A pencil icon (`ic_edit`) next to each profile opens an image chooser:
- **Gallery** — pick from device photo library
- **Camera** — capture via device camera (temp file: `cacheDir/profile_photo_tmp.jpg`)
Profile images are stored locally via `ProfileImageStore`:
- BML: key `bml_{profileId}`
- Fahipay: key `fahipay_{loginId}`
- MIB: uploaded to the MIB server via profile image API (P40); retrieved via hash (P41); deleted via P42
### Add Account
A **+** (Add) button navigates to `LoginActivity``BankSelectionFragment` to add a new bank login.
### Logout
A **Logout** button on each login card shows a confirmation dialog. On confirm:
- Session tokens are revoked where supported
- Credentials are removed from `CredentialStore`
- All associated `MibAccount` objects are removed from `BasedBankApp`
- The accounts list is refreshed
### Business OTP Seed (MIB)
For MIB corporate/business profiles, an **OTP Seed** entry allows importing a TOTP seed string. The seed is stored encrypted in `CredentialStore` and used by the [OTP Screen](12-otp-screen.md).
---
&nbsp;
---
[← Financing](13-financing.md) &nbsp;&nbsp;&nbsp; **Next →** [Settings — Security](15-settings-security.md)
-59
View File
@@ -1,59 +0,0 @@
# Settings — Security
Controls the app lock method, biometric options, auto-lock timeout, and screenshot restriction.
---
## Fragment — `SettingsSecurityFragment`
---
## Change Lock Method
A **Change PIN** or **Change Pattern** button (label reflects current method) opens the security setup flow (same `SecuritySetupFragment` as onboarding) in change-mode. The user must first authenticate with the current PIN/pattern/biometric before the new one is accepted.
---
## Biometrics
A toggle to enable or disable biometric authentication (fingerprint / face — `BIOMETRIC_WEAK`).
- When enabled, `BiometricPrompt` is offered at the lock screen as an alternative to PIN/pattern
- A secondary toggle controls whether biometrics are also required for transfer confirmation
Biometric availability is checked via `BiometricManager.canAuthenticate()`. The toggle is disabled with an explanatory message if the device has no enrolled biometrics.
---
## Auto-Lock Timeout
A radio group or dropdown to set the inactivity timeout:
| Option | Timeout |
|---|---|
| 30 seconds | 30 s |
| 1 minute | 60 s |
| 3 minutes | 180 s |
| 5 minutes | 300 s |
| Never | Disabled |
The selection is stored in `SharedPreferences` and read by `HomeActivity` on each timer reset.
---
## Screenshots
A toggle for `FLAG_SECURE` on `HomeActivity`'s window.
- **On (default)**: screenshots and screen recording are blocked system-wide while the app is in the foreground
- **Off**: screenshots are allowed
The lock screen always has `FLAG_SECURE` regardless of this setting.
---
&nbsp;
---
[← Settings](14-settings.md) &nbsp;&nbsp;&nbsp; **Next →** [Settings — Appearance](16-settings-appearance.md)
-86
View File
@@ -1,86 +0,0 @@
# Settings — Appearance
Controls navigation mode, nav slot assignment, theme, accent colour, and language.
---
## Fragment — `SettingsAppearanceFragment`
---
## Navigation Mode
A toggle/radio group:
| Mode | Description |
|---|---|
| Drawer | Slide-out navigation drawer (default) |
| Bottom Navigation | Bottom bar with 3 visible slots + Dashboard + More |
Changing mode takes effect immediately; `HomeActivity` recreates its navigation structure.
---
## Navigation Slot Customisation — `NavCustomization`
A drag-and-drop list of all 10 navigation destinations. The user reorders items to assign them to slots.
### Drawer Mode
All 10 items appear in the drawer in the configured order.
### Bottom Navigation Mode
- Slots 13 appear in the bottom bar
- Slot 410 appear in the **More** bottom sheet (`NavMoreSheetFragment`)
- Dashboard is always pinned as the first tab and is not part of the 10-item pool
### Quick Action Slots
Two dedicated quick-action slots are configured separately at the bottom of the customisation screen. These map to FAB-style buttons shown on the dashboard card.
### Persistence
The ordered list is serialised to `SharedPreferences` as a comma-separated string of destination IDs.
---
## Theme
A three-way selector:
| Option | Behaviour |
|---|---|
| System default | Follows the device's dark/light mode |
| Light | Forces light theme |
| Dark | Forces dark theme |
Applied via `AppCompatDelegate.setDefaultNightMode()`.
---
## Accent Colour
A horizontal chip row with several Material colour options. The selected accent is applied to the app's `MaterialTheme` colour scheme (primary / secondary).
---
## Language
A dropdown or chip selector:
| Option |
|---|
| System default |
| English |
| Dhivehi |
Applied via `AppCompatDelegate` locale override. Takes effect immediately (activity recreate).
---
&nbsp;
---
[← Settings — Security](15-settings-security.md) &nbsp;&nbsp;&nbsp; **Next →** [Settings — Storage](17-settings-storage.md)
-59
View File
@@ -1,59 +0,0 @@
# Settings — Storage
Manages locally cached data and profile images.
---
## Fragment — `SettingsStorageFragment`
Displays an overview of locally stored data categories with clear actions for each.
---
## Stored Data Categories
| Category | Location | Description |
|---|---|---|
| Profile images | `filesDir/profile_images/` | BML and Fahipay profile photos stored by `ProfileImageStore` |
| Contact images | `filesDir/profile_images/` | Contact avatar photos |
| Transaction image cache | In-memory / HTTP cache | Merchant logos loaded in account history |
| Camera temp file | `cacheDir/profile_photo_tmp.jpg` | Temp file from camera capture; automatically overwritten on next camera use |
| Transfer receipts | Local database | Completed transfer records shown in Activities |
---
## Clear Actions
### Clear Profile Images
Deletes all files in `filesDir/profile_images/` for BML and Fahipay profiles. MIB profile images are stored server-side and are not affected. After clearing, avatars fall back to the initials placeholder.
### Clear Contact Images
Deletes all contact profile images. Contact records are preserved.
### Clear All Caches
Clears:
- `cacheDir` contents (including camera temp file and HTTP response cache)
- In-memory image caches
Does **not** clear credentials, sessions, or transfer records.
### Clear Transfer History
Deletes all locally stored transfer receipt records from the database. This action is irreversible and requires a confirmation dialog.
---
## Storage Usage
The screen may show an approximate size for each category (calculated by summing file sizes in the respective directories).
---
&nbsp;
---
[← Settings — Appearance](16-settings-appearance.md)

Some files were not shown because too many files have changed in this diff Show More