85 Commits

Author SHA1 Message Date
shihaam 24021d7eeb release v1.0.20
Build and Release APK / build (push) Failing after 13m20s
Auto Tag on Version Change / check-version (push) Failing after 14m16s
2026-06-13 23:19:29 +05:00
shihaam e997969070 contact picker recents will not put most recent contact top
Auto Tag on Version Change / check-version (push) Failing after 10m46s
2026-06-13 23:17:57 +05:00
shihaam 3182e14873 fix mib account history header pill
Auto Tag on Version Change / check-version (push) Failing after 13m52s
2026-06-13 23:04:52 +05:00
shihaam 52d2eb235b display outstanding and unbilled credit card values and also fix 00.00 values on credit card statement
Auto Tag on Version Change / check-version (push) Failing after 12m48s
2026-06-13 22:55:54 +05:00
shihaam ae18a8c6c8 fix MIB USD history shows wrong amounts.. #39
Auto Tag on Version Change / check-version (push) Failing after 10m28s
2026-06-13 21:53:15 +05:00
shihaam a8cd22cbe1 update docs
Auto Tag on Version Change / check-version (push) Failing after 13m32s
2026-06-13 21:30:12 +05:00
shihaam 281864347e update docs
Auto Tag on Version Change / check-version (push) Failing after 14m52s
2026-06-13 19:18:49 +05:00
shihaam 16fd909c7f fix some currency detection issues with MIB to MIB, and also Option to automatically add contact during BML to MIB USD transfer #35 (its not fully auto)
Auto Tag on Version Change / check-version (push) Failing after 13m51s
2026-06-13 19:02:34 +05:00
shihaam a95ca0e7a5 rename settings item to confirm transaction now that it inlcuded Tap to Pay
Auto Tag on Version Change / check-version (push) Failing after 12m17s
2026-06-13 18:16:26 +05:00
shihaam 286a6f845d Fix bug that said Cards in tap to pay screen when launched from shortcut and user didnt have biometrics enabled
Auto Tag on Version Change / check-version (push) Failing after 10m58s
2026-06-13 18:12:42 +05:00
shihaam 5b5f776715 remove huge gap in non-edge-to-edge devices with bottom bar in cards and cards manage screen
Auto Tag on Version Change / check-version (push) Successful in 13s
2026-06-13 18:05:10 +05:00
shihaam 98990544fc release v1.0.19
Build and Release APK / build (push) Successful in 3m17s
Auto Tag on Version Change / check-version (push) Failing after 11m17s
2026-06-13 17:42:28 +05:00
shihaam 798e9da9ca idek what these logs doing here
Auto Tag on Version Change / check-version (push) Failing after 12m26s
2026-06-13 17:41:15 +05:00
shihaam 014c002ebe add BML and MIB card freeze/unfreeze
Auto Tag on Version Change / check-version (push) Failing after 13m35s
2026-06-13 17:40:09 +05:00
shihaam 6f8b7130fe notifcation icon white theme fixed
Auto Tag on Version Change / check-version (push) Failing after 13m37s
2026-06-10 16:20:09 +05:00
shihaam 05430f043a background service and push notifications
Auto Tag on Version Change / check-version (push) Failing after 14m1s
2026-06-10 14:19:43 +05:00
shihaam 80bbacc130 Optimize Notifcation loading
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-06-10 13:46:18 +05:00
shihaam 570e6b750b rever commit/4b1c2419 but keeping contact picker fix
Auto Tag on Version Change / check-version (push) Failing after 14m18s
2026-06-10 13:34:26 +05:00
shihaam 21fbd8b12c reoder cards BML active > MIB > BML not active address #38
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-06-10 01:50:49 +05:00
shihaam d0f46e2118 clean up mib notifications
Auto Tag on Version Change / check-version (push) Failing after 14m59s
2026-06-10 01:23:46 +05:00
shihaam 71002ed70c pull more notifications at once 2026-06-10 00:33:43 +05:00
shihaam fbc34d6435 remove log in notification spam
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-06-10 00:24:26 +05:00
shihaam 4b1c2419ec persist mib sessions on disk instead of refreshing token and fix contact picker render issue #37
Auto Tag on Version Change / check-version (push) Failing after 15m8s
2026-06-10 00:17:43 +05:00
shihaam 26dcb20f7f release v1.0.18
Auto Tag on Version Change / check-version (push) Failing after 11m37s
Build and Release APK / build (push) Failing after 15m28s
2026-06-05 02:46:08 +05:00
shihaam 33eb33e18c Add support for otpauth:// and otpauth-migration:// QR scan during login
Auto Tag on Version Change / check-version (push) Failing after 11m54s
2026-06-05 01:15:59 +05:00
shihaam 6a910facaf add an about page #25
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-06-04 23:12:27 +05:00
shihaam e3c6b3a695 Add NFC related prompts (on/off/default/not supported), release version 1.0.17
Auto Tag on Version Change / check-version (push) Failing after 14m33s
Build and Release APK / build (push) Failing after 18m35s
2026-06-04 02:03:15 +05:00
shihaam e978f11343 release version 1.0.16
Auto Tag on Version Change / check-version (push) Failing after 12m55s
Build and Release APK / build (push) Failing after 17m0s
2026-06-04 01:39:23 +05:00
shihaam d227d468b1 add notifcations #24
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-06-04 01:38:38 +05:00
shihaam d0fb88d15a fix Balance not updated after BML QR payment #17
Auto Tag on Version Change / check-version (push) Failing after 14m51s
2026-06-04 01:38:08 +05:00
shihaam b08d983077 fix weird back button navigation when going to finances page from dashboard
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-06-04 01:19:15 +05:00
shihaam c7c89184c0 fix An error occurred" Instead "No available balance" #34
Auto Tag on Version Change / check-version (push) Failing after 13m30s
2026-06-03 23:19:30 +05:00
shihaam 0e5435f0fe add support to View details of blocked balance #33
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-06-03 23:12:02 +05:00
shihaam 3bb44f1c32 wheel optimizations
Auto Tag on Version Change / check-version (push) Failing after 13m59s
2026-06-03 21:09:02 +05:00
shihaam 5dc1a5dbc9 allow wheel and user proper build logo during onboarding
Auto Tag on Version Change / check-version (push) Failing after 12m47s
2026-06-03 20:20:12 +05:00
shihaam 982596f2a8 fix bug that allowed user to bypass warning slide during onboarding
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-06-03 20:01:55 +05:00
shihaam 140b0069bd fix islamic visa card image mapping
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-06-03 17:00:03 +05:00
shihaam 74ec9c383c fix Pin undo/delete button glyph scaling issue #9
Auto Tag on Version Change / check-version (push) Failing after 11m42s
2026-06-03 15:31:19 +05:00
shihaam b4f66342af curved text on wheel
Auto Tag on Version Change / check-version (push) Failing after 14m44s
2026-06-03 14:13:17 +05:00
shihaam f575941141 theme related bug fixes #8
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-06-03 14:00:52 +05:00
shihaam ceaad0e313 fix #20 by removing the scan button
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-06-03 13:20:02 +05:00
shihaam 528663a330 fix recipt buttons not showing on some phones/DPI #22
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-06-03 13:07:48 +05:00
shihaam a1abbc9843 fix non edging 2026-06-03 12:07:32 +05:00
shihaam ffee918258 releave version 1.0.15
Build and Release APK / build (push) Successful in 2m28s
Auto Tag on Version Change / check-version (push) Failing after 13m46s
2026-06-03 04:14:13 +05:00
shihaam fc7fa420b2 auto rotate wheel when tapping icon 2026-06-03 04:12:58 +05:00
shihaam 5f6ec236bf redsign wheel page (reorgnatize wheel)
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-06-03 04:10:05 +05:00
shihaam 890cf15fd0 redsign wheel page (action bar and logo
Auto Tag on Version Change / check-version (push) Failing after 10m26s
2026-06-03 03:57:35 +05:00
shihaam 98a003727b customize circular nav
Auto Tag on Version Change / check-version (push) Failing after 11m21s
2026-06-03 02:21:39 +05:00
shihaam 9ca13d3518 New UI nav mode: Circular
Auto Tag on Version Change / check-version (push) Failing after 14m55s
2026-06-03 01:48:04 +05:00
shihaam 395e2308a0 keep transfer form in cache (no reset on page change
Auto Tag on Version Change / check-version (push) Failing after 12m9s
Build and Release APK / build (push) Failing after 16m4s
2026-05-31 01:25:50 +05:00
shihaam ad7c5a4e5b release version 1.0.14
Auto Tag on Version Change / check-version (push) Has been cancelled
Build and Release APK / build (push) Has been cancelled
2026-05-31 01:14:00 +05:00
shihaam 0ba2396c2c auto pick default account when selecting contact from contact picker or trsnafering from contacts
Auto Tag on Version Change / check-version (push) Has been cancelled
2026-05-31 01:13:36 +05:00
shihaam 173c02ab8f release version 1.0.13
Build and Release APK / build (push) Successful in 3m12s
Auto Tag on Version Change / check-version (push) Failing after 14m33s
2026-05-31 00:28:28 +05:00
shihaam b37b12996f Contacts after selecting account back button Behaviour Enchantment #30
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-31 00:26:10 +05:00
shihaam 21203b39e7 rearrange buttons in contact page #28
Auto Tag on Version Change / check-version (push) Failing after 15m6s
2026-05-31 00:22:55 +05:00
shihaam 0be492ca18 remove custom logic for scan button in PayMV QR generate and just handoff to transfer page to handle auto select account
Auto Tag on Version Change / check-version (push) Failing after 15m1s
2026-05-31 00:18:01 +05:00
shihaam 973576cf6a save static BML QR scans to recents #31
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-31 00:06:37 +05:00
shihaam 4523aed69e remove *** on customer amount set PayMV QRs, #29
Auto Tag on Version Change / check-version (push) Successful in 7s
2026-05-30 23:26:55 +05:00
shihaam f90d83b59e improve share to scan to pay flow: fetching merchant details
Auto Tag on Version Change / check-version (push) Failing after 10m51s
2026-05-30 23:22:10 +05:00
shihaam a03b1b1682 improve QR scan flow part:1 unified
Auto Tag on Version Change / check-version (push) Failing after 14m4s
2026-05-30 22:53:56 +05:00
shihaam bc958e2df6 fallback to use transfer if user scanned a paymv qr after selecting a card 2026-05-30 22:29:28 +05:00
shihaam ae8ad24d13 add support for default trsnager accounts
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-30 22:24:37 +05:00
shihaam a20f2a9ce7 made transfer flow more friendly and vivid UI
Auto Tag on Version Change / check-version (push) Failing after 13m27s
2026-05-30 21:24:35 +05:00
shihaam 0795df35a1 add share to scan to pay
Auto Tag on Version Change / check-version (push) Failing after 11m26s
2026-05-30 20:11:35 +05:00
shihaam 86e1e66a20 update docs
Auto Tag on Version Change / check-version (push) Failing after 14m45s
2026-05-30 19:33:15 +05:00
shihaam a5124096d7 update docs
Auto Tag on Version Change / check-version (push) Failing after 12m5s
2026-05-30 19:00:57 +05:00
shihaam 1d2cd40b3c fix nfc related crash bug
Auto Tag on Version Change / check-version (push) Failing after 11m8s
2026-05-30 18:50:15 +05:00
shihaam abc1a43ad6 address issue #12: add app icon and title
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-30 01:12:44 +05:00
shihaam c7718f94b3 add api call indicator to nfc
Auto Tag on Version Change / check-version (push) Failing after 14m27s
2026-05-30 00:21:54 +05:00
shihaam 57bc488b98 new NFC tap to pay animation
Auto Tag on Version Change / check-version (push) Failing after 15m0s
2026-05-29 23:56:23 +05:00
shihaam 7f87c9e13f update docs
Auto Tag on Version Change / check-version (push) Failing after 12m39s
2026-05-29 18:58:43 +05:00
shihaam cc15ab1c6c potential nfc bug fix 2026-05-29 18:58:35 +05:00
shihaam 0efe833e40 release version 1.0.12
Auto Tag on Version Change / check-version (push) Successful in 6s
Build and Release APK / build (push) Failing after 14m56s
2026-05-29 17:35:27 +05:00
shihaam f5f52829c7 bug fix that took user to default card from dashboard instead of the card user selected
Auto Tag on Version Change / check-version (push) Has been cancelled
2026-05-29 16:45:19 +05:00
shihaam 3db077cf9a rename shorcut to scan to pay
Auto Tag on Version Change / check-version (push) Failing after 10m47s
2026-05-29 16:42:04 +05:00
shihaam ee5ecdaa18 new nfc icon, hide cards, removed offline nfc payments
Auto Tag on Version Change / check-version (push) Failing after 12m50s
2026-05-29 16:39:58 +05:00
shihaam 2df162c09e tap-to-pay part 3: default wallet and shortcut
Auto Tag on Version Change / check-version (push) Failing after 11m53s
2026-05-29 15:58:05 +05:00
shihaam 0f77216d2d tap-to-pay part 1
Auto Tag on Version Change / check-version (push) Failing after 14m38s
2026-05-29 15:43:13 +05:00
shihaam 71e893faf8 update download links: preview tg channel
Auto Tag on Version Change / check-version (push) Failing after 11m6s
2026-05-29 11:51:40 +05:00
shihaam 1cd254c134 update download links: preview tg channel
Auto Tag on Version Change / check-version (push) Failing after 12m3s
2026-05-29 11:50:47 +05:00
shihaam 87536a339b update download links
Auto Tag on Version Change / check-version (push) Failing after 14m42s
2026-05-29 11:48:10 +05:00
shihaam 32d23a43b3 lmao 2026-05-29 11:47:14 +05:00
shihaam 846ce22245 more astudio bs 2026-05-29 11:40:09 +05:00
shihaam ed5b456e3b Release version 1.0.11
Build and Release APK / build (push) Failing after 15m35s
Auto Tag on Version Change / check-version (push) Successful in 28s
2026-05-28 23:40:19 +05:00
shihaam 9b284cc8d4 BML change payment gateway to use PayMV QR, so added support for it
Auto Tag on Version Change / check-version (push) Successful in 15s
2026-05-28 23:39:39 +05:00
152 changed files with 9726 additions and 614 deletions
+2
View File
@@ -17,6 +17,8 @@ jobs:
run: | run: |
echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n' | base64 -d > app/key.jks echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n' | base64 -d > app/key.jks
echo "${{ secrets.DOTENV_BASE64 }}" | tr -d '\n' | base64 -d > .build/release/.env echo "${{ secrets.DOTENV_BASE64 }}" | tr -d '\n' | base64 -d > .build/release/.env
echo "ACCOUNT_MVR=${{ vars.ACCOUNT_MVR }}" >> .build/release/.env
echo "ACCOUNT_USD=${{ vars.ACCOUNT_USD }}" >> .build/release/.env
- name: Build APK - name: Build APK
working-directory: .build/release working-directory: .build/release
+1
View File
@@ -18,3 +18,4 @@ docs/bmlapi/tmp
docs/fahipayapi/tmp docs/fahipayapi/tmp
tmp tmp
app/key.jks app/key.jks
.kotlin/*
+2 -2
View File
@@ -4,10 +4,10 @@
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-22T00:11:32.873305232Z"> <DropdownSelection timestamp="2026-06-13T17:53:06.478193524Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=683a9830" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>
+11 -3
View File
@@ -14,17 +14,25 @@ A native Android client for Maldivian banking services. It is a pure client: req
- Existing accounts with MIB, BML, or Fahipay - Existing accounts with MIB, BML, or Fahipay
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank - Your TOTP seed (base32 secret from your authenticator app setup) for each bank
## Download ## Download APK
[Download latest APK](https://git.shihaam.dev/shihaam/ISODroid/releases/latest) [Gitea Releases](https://git.shihaam.dev/shihaam/thijooree/releases)
[Telegram Channel](https://t.me/s/thijooreeapks)
## Privacy ## Privacy
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. 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).
## Disclaimer ## Disclaimer
This is an unofficial third-party app. It is not affiliated with, endorsed by, or supported by MIB, BML, or Fahipay. Use at your own risk. Review the source code before entering your banking credentials. This is an unofficial third-party app. It is not affiliated with, endorsed by, or supported by MIB, BML, or Fahipay. Use at your own risk. Review the source code before entering your banking credentials.
## Contributing
<blockquote class="twitter-tweet"><p lang="en" dir="ltr">Talk is cheap, send patches.</p>&mdash; FFmpeg (@FFmpeg) <a href="https://x.com/FFmpeg/status/1762805900035686805?ref_src=twsrc%5Etfw">February 28, 2024</a></blockquote>
## License ## License
+19 -2
View File
@@ -1,8 +1,18 @@
import java.util.Properties
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
} }
val localProps = Properties().also { props ->
val f = rootProject.file("local.properties")
if (f.exists()) props.load(f.inputStream())
}
fun localOrEnv(key: String, envKey: String) =
localProps.getProperty(key) ?: System.getenv(envKey) ?: ""
android { android {
namespace = "sh.sar.basedbank" namespace = "sh.sar.basedbank"
compileSdk = 36 compileSdk = 36
@@ -11,10 +21,13 @@ android {
applicationId = "sh.sar.basedbank" applicationId = "sh.sar.basedbank"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 9 versionCode = 20
versionName = "1.0.10" versionName = "1.0.20"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
buildConfigField("String", "ACCOUNT_MVR", "\"${localOrEnv("account.mvr", "ACCOUNT_MVR")}\"")
buildConfigField("String", "ACCOUNT_USD", "\"${localOrEnv("account.usd", "ACCOUNT_USD")}\"")
} }
signingConfigs { signingConfigs {
@@ -49,6 +62,7 @@ android {
} }
buildFeatures { buildFeatures {
viewBinding = true viewBinding = true
buildConfig = true
} }
} }
@@ -91,6 +105,9 @@ dependencies {
// Biometric authentication // Biometric authentication
implementation("androidx.biometric:biometric:1.1.0") implementation("androidx.biometric:biometric:1.1.0")
// Encrypted SharedPreferences (HCE token store)
implementation("androidx.security:security-crypto:1.1.0-alpha06")
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_logo_background">#CC0000</color>
</resources>
+43
View File
@@ -0,0 +1,43 @@
<?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>
+46
View File
@@ -7,6 +7,14 @@
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" /> <uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
<application <application
android:name=".BasedBankApp" android:name=".BasedBankApp"
@@ -59,6 +67,44 @@
android:exported="false" android:exported="false"
android:screenOrientation="portrait" /> android:screenOrientation="portrait" />
<activity
android:name=".nfc.BmlTapToPayActivity"
android:exported="false"
android:launchMode="singleTop"
android:theme="@style/Theme.BasedBank" />
<service
android:name=".service.NotificationPollingService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<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 <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"
@@ -124,8 +124,17 @@ class LockActivity : AppCompatActivity() {
else else
com.google.android.material.R.attr.materialButtonOutlinedStyle com.google.android.material.R.attr.materialButtonOutlinedStyle
val btn = MaterialButton(this, null, style).apply { val btn = MaterialButton(this, null, style).apply {
text = key if (key == "" || key == "") {
textSize = 24f text = ""
icon = ContextCompat.getDrawable(this@LockActivity,
if (key == "") R.drawable.ic_backspace else R.drawable.ic_check)
iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START
iconPadding = 0
iconSize = (28 * dp).toInt()
} else {
text = key
textSize = 24f
}
insetTop = 0; insetBottom = 0 insetTop = 0; insetBottom = 0
minimumWidth = 0; minimumHeight = 0 minimumWidth = 0; minimumHeight = 0
cornerRadius = btnSize / 2 cornerRadius = btnSize / 2
@@ -278,9 +287,13 @@ class LockActivity : AppCompatActivity() {
} }
val navDest = intent.getIntExtra("nav_destination", -1) val navDest = intent.getIntExtra("nav_destination", -1)
val autoScan = intent.getBooleanExtra("auto_scan", false) 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 { startActivity(Intent(this, HomeActivity::class.java).apply {
if (navDest != -1) putExtra("nav_destination", navDest) if (navDest != -1) putExtra("nav_destination", navDest)
if (autoScan) putExtra("auto_scan", true) if (autoScan) putExtra("auto_scan", true)
if (autoTapMode) putExtra("auto_tap_mode", true)
if (shareQrText != null) putExtra("share_qr_text", shareQrText)
}) })
finish() finish()
} }
@@ -12,6 +12,28 @@ import sh.sar.basedbank.R
class MainActivity : AppCompatActivity() { 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -21,13 +43,26 @@ class MainActivity : AppCompatActivity() {
val store = CredentialStore(this) val store = CredentialStore(this)
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials() 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) { val navDestination = when (intent?.action) {
"sh.sar.basedbank.OPEN_TRANSFER" -> R.id.nav_transfer "sh.sar.basedbank.OPEN_TRANSFER" -> R.id.nav_transfer
"sh.sar.basedbank.OPEN_SCAN_QR" -> 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.OPEN_PAY_WITH_CARD" -> R.id.nav_pay_with_card
"sh.sar.basedbank.TAP_TO_PAY" -> R.id.nav_pay_with_card
else -> -1 else -> -1
} }
val autoScan = intent?.action == "sh.sar.basedbank.OPEN_SCAN_QR" val autoScan = intent?.action == "sh.sar.basedbank.OPEN_SCAN_QR"
val autoTapMode = intent?.action == "sh.sar.basedbank.TAP_TO_PAY"
val target = when { val target = when {
!onboardingDone -> OnboardingActivity::class.java !onboardingDone -> OnboardingActivity::class.java
@@ -43,6 +78,8 @@ class MainActivity : AppCompatActivity() {
startActivity(Intent(this, target).apply { startActivity(Intent(this, target).apply {
if (navDestination != -1) putExtra("nav_destination", navDestination) if (navDestination != -1) putExtra("nav_destination", navDestination)
if (autoScan) putExtra("auto_scan", true) if (autoScan) putExtra("auto_scan", true)
if (autoTapMode) putExtra("auto_tap_mode", true)
if (shareQrText != null) putExtra("share_qr_text", shareQrText)
}) })
finish() finish()
} }
@@ -0,0 +1,55 @@
package sh.sar.basedbank.api.bml
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
data class BmlCardActionResult(
val success: Boolean,
val message: String
)
class BmlCardClient {
private val client = newBmlApiClient()
/**
* Freezes or unfreezes a BML card.
* @param cardId BML card UUID (BankAccount.internalId)
* @param action "freeze" or "unfreeze"
*/
fun setCardFreezeState(session: BmlSession, cardId: String, action: String): BmlCardActionResult {
val body = JSONObject().apply {
put("card", cardId)
put("action", action)
}.toString().toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$BML_BASE_URL/api/mobile/services/card/freeze")
.post(body)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("accept", "application/json")
.build()
val resp = client.newCall(request).execute()
val code = resp.code
val responseBody = resp.body?.string()
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
if (code in 500..599) throw BankServerException("BML")
return try {
val json = JSONObject(responseBody ?: "")
val ok = json.optBoolean("success") && json.optInt("code") == 0
BmlCardActionResult(
success = ok,
message = json.optString("payload").ifBlank { json.optString("message") }
)
} catch (_: Exception) {
BmlCardActionResult(success = false, message = "")
}
}
}
@@ -9,6 +9,12 @@ import sh.sar.basedbank.api.models.BankTransaction
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
data class BmlCardHistoryResult(
val statement: List<BankTransaction>,
val outstanding: List<BankTransaction>,
val unbilled: List<BankTransaction>
)
class BmlHistoryClient { class BmlHistoryClient {
private val client = newBmlApiClient() private val client = newBmlApiClient()
@@ -70,7 +76,7 @@ class BmlHistoryClient {
accountDisplayName: String, accountDisplayName: String,
accountNumber: String, accountNumber: String,
month: String month: String
): List<BankTransaction> { ): BmlCardHistoryResult {
val body = """{"card":"$cardId","month":"$month"}""" val body = """{"card":"$cardId","month":"$month"}"""
.toRequestBody("application/json".toMediaType()) .toRequestBody("application/json".toMediaType())
val resp = client.newCall( val resp = client.newCall(
@@ -81,6 +87,72 @@ class BmlHistoryClient {
.build() .build()
).execute() ).execute()
val code = resp.code val code = resp.code
val json = resp.body?.string() ?: return BmlCardHistoryResult(emptyList(), emptyList(), emptyList())
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
if (code in 500..599) throw BankServerException("BML")
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return BmlCardHistoryResult(emptyList(), emptyList(), emptyList())
val payload = root.optJSONObject("payload")
?: return BmlCardHistoryResult(emptyList(), emptyList(), emptyList())
val outstanding = parseCardArray(
payload.optJSONObject("outstanding")?.optJSONArray("CardOutStdAuthDetails"),
idPrefix = "auth", accountNumber, accountDisplayName
)
val unbilled = parseCardArray(
payload.optJSONObject("unbilled")?.optJSONArray("CardUnbillTxnDetails"),
idPrefix = "unbilled", accountNumber, accountDisplayName
)
val statement = parseCardArray(
payload.optJSONArray("cardstatement"),
idPrefix = "stmt", accountNumber, accountDisplayName
)
BmlCardHistoryResult(statement, outstanding, unbilled)
} catch (_: Exception) { BmlCardHistoryResult(emptyList(), emptyList(), emptyList()) }
}
private fun parseCardArray(
arr: org.json.JSONArray?,
idPrefix: String,
accountNumber: String,
accountDisplayName: String
): List<BankTransaction> {
if (arr == null) return emptyList()
return (0 until arr.length()).map { i ->
val item = arr.getJSONObject(i)
val ref = item.optString("TranApprCode")
BankTransaction(
id = "${idPrefix}_${ref.ifBlank { i.toString() }}",
date = item.optString("DateTime"),
description = item.optString("TranDesc").trim(),
amount = item.optDouble("BillingAmount", 0.0),
currency = item.optString("BillingCcy", "MVR"),
counterpartyName = null,
reference = ref.takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML_CARD"
)
}
}
fun fetchPendingHistory(
session: BmlSession,
accountId: String,
accountDisplayName: String,
accountNumber: String
): List<BankTransaction> {
val resp = client.newCall(
Request.Builder().url("$BML_BASE_URL/api/mobile/history/pending/$accountId")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.build()
).execute()
val code = resp.code
val json = resp.body?.string() ?: return emptyList() val json = resp.body?.string() ?: return emptyList()
resp.close() resp.close()
if (code == 401 || code == 419) throw AuthExpiredException() if (code == 401 || code == 419) throw AuthExpiredException()
@@ -88,68 +160,22 @@ class BmlHistoryClient {
return try { return try {
val root = JSONObject(json) val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList() if (!root.optBoolean("success")) return emptyList()
val payload = root.optJSONObject("payload") ?: return emptyList() val payload = root.optJSONArray("payload") ?: return emptyList()
val result = mutableListOf<BankTransaction>() (0 until payload.length()).map { i ->
val item = payload.getJSONObject(i)
val authDetails = payload.optJSONObject("outstanding") BankTransaction(
?.optJSONArray("CardOutStdAuthDetails") id = item.optString("LockedID"),
if (authDetails != null) { date = item.optString("FromDate"),
for (i in 0 until authDetails.length()) { description = "Pending",
val item = authDetails.getJSONObject(i) amount = -item.optDouble("LockedAmount", 0.0),
result.add(BankTransaction( currency = "MVR",
id = "auth_${item.optString("TranApprCode")}_$i", counterpartyName = item.optString("Description").trim().takeIf { it.isNotBlank() },
date = item.optString("DateTime"), reference = null,
description = item.optString("TranDesc").trim(), accountNumber = accountNumber,
amount = item.optDouble("BillingAmount", 0.0), accountDisplayName = accountDisplayName,
currency = item.optString("BillingCcy", "MVR"), source = "BML"
counterpartyName = null, )
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML_CARD"
))
}
} }
val unbilled = payload.optJSONObject("unbilled")
?.optJSONArray("CardUnbillTxnDetails")
if (unbilled != null) {
for (i in 0 until unbilled.length()) {
val item = unbilled.getJSONObject(i)
result.add(BankTransaction(
id = "unbilled_${item.optString("TranApprCode")}_$i",
date = item.optString("DateTime"),
description = item.optString("TranDesc").trim(),
amount = item.optDouble("BillingAmount", 0.0),
currency = item.optString("BillingCcy", "MVR"),
counterpartyName = null,
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML_CARD"
))
}
}
val statement = payload.optJSONArray("cardstatement")
if (statement != null) {
for (i in 0 until statement.length()) {
val item = statement.getJSONObject(i)
result.add(BankTransaction(
id = "stmt_${item.optString("TranRef", i.toString())}",
date = item.optString("TransDate", item.optString("TranDate", "")),
description = item.optString("TranDesc", item.optString("Description", "")).trim(),
amount = -item.optDouble("TranAmount", 0.0),
currency = item.optString("TranCcy", "MVR"),
counterpartyName = null,
reference = item.optString("TranRef").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML_CARD"
))
}
}
result
} catch (_: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
} }
@@ -82,6 +82,15 @@ data class BmlQrPayResult(
val errorMessage: String = "" 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( data class BmlForeignLimit(
val type: String, val type: String,
val used: Double, val used: Double,
@@ -0,0 +1,95 @@
package sh.sar.basedbank.api.bml
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import sh.sar.basedbank.ui.home.AppNotification
import java.text.SimpleDateFormat
import java.util.Locale
private const val BML_NOTIF_BASE = "https://app.bankofmaldives.com.mv"
class BmlNotificationsClient {
private val client = newBmlApiClient()
private val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
data class FetchResult(
val items: List<AppNotification>,
val total: Int
)
fun fetchNotifications(
session: BmlSession,
loginId: String,
group: String = "ALL",
page: Int = 1
): FetchResult {
val url = "$BML_NOTIF_BASE/api/v2/notifications?group=$group&page=$page"
return try {
val resp = client.newCall(bmlApiRequest(session, url)).execute()
if (!resp.isSuccessful) { resp.close(); return FetchResult(emptyList(), 0) }
val body = resp.body?.string().also { resp.close() } ?: return FetchResult(emptyList(), 0)
parseResponse(body, loginId)
} catch (_: Exception) { FetchResult(emptyList(), 0) }
}
fun markAllRead(session: BmlSession): Boolean {
val url = "$BML_NOTIF_BASE/api/v2/notifications/read"
val reqBody = """{"all":true}""".toRequestBody("application/json".toMediaType())
val req = Request.Builder().url(url)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("accept", "application/json")
.put(reqBody)
.build()
return try {
val resp = client.newCall(req).execute()
val ok = resp.isSuccessful
resp.close()
ok
} catch (_: Exception) { false }
}
private fun parseResponse(body: String, loginId: String): FetchResult {
val json = JSONObject(body)
if (!json.optBoolean("success")) return FetchResult(emptyList(), 0)
val total = json.optInt("total", 0)
val payload = json.optJSONArray("payload") ?: return FetchResult(emptyList(), total)
val items = (0 until payload.length()).map { i ->
val obj = payload.getJSONObject(i)
val dataObj = obj.optJSONObject("data")
val detailFields = mutableListOf<Pair<String, String>>()
detailFields.add("Bank" to "BML")
detailFields.add("Group" to obj.optString("group"))
detailFields.add("Type" to obj.optString("type"))
if (dataObj != null) {
dataObj.keys().forEach { key ->
val v = dataObj.opt(key)?.toString()?.takeIf { it.isNotBlank() } ?: return@forEach
detailFields.add(formatKey(key) to v)
}
}
val createdAt = obj.optString("created_at")
val tsMs = try { sdf.parse(createdAt)?.time ?: System.currentTimeMillis() }
catch (_: Exception) { System.currentTimeMillis() }
AppNotification(
id = obj.optString("id"),
bank = "BML",
loginId = loginId,
group = obj.optString("group", "ALERTS"),
title = obj.optString("title"),
message = obj.optString("message"),
timestampMs = tsMs,
isRead = obj.optBoolean("is_read", true),
detailFields = detailFields
)
}
return FetchResult(items, total)
}
private fun formatKey(key: String): String =
key.replace('_', ' ').split(' ').joinToString(" ") { it.replaceFirstChar(Char::uppercase) }
}
@@ -0,0 +1,79 @@
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", "")
)
}
}
}
@@ -84,7 +84,8 @@ class BmlTransferClient {
try { try {
val json = JSONObject(bodyStr) val json = JSONObject(bodyStr)
if (!json.optBoolean("success")) { if (!json.optBoolean("success")) {
BmlTransferResult(false, errorMessage = json.optString("message").ifBlank { "Transfer failed" }) val payloadStr = json.optString("payload").takeIf { it.isNotBlank() && it != "null" }
BmlTransferResult(false, errorMessage = payloadStr ?: json.optString("message").ifBlank { "Transfer failed" })
} else { } else {
val payload = json.optJSONObject("payload") val payload = json.optJSONObject("payload")
BmlTransferResult( BmlTransferResult(
@@ -71,7 +71,8 @@ class BmlValidateClient {
originalInput = account, originalInput = account,
name = root.optString("name"), name = root.optString("name"),
alias = null, alias = null,
currency = "MVR", // BML's MIB verify endpoint doesn't return the MIB account's currency.
currency = "",
agnt = root.optString("agnt").takeIf { it.isNotBlank() } agnt = root.optString("agnt").takeIf { it.isNotBlank() }
) )
} catch (_: Exception) { null } } catch (_: Exception) { null }
@@ -0,0 +1,135 @@
package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import sh.sar.basedbank.ui.home.AppNotification
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.concurrent.TimeUnit
private val SKIP_TYPES = setOf("Switch Profile", "Log in")
private const val MIB_WV_URL = "https://faisamobilex-wv.mib.com.mv"
class MibActivityHistoryClient {
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private val sdf = SimpleDateFormat("dd MMM yyyy HH:mm", Locale.US)
data class FetchResult(
val items: List<AppNotification>, // already filtered (no Switch Profile)
val rawCount: Int, // raw items returned by API before filtering
val totalCount: Int,
val nextStart: Int
)
fun fetchActivity(
session: MibSession,
loginId: String,
start: Int,
end: Int
): FetchResult {
val cookieHeader = "mbmodel=IOS-1.0; " +
"xxid=${session.xxid}; " +
"IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; " +
"time-tracker=597"
val formBody = FormBody.Builder()
.add("start", start.toString())
.add("end", end.toString())
.add("includeCount", "1")
.build()
val req = Request.Builder()
.url("$MIB_WV_URL/aProfile/getPagedActivityHistory")
.header("Cookie", cookieHeader)
.header("User-Agent", "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36")
.header("X-Requested-With", "XMLHttpRequest")
.post(formBody)
.build()
val body = try {
val resp = client.newCall(req).execute()
if (!resp.isSuccessful) { resp.close(); return FetchResult(emptyList(), 0, 0, end + 1) }
resp.body?.string().also { resp.close() } ?: return FetchResult(emptyList(), 0, 0, end + 1)
} catch (_: Exception) { return FetchResult(emptyList(), 0, 0, end + 1) }
return try {
val json = JSONObject(body)
if (!json.optBoolean("success")) return FetchResult(emptyList(), 0, 0, end + 1)
val totalCount = json.optString("total_count", "0").toIntOrNull() ?: 0
val dataArr = json.optJSONArray("data") ?: return FetchResult(emptyList(), 0, totalCount, end + 1)
val items = mutableListOf<AppNotification>()
val rawCount = dataArr.length()
for (i in 0 until rawCount) {
val obj = dataArr.getJSONObject(i)
val activityType = obj.optString("activityType")
if (activityType in SKIP_TYPES) continue
val pa = obj.optString("pa")
val activity = obj.optString("activity")
val pb = obj.optString("pb")
val dateStr = obj.optString("date")
val message = buildString {
append(pa)
if (activity.isNotBlank()) { append(" "); append(activity) }
if (pb.isNotBlank()) { append(" "); append(pb) }
}
val tsMs = try { sdf.parse(dateStr)?.time ?: System.currentTimeMillis() }
catch (_: Exception) { System.currentTimeMillis() }
val detailFields = mutableListOf<Pair<String, String>>().apply {
add("Bank" to "MIB")
add("Type" to activityType)
if (pa.isNotBlank()) add("By" to pa)
if (activity.isNotBlank() && pb.isNotBlank()) add("Action" to "$activity $pb")
if (dateStr.isNotBlank()) add("Date" to dateStr)
}
items.add(AppNotification(
id = obj.optString("aid"),
bank = "MIB",
loginId = loginId,
group = "ALERTS",
title = activityType,
message = message,
timestampMs = tsMs,
isRead = false, // resolved from cache in the sheet
detailFields = detailFields
))
}
FetchResult(items, rawCount, totalCount, end + 1)
} catch (_: Exception) { FetchResult(emptyList(), 0, 0, end + 1) }
}
// Keeps fetching pages until at least `minCount` non-Switch-Profile items found or all pages exhausted.
fun fetchUntilEnough(
session: MibSession,
loginId: String,
minCount: Int = 5,
pageSize: Int = 100
): FetchResult {
val accumulated = mutableListOf<AppNotification>()
var start = 1
var totalCount = 0
while (accumulated.size < minCount) {
val result = fetchActivity(session, loginId, start, start + pageSize - 1)
totalCount = result.totalCount
accumulated.addAll(result.items)
if (result.rawCount == 0 || start + pageSize - 1 >= totalCount) break
start = result.nextStart
}
return FetchResult(accumulated, accumulated.size, totalCount, start)
}
}
@@ -7,10 +7,18 @@ import okhttp3.Request
import org.json.JSONObject import org.json.JSONObject
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
data class MibCardActionResult(
val success: Boolean,
val message: String,
val currentStatusCode: String
)
class MibCardsClient { class MibCardsClient {
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv" private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val USER_AGENT = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
private val client = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS) .connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS) .readTimeout(20, TimeUnit.SECONDS)
@@ -20,7 +28,7 @@ class MibCardsClient {
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " + "mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597" "mbnonce=${session.nonceGenerator}; time-tracker=597"
fun fetchCards(session: MibSession, loginTag: String): List<MibCard> { fun fetchCards(session: MibSession, loginTag: String, profileId: String = ""): List<MibCard> {
val body = FormBody.Builder() val body = FormBody.Builder()
.add("name", "") .add("name", "")
.add("start", "1") .add("start", "1")
@@ -32,7 +40,7 @@ class MibCardsClient {
.url("$BASE_WV_URL/ajaxDebitCard/fetchCardInfos") .url("$BASE_WV_URL/ajaxDebitCard/fetchCardInfos")
.post(body) .post(body)
.header("Cookie", cookieHeader(session)) .header("Cookie", cookieHeader(session))
.header("User-Agent", "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36") .header("User-Agent", USER_AGENT)
.header("X-Requested-With", "XMLHttpRequest") .header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*") .header("Accept", "*/*")
.header("Origin", BASE_WV_URL) .header("Origin", BASE_WV_URL)
@@ -55,9 +63,42 @@ class MibCardsClient {
customerId = item.optString("customerId"), customerId = item.optString("customerId"),
phoneNumber = item.optString("phoneNumber"), phoneNumber = item.optString("phoneNumber"),
cardHolderName = item.optString("cardHolderName"), cardHolderName = item.optString("cardHolderName"),
loginTag = loginTag loginTag = loginTag,
profileId = profileId
) )
} }
} }
} }
/** Freezes a MIB card. action = "freeze" or "unfreeze". */
fun setCardFreezeState(session: MibSession, cardId: String, action: String, comments: String): MibCardActionResult {
val body = FormBody.Builder()
.add("cardId", cardId)
.add("comments", comments)
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxDebitCard/$action")
.post(body)
.header("Cookie", cookieHeader(session))
.header("User-Agent", USER_AGENT)
.header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*")
.header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL//debitCards/manage?cardId=$cardId&dashurl=1")
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string()
?: return MibCardActionResult(false, "", "")
val json = try { JSONObject(bodyStr) } catch (_: Exception) {
return MibCardActionResult(false, "", "")
}
MibCardActionResult(
success = json.optBoolean("success"),
message = json.optString("reasonText"),
currentStatusCode = json.optString("currentStatusCode")
)
}
}
} }
@@ -73,7 +73,7 @@ class MibHistoryClient {
id = item.optString("trxNumber"), id = item.optString("trxNumber"),
date = item.optString("trxDate"), date = item.optString("trxDate"),
description = item.optString("descr1").trim(), description = item.optString("descr1").trim(),
amount = item.optString("baseAmount", "0").toDoubleOrNull() ?: 0.0, amount = item.optString("foreignAmount", "0").toDoubleOrNull() ?: 0.0,
currency = item.optString("curCodeDesc"), currency = item.optString("curCodeDesc"),
counterpartyName = item.optString("benefName").takeIf { counterpartyName = item.optString("benefName").takeIf {
it.isNotBlank() && it != "null" it.isNotBlank() && it != "null"
@@ -42,7 +42,8 @@ data class MibTransferResult(
data class MibIpsAccountInfo( data class MibIpsAccountInfo(
val accountName: String, val accountName: String,
val accountNumber: String, val accountNumber: String,
val bankId: String val bankId: String,
val currency: String = "" // "MVR", "USD", or "" if unknown
) )
@@ -55,7 +56,8 @@ data class MibCard(
val customerId: String, val customerId: String,
val phoneNumber: String, val phoneNumber: String,
val cardHolderName: String, val cardHolderName: String,
val loginTag: String val loginTag: String,
val profileId: String = ""
) )
data class MibFinanceDeal( data class MibFinanceDeal(
@@ -130,7 +130,10 @@ class MibTransferClient {
MibIpsAccountInfo( MibIpsAccountInfo(
accountName = json.optString("accountName").trim(), accountName = json.optString("accountName").trim(),
accountNumber = accountNumber, accountNumber = accountNumber,
bankId = json.optString("bankBic") bankId = json.optString("bankBic"),
// MIB IPS only returns success for MVR cross-bank accounts;
// USD cross-bank accounts fail this lookup entirely.
currency = "MVR"
) )
} }
} }
@@ -156,10 +159,18 @@ class MibTransferClient {
// accountName may be at root or inside a "data" object // accountName may be at root or inside a "data" object
val name = json.optString("accountName").takeIf { it.isNotBlank() } val name = json.optString("accountName").takeIf { it.isNotBlank() }
?: json.optJSONObject("data")?.optString("accountName") ?: "" ?: json.optJSONObject("data")?.optString("accountName") ?: ""
val currencyCode = json.optString("currencyCode").takeIf { it.isNotBlank() }
?: json.optJSONObject("data")?.optString("currencyCode") ?: ""
val currency = when (currencyCode) {
"840" -> "USD"
"462" -> "MVR"
else -> ""
}
MibIpsAccountInfo( MibIpsAccountInfo(
accountName = name.trim(), accountName = name.trim(),
accountNumber = accountNumber, accountNumber = accountNumber,
bankId = "MADVMVMV" // MIB bankId = "MADVMVMV", // MIB
currency = currency
) )
} }
} }
@@ -0,0 +1,185 @@
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"
}
}
}
@@ -0,0 +1,20 @@
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()
}
}
@@ -0,0 +1,174 @@
package sh.sar.basedbank.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.IBinder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlNotificationsClient
import sh.sar.basedbank.api.mib.MibActivityHistoryClient
import sh.sar.basedbank.ui.home.AppNotification
import sh.sar.basedbank.util.NotificationsCache
import java.util.concurrent.atomic.AtomicInteger
class NotificationPollingService : Service() {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val app get() = application as BasedBankApp
private val notifIdCounter = AtomicInteger(2000)
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createChannels()
startForeground(SERVICE_NOTIF_ID, buildServiceNotification())
startPolling()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
private fun startPolling() {
scope.launch {
while (isActive) {
runCatching { poll() }
delay(POLL_INTERVAL_MS)
}
}
}
private suspend fun poll() {
pollBml()
pollMib()
}
private suspend fun pollBml() {
val sessions = app.bmlSessions.toMap()
if (sessions.isEmpty()) return
val client = BmlNotificationsClient()
sessions.forEach { (loginId, session) ->
val result = try { client.fetchNotifications(session, loginId, page = 1) }
catch (_: Exception) { return@forEach }
if (result.items.isEmpty()) return@forEach
val cached = NotificationsCache.loadBml(this@NotificationPollingService, loginId)
if (cached.isEmpty()) {
NotificationsCache.saveBml(this@NotificationPollingService, loginId, result.items)
return@forEach
}
val cachedIds = cached.map { it.id }.toSet()
val newItems = result.items.filter { it.id !in cachedIds }
if (newItems.isNotEmpty()) {
NotificationsCache.saveBml(this@NotificationPollingService, loginId, result.items)
val channelId = ensureLoginChannel("BML", loginId)
newItems.forEach { postBankNotification(it, channelId) }
}
}
}
private suspend fun pollMib() {
val sessions = app.mibSessions.toMap()
if (sessions.isEmpty()) return
val client = MibActivityHistoryClient()
sessions.forEach { (loginId, session) ->
val result = try { client.fetchActivity(session, loginId, 1, 100) }
catch (_: Exception) { return@forEach }
val readIds = NotificationsCache.getMibReadIds(this@NotificationPollingService)
val cached = NotificationsCache.loadMib(this@NotificationPollingService, loginId, readIds)
if (cached.isEmpty()) {
NotificationsCache.saveMib(this@NotificationPollingService, loginId, result.items)
return@forEach
}
val cachedIds = cached.map { it.id }.toSet()
val newItems = result.items.filter { it.id !in cachedIds }
if (newItems.isNotEmpty()) {
val all = (cached + newItems).sortedByDescending { it.timestampMs }
NotificationsCache.saveMib(this@NotificationPollingService, loginId, all)
val channelId = ensureLoginChannel("MIB", loginId)
newItems.forEach { postBankNotification(it, channelId) }
}
}
}
private fun ensureLoginChannel(bank: String, loginId: String): String {
val channelId = "bank_${bank.lowercase()}_$loginId"
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
if (nm.getNotificationChannel(channelId) == null) {
val profileName = when (bank) {
"BML" -> app.bmlProfilesMap[loginId]?.firstOrNull()?.name
"MIB" -> app.mibProfilesMap[loginId]?.firstOrNull()?.name
else -> null
} ?: loginId
nm.createNotificationChannel(
NotificationChannel(channelId, "$bank · $profileName", NotificationManager.IMPORTANCE_DEFAULT)
)
}
return channelId
}
private fun postBankNotification(notif: AppNotification, channelId: String) {
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
val pi = PendingIntent.getActivity(
this, 0,
packageManager.getLaunchIntentForPackage(packageName),
PendingIntent.FLAG_IMMUTABLE
)
val n = Notification.Builder(this, channelId)
.setSmallIcon(R.drawable.ic_bell)
.setContentTitle(notif.title)
.setContentText(notif.message)
.setContentIntent(pi)
.setAutoCancel(true)
.build()
nm.notify(notifIdCounter.getAndIncrement(), n)
}
private fun buildServiceNotification(): Notification {
val pi = PendingIntent.getActivity(
this, 0,
packageManager.getLaunchIntentForPackage(packageName),
PendingIntent.FLAG_IMMUTABLE
)
return Notification.Builder(this, CHANNEL_SERVICE)
.setSmallIcon(R.drawable.ic_bell)
.setContentTitle(getString(R.string.notif_service_title))
.setContentText(getString(R.string.notif_service_desc))
.setContentIntent(pi)
.setOngoing(true)
.build()
}
private fun createChannels() {
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(
NotificationChannel(
CHANNEL_SERVICE,
getString(R.string.notif_channel_service),
NotificationManager.IMPORTANCE_MIN
).apply { setShowBadge(false) }
)
}
companion object {
private const val POLL_INTERVAL_MS = 30_000L
private const val SERVICE_NOTIF_ID = 1001
const val CHANNEL_SERVICE = "notif_polling_service"
}
}
@@ -27,9 +27,10 @@ class AccountHistoryAdapter(
private sealed class Item { private sealed class Item {
data class DateHeader(val label: String) : Item() data class DateHeader(val label: String) : Item()
data class Trx(val transaction: BankTransaction) : Item() data class Trx(val transaction: BankTransaction, val showDate: Boolean = false) : Item()
} }
private val pendingItems = mutableListOf<Item>()
private val displayItems = mutableListOf<Item>() private val displayItems = mutableListOf<Item>()
private var lastInsertedDateKey = "" private var lastInsertedDateKey = ""
private val imageCache = mutableMapOf<String, Bitmap>() private val imageCache = mutableMapOf<String, Bitmap>()
@@ -37,15 +38,22 @@ class AccountHistoryAdapter(
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
var onIconUrlNeeded: ((url: String) -> Unit)? = null var onIconUrlNeeded: ((url: String) -> Unit)? = null
var onTransferClick: ((BankAccount) -> Unit)? = null var onTransferClick: ((BankAccount) -> Unit)? = null
var onDefaultToggle: ((Boolean) -> Unit)? = null
private var hideAmounts: Boolean = false 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) { fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return if (hideAmounts == hide) return
hideAmounts = hide hideAmounts = hide
notifyItemChanged(0) // refresh header card notifyItemChanged(0) // refresh header card
// refresh all transaction rows for (i in pendingItems.indices) {
if (pendingItems[i] is Item.Trx) notifyItemChanged(i + 1)
}
for (i in displayItems.indices) { for (i in displayItems.indices) {
if (displayItems[i] is Item.Trx) notifyItemChanged(i + 1) if (displayItems[i] is Item.Trx) notifyItemChanged(1 + pendingItems.size + i)
} }
} }
@@ -53,7 +61,7 @@ class AccountHistoryAdapter(
imageCache[counterpartyName] = bitmap imageCache[counterpartyName] = bitmap
displayItems.forEachIndexed { i, item -> displayItems.forEachIndexed { i, item ->
if (item is Item.Trx && item.transaction.counterpartyName == counterpartyName) if (item is Item.Trx && item.transaction.counterpartyName == counterpartyName)
notifyItemChanged(i + 1) // +1 for account header at position 0 notifyItemChanged(1 + pendingItems.size + i)
} }
} }
@@ -61,10 +69,28 @@ class AccountHistoryAdapter(
iconUrlCache[url] = bitmap iconUrlCache[url] = bitmap
displayItems.forEachIndexed { i, item -> displayItems.forEachIndexed { i, item ->
if (item is Item.Trx && item.transaction.iconUrl == url) if (item is Item.Trx && item.transaction.iconUrl == url)
notifyItemChanged(i + 1) // +1 for account header at position 0 notifyItemChanged(1 + pendingItems.size + i)
} }
} }
fun setPendingTransactions(transactions: List<BankTransaction>) {
setLeadingSections(listOf("Pending" to transactions))
}
/**
* Sets one or more labeled sections that render above the main statement list
* (e.g. card "Outstanding" + "Unbilled"). Empty sections are skipped.
*/
fun setLeadingSections(sections: List<Pair<String, List<BankTransaction>>>) {
pendingItems.clear()
for ((label, transactions) in sections) {
if (transactions.isEmpty()) continue
pendingItems.add(Item.DateHeader(label))
for (trx in transactions) pendingItems.add(Item.Trx(trx, showDate = true))
}
notifyDataSetChanged()
}
private var _showLoadingFooter = false private var _showLoadingFooter = false
var showLoadingFooter: Boolean var showLoadingFooter: Boolean
get() = _showLoadingFooter get() = _showLoadingFooter
@@ -122,18 +148,24 @@ class AccountHistoryAdapter(
displayItems.add(Item.Trx(trx)) displayItems.add(Item.Trx(trx))
} }
val added = displayItems.size - oldCount val added = displayItems.size - oldCount
if (added > 0) notifyItemRangeInserted(1 + oldCount, added) // +1 for account header if (added > 0) notifyItemRangeInserted(1 + pendingItems.size + oldCount, added)
} }
// Position 0 = account header card // Position 0 = account header card
// Positions 1..displayItems.size = date headers + transactions // Positions 1..pendingItems.size = pending header + pending transactions
// Positions 1+pendingItems.size..1+pendingItems.size+displayItems.size = date headers + transactions
// Last position = loading footer when showLoadingFooter = true // Last position = loading footer when showLoadingFooter = true
override fun getItemCount() = 1 + displayItems.size + if (_showLoadingFooter) 1 else 0 override fun getItemCount() = 1 + pendingItems.size + displayItems.size + if (_showLoadingFooter) 1 else 0
private fun itemAt(position: Int): Item {
val idx = position - 1
return if (idx < pendingItems.size) pendingItems[idx] else displayItems[idx - pendingItems.size]
}
override fun getItemViewType(position: Int) = when { override fun getItemViewType(position: Int) = when {
position == 0 -> TYPE_HEADER position == 0 -> TYPE_HEADER
_showLoadingFooter && position == itemCount - 1 -> TYPE_LOADING _showLoadingFooter && position == itemCount - 1 -> TYPE_LOADING
else -> when (displayItems[position - 1]) { else -> when (itemAt(position)) {
is Item.DateHeader -> TYPE_DATE_HEADER is Item.DateHeader -> TYPE_DATE_HEADER
is Item.Trx -> TYPE_TRANSACTION is Item.Trx -> TYPE_TRANSACTION
} }
@@ -152,8 +184,11 @@ class AccountHistoryAdapter(
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) { when (holder) {
is HeaderVH -> holder.bind(display) is HeaderVH -> holder.bind(display)
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label) is DateHeaderVH -> holder.bind((itemAt(position) as Item.DateHeader).label)
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction) is TransactionVH -> {
val item = itemAt(position) as Item.Trx
holder.bind(item.transaction, item.showDate)
}
else -> Unit else -> Unit
} }
} }
@@ -174,6 +209,20 @@ class AccountHistoryAdapter(
b.llHeaderBlocked.visibility = View.GONE b.llHeaderBlocked.visibility = View.GONE
} }
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) } 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
}
} }
} }
@@ -184,7 +233,7 @@ class AccountHistoryAdapter(
inner class TransactionVH(private val b: ItemTransactionBinding) : inner class TransactionVH(private val b: ItemTransactionBinding) :
RecyclerView.ViewHolder(b.root) { RecyclerView.ViewHolder(b.root) {
fun bind(trx: BankTransaction) { fun bind(trx: BankTransaction, showDate: Boolean = false) {
val isCredit = trx.amount >= 0 val isCredit = trx.amount >= 0
val color = sourceColor(trx.source) val color = sourceColor(trx.source)
val name = trx.counterpartyName ?: trx.description val name = trx.counterpartyName ?: trx.description
@@ -220,7 +269,7 @@ class AccountHistoryAdapter(
b.tvCounterparty.visibility = View.GONE b.tvCounterparty.visibility = View.GONE
} }
b.tvDate.text = formatTime(trx.date) b.tvDate.text = if (showDate) formatDateOnly(trx.date) else formatTime(trx.date)
if (hideAmounts) { if (hideAmounts) {
b.tvAmount.text = "${trx.currency} ••••••" b.tvAmount.text = "${trx.currency} ••••••"
@@ -267,6 +316,7 @@ class AccountHistoryAdapter(
private val MIB_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) private val MIB_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
private val BML_FMT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US) private val BML_FMT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US)
private val DATE_HEADER_FMT = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault()) private val DATE_HEADER_FMT = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault())
private val DATE_ONLY_FMT = SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
private val TIME_FMT = SimpleDateFormat("h:mm a", Locale.getDefault()) private val TIME_FMT = SimpleDateFormat("h:mm a", Locale.getDefault())
private val FULL_DATE_FMT = SimpleDateFormat("MMM d, yyyy · h:mm a", Locale.getDefault()) private val FULL_DATE_FMT = SimpleDateFormat("MMM d, yyyy · h:mm a", Locale.getDefault())
@@ -288,6 +338,11 @@ class AccountHistoryAdapter(
return DATE_HEADER_FMT.format(date) return DATE_HEADER_FMT.format(date)
} }
fun formatDateOnly(raw: String): String {
val date = parseDate(raw) ?: return raw.take(10)
return DATE_ONLY_FMT.format(date)
}
fun formatTime(raw: String): String { fun formatTime(raw: String): String {
val date = parseDate(raw) ?: return "" val date = parseDate(raw) ?: return ""
return TIME_FMT.format(date) return TIME_FMT.format(date)
@@ -24,12 +24,15 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankServerException import sh.sar.basedbank.api.models.BankServerException
import sh.sar.basedbank.api.bml.BmlHistoryClient
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.models.BankTransaction import sh.sar.basedbank.api.models.BankTransaction
import sh.sar.basedbank.api.mib.TransactionCache import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
import sh.sar.basedbank.util.AccountHistoryParser import sh.sar.basedbank.util.AccountHistoryParser
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.ContactImageCache import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.HistoryFetcher import sh.sar.basedbank.util.HistoryFetcher
import sh.sar.basedbank.util.MerchantIconCache import sh.sar.basedbank.util.MerchantIconCache
@@ -80,6 +83,23 @@ class AccountHistoryFragment : Fragment() {
} }
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false) adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) } 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.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
@@ -119,6 +139,7 @@ class AccountHistoryFragment : Fragment() {
} }
(activity as? HomeActivity)?.setRefreshing(true) (activity as? HomeActivity)?.setRefreshing(true)
loadNextPage() loadNextPage()
loadPendingTransactions()
binding.swipeRefresh.setOnRefreshListener { binding.swipeRefresh.setOnRefreshListener {
if (isLoading) { if (isLoading) {
@@ -131,7 +152,12 @@ class AccountHistoryFragment : Fragment() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
if (::account.isInitialized) requireActivity().title = account.accountBriefName if (::account.isInitialized) {
requireActivity().title = account.accountBriefName
if (adapter.showDefaultToggle) {
adapter.isDefaultAccount = CredentialStore(requireContext()).getDefaultAccountNumber() == account.accountNumber
}
}
} }
private fun filterAndDisplay() { private fun filterAndDisplay() {
@@ -160,6 +186,7 @@ class AccountHistoryFragment : Fragment() {
binding.emptyView.visibility = View.GONE binding.emptyView.visibility = View.GONE
} }
loadNextPage() loadNextPage()
loadPendingTransactions()
} }
private fun loadNextPage() { private fun loadNextPage() {
@@ -201,6 +228,13 @@ class AccountHistoryFragment : Fragment() {
} }
(activity as? HomeActivity)?.hideConnectivityBanner() (activity as? HomeActivity)?.hideConnectivityBanner()
fetcher.takeCardPendingSections()?.let { (outstanding, unbilled) ->
adapter.setLeadingSections(listOf(
"Outstanding" to outstanding,
"Unbilled" to unbilled
))
}
if (transactions.isNotEmpty()) { if (transactions.isNotEmpty()) {
val existingIds = allTransactions.map { it.id }.toHashSet() val existingIds = allTransactions.map { it.id }.toHashSet()
val newOnes = transactions.filter { it.id !in existingIds } val newOnes = transactions.filter { it.id !in existingIds }
@@ -226,6 +260,26 @@ class AccountHistoryFragment : Fragment() {
} }
} }
private fun loadPendingTransactions() {
if (account.bank != "BML" || account.profileType != "BML") return
val app = requireActivity().application as BasedBankApp
val session = app.bmlSessionFor(account) ?: return
viewLifecycleOwner.lifecycleScope.launch {
try {
val pending = withContext(Dispatchers.IO) {
BmlHistoryClient().fetchPendingHistory(
session = session,
accountId = account.internalId,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber
)
}
if (_binding == null) return@launch
adapter.setPendingTransactions(pending)
} catch (_: Exception) { }
}
}
private fun loadContactImage(name: String) { private fun loadContactImage(name: String) {
if (!pendingImageNames.add(name)) return if (!pendingImageNames.add(name)) return
val contact = viewModel.contacts.value?.firstOrNull { it.benefNickName == name } ?: return val contact = viewModel.contacts.value?.firstOrNull { it.benefNickName == name } ?: return
@@ -89,6 +89,52 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
categories = cats.filter { it.id != "BML" } categories = cats.filter { it.id != "BML" }
if (selectedDest?.isBml == false) setupCategoryDropdown() if (selectedDest?.isBml == false) setupCategoryDropdown()
} }
applyPrefillArgs()
}
private fun applyPrefillArgs() {
val args = arguments ?: return
val bmlProfileId = args.getString(ARG_BML_PROFILE_ID)
val accountNumber = args.getString(ARG_ACCOUNT_NUMBER)
val recipientName = args.getString(ARG_RECIPIENT_NAME)
val currency = args.getString(ARG_CURRENCY)
if (bmlProfileId != null) {
val match = destinations.firstOrNull { it.isBml && it.bmlLoginId == bmlProfileId }
if (match != null) {
selectedDest = match
binding.actvDestination.setText(match.label, false)
updateMibOnlyVisibility()
}
}
if (accountNumber != null) {
binding.etAccount.setText(accountNumber)
}
// Skip lookup only when we have a MIB-verified name+currency from the caller.
if (selectedDest != null && accountNumber != null &&
!recipientName.isNullOrBlank() && !currency.isNullOrBlank()
) {
val bankBic = when {
accountNumber.matches(Regex("^9\\d{16}$")) -> "MADVMVMV"
accountNumber.matches(Regex("^7\\d{12}$")) -> "MALBMVMV"
else -> ""
}
val trnType = if (accountNumber.matches(Regex("^9\\d{16}$"))) "DOT" else "IAT"
val validation = BmlAccountValidation(
trnType = trnType,
validationType = "prefilled",
account = accountNumber,
originalInput = accountNumber,
name = recipientName,
alias = null,
currency = currency,
agnt = bankBic.takeIf { it.isNotBlank() }
)
showLookupResult(validation, accountNumber)
}
} }
private fun buildDestinations(): List<DestinationOption> { private fun buildDestinations(): List<DestinationOption> {
@@ -517,5 +563,24 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
companion object { companion object {
// BML's internal UUID for MIB bank — used as the "swift" field when saving DOT contacts // BML's internal UUID for MIB bank — used as the "swift" field when saving DOT contacts
private const val MIB_SWIFT_ON_BML = "F4E79935-3E73-E611-80DD-00155D020F0A" private const val MIB_SWIFT_ON_BML = "F4E79935-3E73-E611-80DD-00155D020F0A"
private const val ARG_BML_PROFILE_ID = "bml_profile_id"
private const val ARG_ACCOUNT_NUMBER = "account_number"
private const val ARG_RECIPIENT_NAME = "recipient_name"
private const val ARG_CURRENCY = "currency"
fun newInstance(
bmlProfileId: String? = null,
accountNumber: String? = null,
recipientName: String? = null,
currency: String? = null
) = AddContactSheetFragment().apply {
arguments = Bundle().apply {
if (bmlProfileId != null) putString(ARG_BML_PROFILE_ID, bmlProfileId)
if (accountNumber != null) putString(ARG_ACCOUNT_NUMBER, accountNumber)
if (recipientName != null) putString(ARG_RECIPIENT_NAME, recipientName)
if (currency != null) putString(ARG_CURRENCY, currency)
}
}
} }
} }
@@ -0,0 +1,13 @@
package sh.sar.basedbank.ui.home
data class AppNotification(
val id: String,
val bank: String, // "BML" or "MIB"
val loginId: String, // key in bmlSessions / mibSessions
val group: String, // "ALERTS" or "INFORMATION"
val title: String,
val message: String,
val timestampMs: Long,
val isRead: Boolean,
val detailFields: List<Pair<String, String>> = emptyList()
)
@@ -33,6 +33,8 @@ import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentBmlQrPayBinding import sh.sar.basedbank.databinding.FragmentBmlQrPayBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache
import sh.sar.basedbank.util.Totp import sh.sar.basedbank.util.Totp
class BmlQrPayFragment : Fragment() { class BmlQrPayFragment : Fragment() {
@@ -150,6 +152,19 @@ class BmlQrPayFragment : Fragment() {
return@launch return@launch
} }
merchantInfo = info 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) populateMerchant(info)
} }
} }
@@ -315,6 +330,7 @@ class BmlQrPayFragment : Fragment() {
.setTitle(R.string.bml_qr_payment_success) .setTitle(R.string.bml_qr_payment_success)
.setView(container) .setView(container)
.setPositiveButton(android.R.string.ok) { _, _ -> .setPositiveButton(android.R.string.ok) { _, _ ->
(activity as? HomeActivity)?.triggerRefresh()
requireActivity().onBackPressedDispatcher.onBackPressed() requireActivity().onBackPressedDispatcher.onBackPressed()
} }
.setCancelable(false) .setCancelable(false)
@@ -0,0 +1,561 @@
package sh.sar.basedbank.ui.home
import android.animation.ValueAnimator
import android.content.Context
import android.graphics.*
import android.os.Bundle
import android.os.VibrationEffect
import android.os.Vibrator
import android.util.AttributeSet
import android.util.TypedValue
import android.view.*
import android.view.animation.DecelerateInterpolator
import android.view.animation.LinearInterpolator
import android.animation.AnimatorListenerAdapter
import android.animation.Animator
import android.widget.FrameLayout
import android.graphics.Typeface
import androidx.annotation.DrawableRes
import androidx.appcompat.content.res.AppCompatResources
import androidx.appcompat.widget.Toolbar
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import com.google.android.material.color.MaterialColors
import sh.sar.basedbank.R
import kotlin.math.*
class CircularNavFragment : Fragment() {
private var wheelView: CircularWheelView? = null
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val ctx = requireContext()
val colorPrimary = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorPrimary, Color.RED)
val colorSurface = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSurface, Color.WHITE)
val colorOnSurface = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, Color.DKGRAY)
fun dp(v: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, ctx.resources.displayMetrics)
val root = android.widget.LinearLayout(ctx).apply {
orientation = android.widget.LinearLayout.VERTICAL
setBackgroundColor(colorSurface)
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
// Wheel area (weight 1, fills remaining space)
val wheelContainer = FrameLayout(ctx).apply {
layoutParams = android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f
)
}
val prefs = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
wheelView = CircularWheelView(ctx).apply {
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
wheelAngle = prefs.getFloat("circular_wheel_angle", 0f)
val savedSlots = NavCustomization.getCircularSlots(prefs).map { id ->
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == id }!!
CircularWheelView.WheelItem(def.id, def.iconRes, ctx.getString(def.titleRes))
}
items = listOf(
savedSlots[3], // 4 o'clock (strip slot 3)
CircularWheelView.WheelItem(R.id.nav_dashboard, R.drawable.ic_nav_dashboard, ctx.getString(R.string.nav_dashboard)), // 6 o'clock
CircularWheelView.WheelItem(R.id.nav_more, R.drawable.ic_nav_more, ctx.getString(R.string.nav_more)), // 8 o'clock
savedSlots[0], // 10 o'clock (strip slot 0 — first in strip)
savedSlots[1], // 12 o'clock (strip slot 1)
savedSlots[2], // 2 o'clock (strip slot 2)
)
accentColor = colorPrimary
surfaceColor = colorSurface
labelColor = colorOnSurface
onItemClick = { navId -> (activity as? HomeActivity)?.navigateTo(navId) }
onCenterClick = { /* unused: tap on unlocked center locks the wheel */ }
onWheelCenterLockedTap = { (activity as? HomeActivity)?.notifyWheelLockTap() }
}
wheelContainer.addView(wheelView)
// App icon centered at the bottom
val iconSz = dp(48f).toInt()
val footerIcon = android.widget.ImageView(ctx).apply {
setImageDrawable(ctx.packageManager.getApplicationIcon(ctx.packageName))
layoutParams = android.widget.LinearLayout.LayoutParams(iconSz, iconSz).also {
it.gravity = Gravity.CENTER_HORIZONTAL
it.topMargin = dp(12f).toInt()
it.bottomMargin = dp(16f).toInt()
}
}
root.addView(wheelContainer)
root.addView(footerIcon)
ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
(footerIcon.layoutParams as android.widget.LinearLayout.LayoutParams).bottomMargin = dp(16f).toInt() + bars.bottom
footerIcon.requestLayout()
insets
}
return root
}
override fun onResume() {
super.onResume()
requireActivity().invalidateOptionsMenu()
val ctx = requireContext()
val toolbar = requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar)
requireActivity().title = ""
val textColor = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.DKGRAY)
val container = android.widget.TextView(ctx).apply {
text = getString(R.string.app_name)
setTextColor(textColor)
textSize = 20f
typeface = Typeface.DEFAULT_BOLD
tag = "wheel_title"
}
toolbar.addView(container, Toolbar.LayoutParams(
Toolbar.LayoutParams.WRAP_CONTENT,
Toolbar.LayoutParams.WRAP_CONTENT,
Gravity.CENTER
))
}
override fun onPause() {
super.onPause()
wheelView?.let { wv ->
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
.edit().putFloat("circular_wheel_angle", wv.wheelAngle).apply()
}
val toolbar = requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar)
toolbar.findViewWithTag<android.view.View>("wheel_title")?.let { toolbar.removeView(it) }
requireActivity().invalidateOptionsMenu()
}
fun unlockWheelLock() {
wheelView?.unlockWheel()
}
}
// ---------------------------------------------------------------------------
// Custom wheel view
// ---------------------------------------------------------------------------
class CircularWheelView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
data class WheelItem(
val navId: Int,
@DrawableRes val iconRes: Int,
val label: String
)
// ---- public properties ------------------------------------------------
var items: List<WheelItem> = emptyList()
set(value) {
field = value
iconBitmaps = arrayOfNulls(value.size)
if (cx > 0f) reloadBitmaps()
invalidate()
}
var accentColor: Int = Color.RED
set(value) { field = value; if (cx > 0f) reloadBitmaps(); invalidate() }
var surfaceColor: Int = Color.WHITE
set(value) { field = value; invalidate() }
var labelColor: Int = Color.DKGRAY
set(value) { field = value; invalidate() }
var isWheelLocked = false
set(value) { field = value; invalidate() }
var onItemClick: ((Int) -> Unit)? = null
var onCenterClick: (() -> Unit)? = null
var onWheelCenterLockedTap: (() -> Unit)? = null
// ---- geometry ---------------------------------------------------------
private var cx = 0f
private var cy = 0f
private var outerRadius = 0f
private var innerRadius = 0f
// ---- paint ------------------------------------------------------------
private val discPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val accentRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
private val accentRing2Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
private val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = Typeface.DEFAULT_BOLD
}
private val centerFillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val centerRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
private val bitmapPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private var iconBitmaps: Array<Bitmap?> = emptyArray()
private var centerBitmap: Bitmap? = null
private var centerUnlockedBitmap: Bitmap? = null
private val grayFilter = ColorMatrixColorFilter(ColorMatrix().apply { setSaturation(0f) })
private var lockShakeAngle = 0f
private var shakeAnimator: ValueAnimator? = null
// ---- touch & fling ----------------------------------------------------
var wheelAngle = 0f
private var isDragging = false
private var snapAnimator: ValueAnimator? = null
// Incremental drag state
private var prevTouchAngle = 0f
private var touchDownX = 0f
private var touchDownY = 0f
// Velocity buffer: stores (cumulative wheel angle, timestamp) for last N samples
private val VEL_SAMPLES = 6
private val velAngles = FloatArray(VEL_SAMPLES)
private val velTimes = LongArray(VEL_SAMPLES)
private var velIdx = 0
private var velCount = 0
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop.toFloat()
// ---- helpers ----------------------------------------------------------
private fun dp(v: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, resources.displayMetrics)
// ---- sizing -----------------------------------------------------------
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
cx = w / 2f
cy = h / 2f
val size = minOf(w, h)
outerRadius = size / 2f * 0.80f
innerRadius = outerRadius * 0.26f
textPaint.textSize = size * 0.034f
dividerPaint.strokeWidth = dp(0.7f)
accentRingPaint.strokeWidth = dp(5f)
accentRing2Paint.strokeWidth = dp(3f)
centerRingPaint.strokeWidth = dp(4f)
reloadBitmaps()
}
private fun reloadBitmaps() {
val iconPx = (outerRadius * 0.24f).toInt().coerceAtLeast(1)
items.forEachIndexed { i, item ->
iconBitmaps[i] = tintedBitmap(item.iconRes, accentColor, iconPx)
}
val centerPx = (innerRadius * 0.64f).toInt().coerceAtLeast(1)
centerBitmap = tintedBitmap(R.drawable.ic_lock, accentColor, centerPx)
centerUnlockedBitmap = tintedBitmap(R.drawable.ic_lock_open, accentColor, centerPx)
}
private fun tintedBitmap(@DrawableRes resId: Int, tint: Int, sizePx: Int): Bitmap? {
if (sizePx <= 0) return null
return try {
val d = AppCompatResources.getDrawable(context, resId)!!.mutate()
DrawableCompat.setTint(DrawableCompat.wrap(d), tint)
val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
Canvas(bmp).also { d.setBounds(0, 0, sizePx, sizePx); d.draw(it) }
bmp
} catch (_: Exception) { null }
}
// ---- drawing ----------------------------------------------------------
override fun onDraw(canvas: Canvas) {
if (items.isEmpty()) return
val segCount = items.size
val segDeg = 360f / segCount
// Wheel disc
discPaint.color = surfaceColor
canvas.drawCircle(cx, cy, outerRadius, discPaint)
// Accent ring around wheel
accentRingPaint.color = accentColor
canvas.drawCircle(cx, cy, outerRadius + dp(20f), accentRingPaint)
// Rotatable layer
canvas.save()
canvas.rotate(wheelAngle, cx, cy)
// Divider lines between segments
dividerPaint.color = (labelColor and 0x00FFFFFF) or (100 shl 24)
for (i in 0 until segCount) {
val rad = Math.toRadians((i * segDeg).toDouble())
val cos = cos(rad).toFloat()
val sin = sin(rad).toFloat()
canvas.drawLine(
cx + cos * (innerRadius + dp(6f)), cy + sin * (innerRadius + dp(6f)),
cx + cos * (outerRadius - dp(12f)), cy + sin * (outerRadius - dp(12f)),
dividerPaint
)
}
// Segment content
for (i in 0 until segCount) {
val midDeg = i * segDeg + segDeg / 2f
drawSegment(canvas, i, midDeg)
}
canvas.restore()
// Center button — always upright
centerRingPaint.color = accentColor
canvas.drawCircle(cx, cy, innerRadius + dp(3f), centerRingPaint)
centerFillPaint.color = surfaceColor
canvas.drawCircle(cx, cy, innerRadius, centerFillPaint)
val activeCenterBitmap = if (isWheelLocked) centerBitmap else centerUnlockedBitmap
activeCenterBitmap?.let {
canvas.save()
// Shake pivots around the bottom-centre of the icon
if (lockShakeAngle != 0f) canvas.rotate(lockShakeAngle, cx, cy + it.height / 2f)
canvas.drawBitmap(it, cx - it.width / 2f, cy - it.height / 2f, bitmapPaint)
canvas.restore()
}
}
private fun drawSegment(canvas: Canvas, index: Int, midDeg: Float) {
val rad = Math.toRadians(midDeg.toDouble())
val cosA = cos(rad).toFloat()
val sinA = sin(rad).toFloat()
val iconX = cx + cosA * (outerRadius * 0.63f)
val iconY = cy + sinA * (outerRadius * 0.63f)
// Icon — radially oriented; top items are naturally upside-down
iconBitmaps.getOrNull(index)?.let { bmp ->
canvas.save()
canvas.translate(iconX, iconY)
canvas.rotate(midDeg - 90f)
if (isWheelLocked) {
bitmapPaint.colorFilter = grayFilter
bitmapPaint.alpha = 100
}
canvas.drawBitmap(bmp, -bmp.width / 2f, -bmp.height / 2f, bitmapPaint)
if (isWheelLocked) {
bitmapPaint.colorFilter = null
bitmapPaint.alpha = 255
}
canvas.restore()
}
// Curved label — same radial orientation as icons.
// In the local rotated frame the wheel arc is a circle of radius `labelRadius`
// with its centre directly "above" at (0, -labelRadius). A CCW arc through (0,0)
// flows rightward at that point, matching the natural reading direction at 6 o'clock.
val labelRadius = outerRadius * 0.84f
val textX = cx + cosA * labelRadius
val textY = cy + sinA * labelRadius
val label = items[index].label
textPaint.color = if (isWheelLocked) (labelColor and 0x00FFFFFF) or (80 shl 24) else labelColor
textPaint.textAlign = Paint.Align.LEFT
val halfAngleDeg = Math.toDegrees((textPaint.measureText(label) / 2.0) / labelRadius).toFloat()
val localArcRect = RectF(-labelRadius, -2f * labelRadius, labelRadius, 0f)
val arcPath = Path().apply { addArc(localArcRect, 90f + halfAngleDeg, -(halfAngleDeg * 2f)) }
canvas.save()
canvas.translate(textX, textY)
canvas.rotate(midDeg - 90f)
canvas.drawTextOnPath(label, arcPath, 0f, textPaint.textSize * 0.36f, textPaint)
canvas.restore()
}
// ---- touch ------------------------------------------------------------
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
snapAnimator?.cancel()
prevTouchAngle = angleAt(event.x, event.y)
touchDownX = event.x
touchDownY = event.y
isDragging = false
velIdx = 0
velCount = 0
recordVelSample()
return true
}
MotionEvent.ACTION_MOVE -> {
val curr = angleAt(event.x, event.y)
// Incremental delta — normalised to [-180, 180] to survive the ±180° wrap
var dA = curr - prevTouchAngle
if (dA > 180f) dA -= 360f
if (dA < -180f) dA += 360f
prevTouchAngle = curr
val moved = hypot(event.x - touchDownX, event.y - touchDownY)
if (moved > touchSlop || isDragging) {
isDragging = true
wheelAngle += dA
recordVelSample()
invalidate()
}
}
MotionEvent.ACTION_UP -> {
if (!isDragging) {
val dist = hypot(event.x - cx, event.y - cy)
when {
dist <= innerRadius -> {
if (isWheelLocked) {
onWheelCenterLockedTap?.invoke()
} else {
isWheelLocked = true
}
}
dist <= outerRadius -> {
if (isWheelLocked) {
val idx = segmentAt(event.x, event.y)
if (idx in items.indices) animateToSixOClock(idx) {
vibrateDevice()
shakeLock()
}
} else {
val idx = segmentAt(event.x, event.y)
if (idx in items.indices) animateToSixOClock(idx) { onItemClick?.invoke(items[idx].navId) }
}
}
}
} else {
val vel = computeVelocity()
if (abs(vel) > 0.05f) fling(vel) else snapToNearest()
}
}
MotionEvent.ACTION_CANCEL -> {
if (isDragging) {
val vel = computeVelocity()
if (abs(vel) > 0.05f) fling(vel) else snapToNearest()
}
}
}
return true
}
private fun recordVelSample() {
val slot = velIdx % VEL_SAMPLES
velAngles[slot] = wheelAngle
velTimes[slot] = System.currentTimeMillis()
velIdx++
if (velCount < VEL_SAMPLES) velCount++
}
/** Returns angular velocity in degrees per millisecond, using the oldest available sample. */
private fun computeVelocity(): Float {
if (velCount < 2) return 0f
val newest = (velIdx - 1 + VEL_SAMPLES) % VEL_SAMPLES
// Use the sample that is ~100 ms old for a stable estimate
val oldest = (velIdx - velCount + VEL_SAMPLES) % VEL_SAMPLES
val dt = velTimes[newest] - velTimes[oldest]
if (dt <= 0L) return 0f
return (velAngles[newest] - velAngles[oldest]) / dt
}
/**
* Kick off a physics-based fling: uniform deceleration from [initialVel] to zero,
* then snap to the nearest segment.
* Formula: total_rotation = v0² / (2 * DECEL), duration = v0 / DECEL
* With DecelerateInterpolator(1) the initial animation velocity matches v0.
*/
private fun fling(initialVel: Float) {
val DECEL = 0.0008f // deg / ms² — tune for feel
val duration = (abs(initialVel) / DECEL).toLong().coerceIn(200, 3500)
val sign = if (initialVel >= 0f) 1f else -1f
val totalRot = sign * initialVel * initialVel / (2f * DECEL)
val startAngle = wheelAngle
val endAngle = startAngle + totalRot
snapAnimator = ValueAnimator.ofFloat(startAngle, endAngle).apply {
this.duration = duration
interpolator = DecelerateInterpolator() // matches v0 at t=0
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(a: Animator) { snapToNearest() }
})
start()
}
}
private fun angleAt(x: Float, y: Float): Float =
Math.toDegrees(atan2((y - cy).toDouble(), (x - cx).toDouble())).toFloat()
private fun segmentAt(x: Float, y: Float): Int {
var a = angleAt(x, y) - wheelAngle
a = (a % 360f + 360f) % 360f
return (a / (360f / items.size)).toInt() % items.size
}
private fun animateToSixOClock(index: Int, onDone: () -> Unit) {
val segDeg = 360f / items.size.coerceAtLeast(1)
val midDeg = index * segDeg + segDeg / 2f
// delta needed so this segment's midpoint lands at 90° (6 o'clock in math coords)
var delta = (90f - midDeg) - wheelAngle
// normalise to shortest path [-180, 180]
delta = ((delta % 360f) + 360f) % 360f
if (delta > 180f) delta -= 360f
val endAngle = wheelAngle + delta
snapAnimator?.cancel()
snapAnimator = ValueAnimator.ofFloat(wheelAngle, endAngle).apply {
duration = 350
interpolator = DecelerateInterpolator()
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
addListener(object : AnimatorListenerAdapter() {
private var cancelled = false
override fun onAnimationCancel(a: Animator) { cancelled = true }
override fun onAnimationEnd(a: Animator) { if (!cancelled) onDone() }
})
start()
}
}
private fun snapToNearest() {
val segDeg = 360f / items.size.coerceAtLeast(1)
val target = (wheelAngle / segDeg).roundToInt() * segDeg
snapAnimator = ValueAnimator.ofFloat(wheelAngle, target).apply {
duration = 300
interpolator = DecelerateInterpolator()
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
start()
}
}
private fun vibrateDevice() {
val v = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
v.vibrate(VibrationEffect.createOneShot(60, VibrationEffect.DEFAULT_AMPLITUDE))
}
fun shakeLock() {
shakeAnimator?.cancel()
shakeAnimator = ValueAnimator.ofFloat(0f, -18f, 18f, -12f, 12f, -6f, 6f, 0f).apply {
duration = 500
interpolator = LinearInterpolator()
addUpdateListener { lockShakeAngle = it.animatedValue as Float; invalidate() }
addListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(a: Animator) { lockShakeAngle = 0f; invalidate() }
})
start()
}
}
fun unlockWheel() {
isWheelLocked = false
lockShakeAngle = 0f
shakeAnimator?.cancel()
invalidate()
}
}
@@ -151,7 +151,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() } viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() } viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
(activity as? HomeActivity)?.triggerRefresh() (activity as? HomeActivity)?.loadAllContacts()
} }
private fun attachMediator(pages: List<TabDef>) { private fun attachMediator(pages: List<TabDef>) {
@@ -168,6 +168,10 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val account = accounts.firstOrNull { it.accountNumber == accountNumber } val account = accounts.firstOrNull { it.accountNumber == accountNumber }
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label) val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
when { when {
accountNumber.startsWith("bmlqr:") -> {
bundle.putString(KEY_SUBTITLE, "BML QR Merchant")
bundle.putString(KEY_COLOR, "#0066A1")
}
account != null -> { account != null -> {
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}") bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
bundle.putString(KEY_COLOR, "#FE860E") bundle.putString(KEY_COLOR, "#FE860E")
@@ -178,7 +178,7 @@ class ContactsFragment : Fragment() {
colorHex = contact.bankColor, colorHex = contact.bankColor,
imageHash = contact.imageHash imageHash = contact.imageHash
) )
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment) (requireActivity() as HomeActivity).showWithBackStack(fragment)
} }
private fun confirmDelete(contact: ContactDisplay) { private fun confirmDelete(contact: ContactDisplay) {
@@ -4,13 +4,13 @@ import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Bundle import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
@@ -26,6 +26,8 @@ import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.util.bmlapi.BmlCardParser import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.NfcPaymentUtil
import sh.sar.basedbank.util.PaymvQrParser
import kotlin.math.abs import kotlin.math.abs
import sh.sar.basedbank.databinding.FragmentDashboardBinding import sh.sar.basedbank.databinding.FragmentDashboardBinding
import sh.sar.basedbank.databinding.ItemForeignLimitBinding import sh.sar.basedbank.databinding.ItemForeignLimitBinding
@@ -35,21 +37,35 @@ class DashboardFragment : Fragment() {
private var _binding: FragmentDashboardBinding? = null private var _binding: FragmentDashboardBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels() private val viewModel: HomeViewModel by activityViewModels()
private var pendingQrCardNumber: String? = null
private var pendingQrAccountNumber: String? = null
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
raw.startsWith("https://pay.bml.com.mv/app/")) { val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
(requireActivity() as HomeActivity).navigateTo( (requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, pendingQrAccountNumber) R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
) )
} else { } else {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show() 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()
}
} }
pendingQrAccountNumber = null
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
@@ -84,11 +100,11 @@ class DashboardFragment : Fragment() {
} }
binding.cardPendingFinances.setOnClickListener { binding.cardPendingFinances.setOnClickListener {
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances) (activity as? HomeActivity)?.showWithBackStackAndNav(FinancingFragment(), R.id.nav_finances)
} }
binding.cardOverdue.setOnClickListener { binding.cardOverdue.setOnClickListener {
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances) (activity as? HomeActivity)?.showWithBackStackAndNav(FinancingFragment(), R.id.nav_finances)
} }
val cardAdapter = DashboardCardAdapter() val cardAdapter = DashboardCardAdapter()
@@ -97,12 +113,16 @@ class DashboardFragment : Fragment() {
LinearSnapHelper().attachToRecyclerView(binding.rvCards) LinearSnapHelper().attachToRecyclerView(binding.rvCards)
val updateCardList = { val updateCardList = {
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) } val credStore = CredentialStore(requireContext())
val hidden = credStore.getHiddenDashboardCardNumbers()
val mibItems = (viewModel.mibCards.value ?: emptyList())
.filter { CardsFragment.isMibCardActive(it.cardStatus) && !hidden.contains(it.maskedCardNumber) }
.map { CardItem.Mib(it) }
val bmlItems = (viewModel.accounts.value ?: emptyList()) 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) } .filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
.map { CardItem.Bml(it) } .map { CardItem.Bml(it) }
val all = mibItems + bmlItems val all = bmlItems + mibItems
val defaultNum = CredentialStore(requireContext()).getDefaultCardAccountNumber() val defaultNum = credStore.getDefaultCardAccountNumber()
val ordered = if (defaultNum != null) { val ordered = if (defaultNum != null) {
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum } val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
if (def != null) listOf(def) + all.filter { it !== def } else all if (def != null) listOf(def) + all.filter { it !== def } else all
@@ -115,7 +135,7 @@ class DashboardFragment : Fragment() {
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt() val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets -> ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false) val isBottomNav = NavCustomization.getNavMode(requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE)) == NavCustomization.NAV_MODE_BOTTOM
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars()) val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom) v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
@@ -126,13 +146,32 @@ class DashboardFragment : Fragment() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
requireActivity().title = getString(R.string.nav_dashboard) val isBottom = NavCustomization.getNavMode(requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)) == NavCustomization.NAV_MODE_BOTTOM
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)
}
refreshQuickActions() refreshQuickActions()
} }
override fun onPause() {
super.onPause()
requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar).logo = null
}
private fun refreshQuickActions() { private fun refreshQuickActions() {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val isBottom = prefs.getBoolean("bottom_nav", false) val isBottom = NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM
if (isBottom) { if (isBottom) {
binding.buttonBar.visibility = View.GONE binding.buttonBar.visibility = View.GONE
return return
@@ -377,7 +416,7 @@ class DashboardFragment : Fragment() {
if (isMib) { if (isMib) {
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
} else { } else {
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber pendingQrCardNumber = (item as CardItem.Bml).account.accountNumber
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java)) qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
} }
} }
@@ -385,8 +424,17 @@ class DashboardFragment : Fragment() {
val nfcSupported = nfcAdapter != null val nfcSupported = nfcAdapter != null
btnPayNfc.isEnabled = nfcSupported btnPayNfc.isEnabled = nfcSupported
btnPayNfc.setOnClickListener { btnPayNfc.setOnClickListener {
val msg = if (isMib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress if (isMib) {
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
} else {
NfcPaymentUtil.checkAndProceed(requireContext()) {
val accountNumber = (item as CardItem.Bml).account.accountNumber
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_pay_with_card,
CardsFragment.newInstanceWithAutoTapMode(accountNumber)
)
}
}
} }
} }
} }
@@ -76,6 +76,8 @@ class HomeActivity : AppCompatActivity() {
private val viewModel: HomeViewModel by viewModels() private val viewModel: HomeViewModel by viewModels()
private lateinit var toggle: ActionBarDrawerToggle private lateinit var toggle: ActionBarDrawerToggle
private var suppressBottomNavCallback = false private var suppressBottomNavCallback = false
private var cachedTransferFragment: TransferFragment? = null
private val navBackStack = ArrayDeque<Int>()
private var backPressedOnce = false private var backPressedOnce = false
private val backPressHandler = Handler(Looper.getMainLooper()) private val backPressHandler = Handler(Looper.getMainLooper())
@@ -89,6 +91,10 @@ class HomeActivity : AppCompatActivity() {
private val warningRunnable = Runnable { showAutolockWarning() } private val warningRunnable = Runnable { showAutolockWarning() }
private var isLocked = false private var isLocked = false
private var pendingWheelUnlock = false
private var hasUnreadNotifications = false
private var notifMenuItem: MenuItem? = null
private val autolockRunnable = Runnable { private val autolockRunnable = Runnable {
countdownTimer?.cancel(); countdownTimer = null countdownTimer?.cancel(); countdownTimer = null
@@ -98,6 +104,21 @@ class HomeActivity : AppCompatActivity() {
if (securitySet) lock() if (securitySet) lock()
} }
fun lockApp() = lock()
fun notifyWheelLockTap() {
val securitySet = getSharedPreferences("prefs", MODE_PRIVATE)
.getString("security_method", null) != null
if (securitySet) {
pendingWheelUnlock = true
lock()
} else {
// No security configured — unlock the wheel immediately
(supportFragmentManager.findFragmentById(R.id.contentFrame) as? CircularNavFragment)
?.unlockWheelLock()
}
}
private fun lock() { private fun lock() {
isLocked = true isLocked = true
startActivity( startActivity(
@@ -156,7 +177,7 @@ class HomeActivity : AppCompatActivity() {
R.id.nav_dashboard -> DashboardFragment() R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> AccountsFragment() R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> ContactsFragment() R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> TransferFragment() R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
R.id.nav_pay_mv_qr -> PayMvQrFragment() R.id.nav_pay_mv_qr -> PayMvQrFragment()
R.id.nav_more -> MoreFragment() R.id.nav_more -> MoreFragment()
R.id.nav_activities -> ActivitiesFragment() R.id.nav_activities -> ActivitiesFragment()
@@ -237,14 +258,31 @@ class HomeActivity : AppCompatActivity() {
if (savedInstanceState == null) { if (savedInstanceState == null) {
val navDest = intent.getIntExtra("nav_destination", -1) val navDest = intent.getIntExtra("nav_destination", -1)
val autoScan = intent.getBooleanExtra("auto_scan", false) val autoScan = intent.getBooleanExtra("auto_scan", false)
if (navDest != -1) { val autoTapMode = intent.getBooleanExtra("auto_tap_mode", false)
val fragment = if (autoScan && navDest == R.id.nav_transfer) val shareQrText = intent.getStringExtra("share_qr_text")
TransferFragment.newInstanceWithAutoScan() when {
else null shareQrText != null -> {
navigateTo(navDest, fragment) show(DashboardFragment())
} else { binding.navigationView.setCheckedItem(R.id.nav_dashboard)
show(DashboardFragment()) routeSharedQrText(shareQrText)
binding.navigationView.setCheckedItem(R.id.nav_dashboard) }
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 -> {
val initPrefs = getSharedPreferences("prefs", MODE_PRIVATE)
if (NavCustomization.getNavMode(initPrefs) == NavCustomization.NAV_MODE_CIRCULAR) {
show(CircularNavFragment())
} else {
show(DashboardFragment())
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
}
}
} }
} }
@@ -258,14 +296,40 @@ class HomeActivity : AppCompatActivity() {
// Let CardsFragment handle back if in manage mode // Let CardsFragment handle back if in manage mode
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame) val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
if (currentFrag is CardsFragment && currentFrag.onBackPressed()) return if (currentFrag is CardsFragment && currentFrag.onBackPressed()) return
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val navMode = NavCustomization.getNavMode(prefs)
// Circular nav mode: back always returns to the wheel
if (navMode == NavCustomization.NAV_MODE_CIRCULAR) {
if (supportFragmentManager.backStackEntryCount > 0) {
supportFragmentManager.popBackStack()
navBackStack.removeLastOrNull()?.let { updateNavSelection(it) }
return
}
if (currentFrag is CircularNavFragment) {
if (backPressedOnce) {
backPressHandler.removeCallbacks(resetBackPress)
finish()
} else {
backPressedOnce = true
Toast.makeText(this@HomeActivity, R.string.press_back_to_exit, Toast.LENGTH_SHORT).show()
backPressHandler.postDelayed(resetBackPress, 2000)
}
} else {
show(CircularNavFragment())
}
return
}
// Pop fragment back stack if there's anything on it (e.g. showWithBackStack) // Pop fragment back stack if there's anything on it (e.g. showWithBackStack)
if (supportFragmentManager.backStackEntryCount > 0) { if (supportFragmentManager.backStackEntryCount > 0) {
supportFragmentManager.popBackStack() supportFragmentManager.popBackStack()
navBackStack.removeLastOrNull()?.let { updateNavSelection(it) }
return return
} }
// In bottom nav mode, pressing back navigates up the hierarchy // In bottom nav mode, pressing back navigates up the hierarchy
val isBottomNav = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false) if (navMode == NavCustomization.NAV_MODE_BOTTOM && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
if (isBottomNav && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
// Sub-page reached via More (e.g. Settings, Activities) — go back to More // Sub-page reached via More (e.g. Settings, Activities) — go back to More
if (binding.bottomNavigation.selectedItemId == R.id.nav_more && currentFrag !is MoreFragment) { if (binding.bottomNavigation.selectedItemId == R.id.nav_more && currentFrag !is MoreFragment) {
show(MoreFragment()) show(MoreFragment())
@@ -321,21 +385,44 @@ class HomeActivity : AppCompatActivity() {
.commit() .commit()
} }
private fun updateNavSelection(itemId: Int) {
binding.navigationView.setCheckedItem(itemId)
if (binding.bottomNavigation.visibility == View.VISIBLE) {
val bottomNavIds = (0 until binding.bottomNavigation.menu.size())
.map { binding.bottomNavigation.menu.getItem(it).itemId }.toSet()
val selectId = if (itemId in bottomNavIds) itemId else if (R.id.nav_more in bottomNavIds) R.id.nav_more else null
if (selectId != null) {
suppressBottomNavCallback = true
binding.bottomNavigation.selectedItemId = selectId
suppressBottomNavCallback = false
}
}
}
fun applyNavMode() { fun applyNavMode() {
val prefs = getSharedPreferences("prefs", MODE_PRIVATE) val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val isBottom = prefs.getBoolean("bottom_nav", false) when (NavCustomization.getNavMode(prefs)) {
if (isBottom) { NavCustomization.NAV_MODE_BOTTOM -> {
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED) binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
toggle.isDrawerIndicatorEnabled = false toggle.isDrawerIndicatorEnabled = false
supportActionBar?.setDisplayHomeAsUpEnabled(false) supportActionBar?.setDisplayHomeAsUpEnabled(false)
binding.bottomNavigation.visibility = View.VISIBLE binding.bottomNavigation.visibility = View.VISIBLE
rebuildBottomNav(prefs) rebuildBottomNav(prefs)
applyNavLabelVisibility() applyNavLabelVisibility()
} else { }
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED) NavCustomization.NAV_MODE_CIRCULAR -> {
toggle.isDrawerIndicatorEnabled = true binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
toggle.syncState() toggle.isDrawerIndicatorEnabled = false
binding.bottomNavigation.visibility = View.GONE supportActionBar?.setDisplayHomeAsUpEnabled(false)
binding.bottomNavigation.visibility = View.GONE
}
else -> {
supportActionBar?.show()
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
toggle.isDrawerIndicatorEnabled = true
toggle.syncState()
binding.bottomNavigation.visibility = View.GONE
}
} }
} }
@@ -374,11 +461,15 @@ fun applyNavLabelVisibility() {
} }
fun navigateTo(itemId: Int, fragment: Fragment? = null) { fun navigateTo(itemId: Int, fragment: Fragment? = null) {
// Restore action bar when leaving the circular wheel screen
if (NavCustomization.getNavMode(getSharedPreferences("prefs", MODE_PRIVATE)) == NavCustomization.NAV_MODE_CIRCULAR) {
supportActionBar?.show()
}
val dest = fragment ?: when (itemId) { val dest = fragment ?: when (itemId) {
R.id.nav_dashboard -> DashboardFragment() R.id.nav_dashboard -> DashboardFragment()
R.id.nav_accounts -> AccountsFragment() R.id.nav_accounts -> AccountsFragment()
R.id.nav_contacts -> ContactsFragment() R.id.nav_contacts -> ContactsFragment()
R.id.nav_transfer -> TransferFragment() R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
R.id.nav_pay_mv_qr -> PayMvQrFragment() R.id.nav_pay_mv_qr -> PayMvQrFragment()
R.id.nav_activities -> ActivitiesFragment() R.id.nav_activities -> ActivitiesFragment()
R.id.nav_transfer_history -> TransferHistoryFragment() R.id.nav_transfer_history -> TransferHistoryFragment()
@@ -386,25 +477,16 @@ fun applyNavLabelVisibility() {
R.id.nav_otp -> OtpFragment() R.id.nav_otp -> OtpFragment()
R.id.nav_settings -> SettingsFragment() R.id.nav_settings -> SettingsFragment()
R.id.nav_pay_with_card -> CardsFragment() R.id.nav_pay_with_card -> CardsFragment()
R.id.nav_more -> MoreFragment()
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return } else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
} }
show(dest) show(dest)
binding.navigationView.setCheckedItem(itemId) updateNavSelection(itemId)
if (binding.bottomNavigation.visibility == View.VISIBLE) {
val bottomNavIds = (0 until binding.bottomNavigation.menu.size())
.map { binding.bottomNavigation.menu.getItem(it).itemId }.toSet()
val selectId = if (itemId in bottomNavIds) itemId else if (R.id.nav_more in bottomNavIds) R.id.nav_more else null
if (selectId != null) {
suppressBottomNavCallback = true
binding.bottomNavigation.selectedItemId = selectId
suppressBottomNavCallback = false
}
}
} }
fun setBottomNavVisible(visible: Boolean) { fun setBottomNavVisible(visible: Boolean) {
val isBottom = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false) val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
if (isBottom) { if (NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM) {
binding.bottomNavigation.visibility = if (visible) View.VISIBLE else View.GONE binding.bottomNavigation.visibility = if (visible) View.VISIBLE else View.GONE
} }
} }
@@ -433,6 +515,33 @@ fun applyNavLabelVisibility() {
.commit() .commit()
} }
fun showWithBackStackAndNav(fragment: Fragment, itemId: Int) {
navBackStack.addLast(binding.bottomNavigation.selectedItemId)
showWithBackStack(fragment)
updateNavSelection(itemId)
}
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() { override fun onResume() {
super.onResume() super.onResume()
// Returning from LockActivity — refresh sessions since they may have expired. // Returning from LockActivity — refresh sessions since they may have expired.
@@ -441,6 +550,11 @@ fun applyNavLabelVisibility() {
pauseTime = 0L pauseTime = 0L
resetAutolockTimer() resetAutolockTimer()
autoRefresh(CredentialStore(this)) autoRefresh(CredentialStore(this))
if (pendingWheelUnlock) {
pendingWheelUnlock = false
(supportFragmentManager.findFragmentById(R.id.contentFrame) as? CircularNavFragment)
?.unlockWheelLock()
}
return return
} }
// If we were away long enough to have hit the autolock timeout (e.g. while // If we were away long enough to have hit the autolock timeout (e.g. while
@@ -523,9 +637,19 @@ fun applyNavLabelVisibility() {
eyeItem?.isVisible = true eyeItem?.isVisible = true
val hidden = viewModel.hideAmounts.value ?: false val hidden = viewModel.hideAmounts.value ?: false
eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility) eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility)
notifMenuItem = menu.findItem(R.id.action_notifications)
notifMenuItem?.setIcon(if (hasUnreadNotifications) R.drawable.ic_bell else R.drawable.ic_bell_read)
return true return true
} }
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
val onWheel = supportFragmentManager.findFragmentById(R.id.contentFrame) is CircularNavFragment
menu.findItem(R.id.action_hide_amounts)?.isVisible = !onWheel
menu.findItem(R.id.action_lock)?.isVisible = !onWheel
menu.findItem(R.id.action_notifications)?.isVisible = !onWheel
return super.onPrepareOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.action_lock) { if (item.itemId == R.id.action_lock) {
val avd = getDrawable(R.drawable.avd_lock) as? android.graphics.drawable.AnimatedVectorDrawable val avd = getDrawable(R.drawable.avd_lock) as? android.graphics.drawable.AnimatedVectorDrawable
@@ -538,6 +662,10 @@ fun applyNavLabelVisibility() {
} }
return true return true
} }
if (item.itemId == R.id.action_notifications) {
openNotificationsSheet()
return true
}
if (item.itemId == R.id.action_hide_amounts) { if (item.itemId == R.id.action_hide_amounts) {
val newHidden = !(viewModel.hideAmounts.value ?: false) val newHidden = !(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.value = newHidden viewModel.hideAmounts.value = newHidden
@@ -551,6 +679,16 @@ fun applyNavLabelVisibility() {
return super.onOptionsItemSelected(item) return super.onOptionsItemSelected(item)
} }
fun setNotificationUnread(hasUnread: Boolean) {
hasUnreadNotifications = hasUnread
notifMenuItem?.setIcon(if (hasUnread) R.drawable.ic_bell else R.drawable.ic_bell_read)
}
private fun openNotificationsSheet() {
val sheet = NotificationsSheetFragment()
sheet.onUnreadCountChanged = { hasUnread -> setNotificationUnread(hasUnread) }
sheet.show(supportFragmentManager, "notifications")
}
fun relogin() { fun relogin() {
val store = CredentialStore(this) val store = CredentialStore(this)
@@ -992,14 +1130,14 @@ fun applyNavLabelVisibility() {
val fresh = withContext(Dispatchers.IO) { val fresh = withContext(Dispatchers.IO) {
val sess = app.bmlSessionFor(src) ?: return@withContext null val sess = app.bmlSessionFor(src) ?: return@withContext null
try { try {
val accounts = BmlAccountClient().fetchAccounts(sess, src.loginTag) val accounts = BmlAccountClient().fetchAccounts(sess, src.loginTag, src.profileName, src.profileId)
AccountCache.saveBml(this@HomeActivity, loginId, accounts) AccountCache.saveBml(this@HomeActivity, loginId, accounts)
val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag } val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag || it.profileId != src.profileId }
app.bmlAccounts = otherBml + accounts app.bmlAccounts = otherBml + accounts
accounts accounts
} catch (_: Exception) { null } } catch (_: Exception) { null }
} ?: return@launch } ?: return@launch
val otherAccounts = current.filter { it.bank != "BML" || it.loginTag != src.loginTag } val otherAccounts = current.filter { it.bank != "BML" || it.loginTag != src.loginTag || it.profileId != src.profileId }
viewModel.accounts.postValue(otherAccounts + fresh) viewModel.accounts.postValue(otherAccounts + fresh)
} else { } else {
val loginId = src.loginTag.removePrefix("mib_") val loginId = src.loginTag.removePrefix("mib_")
@@ -1082,7 +1220,7 @@ fun applyNavLabelVisibility() {
for (profile in profiles) { for (profile in profiles) {
try { try {
flow.switchProfile(session, profile) flow.switchProfile(session, profile)
for (card in client.fetchCards(session, "mib_$loginId")) { for (card in client.fetchCards(session, "mib_$loginId", profile.profileId)) {
if (seen.add(card.cardId)) result += card if (seen.add(card.cardId)) result += card
} }
} catch (_: Exception) { } } catch (_: Exception) { }
@@ -7,6 +7,20 @@ import sh.sar.basedbank.R
object NavCustomization { object NavCustomization {
const val NAV_MODE_DRAWER = "drawer"
const val NAV_MODE_BOTTOM = "bottom"
const val NAV_MODE_CIRCULAR = "circular"
fun getNavMode(prefs: SharedPreferences): String {
val explicit = prefs.getString("nav_mode", null)
if (explicit != null) return explicit
return if (prefs.getBoolean("bottom_nav", false)) NAV_MODE_BOTTOM else NAV_MODE_DRAWER
}
fun saveNavMode(prefs: SharedPreferences, mode: String) {
prefs.edit().putString("nav_mode", mode).apply()
}
data class NavItemDef( data class NavItemDef(
val id: Int, val id: Int,
val key: String, val key: String,
@@ -62,8 +76,31 @@ object NavCustomization {
} }
/** Items that belong in the "More" screen — those not occupying a bottom nav slot. */ /** Items that belong in the "More" screen — those not occupying a bottom nav slot. */
fun getCircularSlots(prefs: SharedPreferences): List<Int> = listOf(
keyToId(prefs.getString("circular_slot_1_key", null), R.id.nav_transfer),
keyToId(prefs.getString("circular_slot_2_key", null), R.id.nav_pay_with_card),
keyToId(prefs.getString("circular_slot_3_key", null), R.id.nav_contacts),
keyToId(prefs.getString("circular_slot_4_key", null), R.id.nav_accounts),
)
fun saveCircularSlots(prefs: SharedPreferences, slots: List<Int>) {
prefs.edit()
.putString("circular_slot_1_key", idToKey(slots[0]) ?: "nav_transfer")
.putString("circular_slot_2_key", idToKey(slots[1]) ?: "nav_pay_with_card")
.putString("circular_slot_3_key", idToKey(slots[2]) ?: "nav_contacts")
.putString("circular_slot_4_key", idToKey(slots[3]) ?: "nav_accounts")
.apply()
}
fun getMoreItems(prefs: SharedPreferences): List<NavItemDef> { fun getMoreItems(prefs: SharedPreferences): List<NavItemDef> {
if (getNavMode(prefs) == NAV_MODE_CIRCULAR) return getCircularMoreItems(prefs)
val slots = getSlots(prefs).toSet() val slots = getSlots(prefs).toSet()
return ALL_SWAPPABLE.filter { it.id !in slots } return ALL_SWAPPABLE.filter { it.id !in slots }
} }
/** Items shown in More when circular nav is active — everything not in the saved wheel slots. */
private fun getCircularMoreItems(prefs: SharedPreferences): List<NavItemDef> {
val slotIds = getCircularSlots(prefs).toSet()
return ALL_SWAPPABLE.filter { it.id !in slotIds }
}
} }
@@ -0,0 +1,657 @@
package sh.sar.basedbank.ui.home
import android.app.Dialog
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.Typeface
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
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.BmlNotificationsClient
import sh.sar.basedbank.api.mib.MibActivityHistoryClient
import sh.sar.basedbank.util.NotificationsCache
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
// ── Sealed list item for date-grouped lists ───────────────────────────────────
private sealed class NotifListItem {
data class Header(val label: String) : NotifListItem()
data class Entry(val n: AppNotification) : NotifListItem()
}
private val headerSdf = SimpleDateFormat("EEEE, d MMMM yyyy", Locale.US)
private val dateKeySdf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
private fun toGroupedList(notifications: List<AppNotification>): List<NotifListItem> {
val result = mutableListOf<NotifListItem>()
var lastKey = ""
for (n in notifications) {
val key = dateKeySdf.format(Date(n.timestampMs))
if (key != lastKey) {
result.add(NotifListItem.Header(headerSdf.format(Date(n.timestampMs))))
lastKey = key
}
result.add(NotifListItem.Entry(n))
}
return result
}
private class NotifDiff(
private val old: List<NotifListItem>,
private val new: List<NotifListItem>
) : DiffUtil.Callback() {
override fun getOldListSize() = old.size
override fun getNewListSize() = new.size
override fun areItemsTheSame(oldPos: Int, newPos: Int): Boolean {
val o = old[oldPos]; val n = new[newPos]
return when {
o is NotifListItem.Header && n is NotifListItem.Header -> o.label == n.label
o is NotifListItem.Entry && n is NotifListItem.Entry -> o.n.id == n.n.id
else -> false
}
}
override fun areContentsTheSame(oldPos: Int, newPos: Int) = old[oldPos] == new[newPos]
}
class NotificationsSheetFragment : BottomSheetDialogFragment() {
var onUnreadCountChanged: ((hasUnread: Boolean) -> Unit)? = null
private val allNotifications = mutableListOf<AppNotification>()
private val bmlNextPage = mutableMapOf<String, Int>()
private val bmlDone = mutableMapOf<String, Boolean>()
private val mibNextStart = mutableMapOf<String, Int>()
private val mibDone = mutableMapOf<String, Boolean>()
private var isLoadingMore = false
private var mediator: TabLayoutMediator? = null
private val tabAdapters = arrayOfNulls<NotifPageAdapter>(3)
private val tabLabels = listOf("All", "Alerts", "Information")
private val tabGroupFilters = listOf<String?>(null, "ALERTS", "INFORMATION")
private lateinit var viewPager: ViewPager2
private lateinit var btnMarkAllRead: TextView
private val app get() = requireActivity().application as BasedBankApp
// ── Lifecycle ─────────────────────────────────────────────────────────────────
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val d = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
d.setOnShowListener {
val sheet = d.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)!!
BottomSheetBehavior.from(sheet).apply {
state = BottomSheetBehavior.STATE_EXPANDED
skipCollapsed = true
}
}
return d
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
inflater.inflate(R.layout.sheet_notifications, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val tabLayout = view.findViewById<TabLayout>(R.id.notifTabs)
viewPager = view.findViewById(R.id.notifPager)
btnMarkAllRead = view.findViewById(R.id.btnMarkAllRead)
tabAdapters[0] = NotifPageAdapter(null)
tabAdapters[1] = NotifPageAdapter("ALERTS")
tabAdapters[2] = NotifPageAdapter("INFORMATION")
viewPager.adapter = PageAdapter()
viewPager.offscreenPageLimit = 2
mediator = TabLayoutMediator(tabLayout, viewPager) { tab, pos ->
tab.text = tabLabels[pos]
}.also { it.attach() }
btnMarkAllRead.setOnClickListener { markAllRead() }
loadFromCache()
refreshFromNetwork()
}
override fun onDestroyView() {
mediator?.detach()
mediator = null
super.onDestroyView()
}
// ── Data loading ──────────────────────────────────────────────────────────────
private fun loadFromCache() {
val ctx = requireContext()
val readIds = NotificationsCache.getMibReadIds(ctx)
val cached = mutableListOf<AppNotification>()
app.bmlSessions.forEach { (loginId, _) ->
cached.addAll(NotificationsCache.loadBml(ctx, loginId))
}
app.mibSessions.forEach { (loginId, _) ->
cached.addAll(NotificationsCache.loadMib(ctx, loginId, readIds))
}
if (cached.isNotEmpty()) {
mergeInto(allNotifications, cached)
refreshAdapters()
}
}
private fun setSpinner(show: Boolean) {
tabAdapters.forEach { it?.showLoadingSpinner = show }
}
private fun refreshFromNetwork() {
val bmlSessions = app.bmlSessions.toMap()
val mibSessions = app.mibSessions.toMap()
lifecycleScope.launch {
setSpinner(true)
val bmlClient = BmlNotificationsClient()
bmlSessions.forEach { (loginId, session) ->
val result = withContext(Dispatchers.IO) {
bmlClient.fetchNotifications(session, loginId, page = 1)
}
if (result.items.isNotEmpty() && isAdded) {
allNotifications.removeAll { it.bank == "BML" && it.loginId == loginId }
allNotifications.addAll(result.items)
allNotifications.sortByDescending { it.timestampMs }
bmlNextPage[loginId] = 2
bmlDone[loginId] = result.items.size >= result.total
NotificationsCache.saveBml(requireContext(), loginId, result.items)
refreshAdapters()
broadcastUnread()
}
}
val mibClient = MibActivityHistoryClient()
mibSessions.forEach { (loginId, session) ->
val cachedIds = allNotifications
.filter { it.bank == "MIB" && it.loginId == loginId }
.map { it.id }.toSet()
val result = withContext(Dispatchers.IO) {
mibClient.fetchActivity(session, loginId, 1, 100)
}
if (isAdded) {
val readIds = NotificationsCache.getMibReadIds(requireContext())
val hasOverlap = cachedIds.isNotEmpty() && result.items.any { it.id in cachedIds }
val newItems = result.items
.filter { it.id !in cachedIds }
.map { it.copy(isRead = it.id in readIds) }
if (newItems.isNotEmpty()) {
allNotifications.addAll(newItems)
allNotifications.sortByDescending { it.timestampMs }
val allForLogin = allNotifications.filter { it.bank == "MIB" && it.loginId == loginId }
NotificationsCache.saveMib(requireContext(), loginId, allForLogin)
refreshAdapters()
broadcastUnread()
}
mibNextStart[loginId] = result.nextStart
mibDone[loginId] = hasOverlap || result.nextStart > result.totalCount
}
}
if (isAdded) setSpinner(false)
}
}
private fun loadMore() {
if (isLoadingMore) return
val bmlSessions = app.bmlSessions.toMap()
val mibSessions = app.mibSessions.toMap()
val anyLeft = bmlSessions.keys.any { bmlDone[it] != true } ||
mibSessions.keys.any { mibDone[it] != true }
if (!anyLeft) return
isLoadingMore = true
setSpinner(true)
lifecycleScope.launch {
val bmlClient = BmlNotificationsClient()
bmlSessions.forEach { (loginId, session) ->
if (bmlDone[loginId] == true) return@forEach
val page = bmlNextPage[loginId] ?: 2
val result = withContext(Dispatchers.IO) {
bmlClient.fetchNotifications(session, loginId, page = page)
}
if (result.items.isNotEmpty() && isAdded) {
allNotifications.addAll(result.items.filter { n -> allNotifications.none { it.id == n.id } })
allNotifications.sortByDescending { it.timestampMs }
bmlNextPage[loginId] = page + 1
bmlDone[loginId] = allNotifications.count { it.bank == "BML" && it.loginId == loginId } >= result.total
val allForLogin = allNotifications.filter { it.bank == "BML" && it.loginId == loginId }
NotificationsCache.saveBml(requireContext(), loginId, allForLogin)
}
}
val mibClient = MibActivityHistoryClient()
mibSessions.forEach { (loginId, session) ->
if (mibDone[loginId] == true) return@forEach
while (mibDone[loginId] != true && isAdded) {
val start = mibNextStart[loginId] ?: 101
val cachedIds = allNotifications
.filter { it.bank == "MIB" && it.loginId == loginId }
.map { it.id }.toSet()
val result = withContext(Dispatchers.IO) {
mibClient.fetchActivity(session, loginId, start, start + 99)
}
if (result.rawCount == 0) break
val readIds = NotificationsCache.getMibReadIds(requireContext())
val newItems = result.items
.filter { it.id !in cachedIds }
.map { it.copy(isRead = it.id in readIds) }
if (newItems.isNotEmpty()) {
allNotifications.addAll(newItems)
allNotifications.sortByDescending { it.timestampMs }
val allForLogin = allNotifications.filter { it.bank == "MIB" && it.loginId == loginId }
NotificationsCache.saveMib(requireContext(), loginId, allForLogin)
}
mibNextStart[loginId] = result.nextStart
mibDone[loginId] = result.nextStart > result.totalCount
if (newItems.isNotEmpty()) break
}
}
isLoadingMore = false
if (isAdded) {
setSpinner(false)
refreshAdapters()
}
}
}
// ── Mark all read ─────────────────────────────────────────────────────────────
private fun markAllRead() {
val bmlSessions = app.bmlSessions.toMap()
val mibIds = allNotifications.filter { it.bank == "MIB" && !it.isRead }.map { it.id }
lifecycleScope.launch {
var bmlOk = true
bmlSessions.forEach { (_, session) ->
val ok = withContext(Dispatchers.IO) { BmlNotificationsClient().markAllRead(session) }
if (!ok) bmlOk = false
}
if (mibIds.isNotEmpty()) NotificationsCache.addMibReadIds(requireContext(), mibIds)
val updated = allNotifications.map { it.copy(isRead = true) }
allNotifications.clear()
allNotifications.addAll(updated)
refreshAdapters()
broadcastUnread()
if (isAdded) {
val msg = if (bmlOk) "All notifications marked as read"
else "Marked read locally — some accounts had a network error"
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
private fun mergeInto(target: MutableList<AppNotification>, incoming: List<AppNotification>) {
val existingIds = target.map { it.id }.toSet()
target.addAll(incoming.filter { it.id !in existingIds })
target.sortByDescending { it.timestampMs }
}
private fun refreshAdapters() {
tabGroupFilters.forEachIndexed { i, filter ->
val filtered = if (filter == null) allNotifications
else allNotifications.filter { it.group == filter }
tabAdapters[i]?.update(filtered)
}
}
private fun broadcastUnread() {
onUnreadCountChanged?.invoke(allNotifications.any { !it.isRead })
}
private fun onNotificationTapped(item: AppNotification) {
val idx = allNotifications.indexOfFirst { it.id == item.id }
if (idx >= 0 && !allNotifications[idx].isRead) {
allNotifications[idx] = allNotifications[idx].copy(isRead = true)
if (item.bank == "MIB") NotificationsCache.addMibReadIds(requireContext(), listOf(item.id))
refreshAdapters()
broadcastUnread()
}
val detail = item.detailFields.joinToString("\n\n") { (k, v) -> "$k\n$v" }
MaterialAlertDialogBuilder(requireActivity())
.setTitle(item.title)
.setMessage(detail.ifBlank { item.message })
.setPositiveButton("OK", null)
.show()
}
// ── ViewPager2 page adapter ───────────────────────────────────────────────────
private inner class PageAdapter : RecyclerView.Adapter<PageAdapter.VH>() {
inner class VH(val rv: RecyclerView) : RecyclerView.ViewHolder(rv)
override fun getItemCount() = 3
override fun getItemViewType(position: Int) = position
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val rv = RecyclerView(parent.context).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
layoutManager = LinearLayoutManager(context)
adapter = tabAdapters[viewType]
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
val lm = rv.layoutManager as LinearLayoutManager
if (lm.findLastVisibleItemPosition() >= lm.itemCount - 4) loadMore()
}
})
}
return VH(rv)
}
override fun onBindViewHolder(holder: VH, position: Int) {}
}
// ── Per-tab list adapter ──────────────────────────────────────────────────────
private inner class NotifPageAdapter(private val groupFilter: String?) :
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val displayItems = mutableListOf<NotifListItem>()
var showLoadingSpinner: Boolean = false
set(value) {
if (field == value) return
field = value
if (displayItems.isEmpty()) {
notifyItemChanged(0)
} else if (value) {
notifyItemInserted(displayItems.size)
} else {
notifyItemRemoved(displayItems.size)
}
}
fun update(filtered: List<AppNotification>) {
val newItems = toGroupedList(filtered)
val diff = DiffUtil.calculateDiff(NotifDiff(displayItems.toList(), newItems))
displayItems.clear()
displayItems.addAll(newItems)
diff.dispatchUpdatesTo(this)
}
override fun getItemCount(): Int {
if (displayItems.isEmpty()) return 1
return displayItems.size + if (showLoadingSpinner) 1 else 0
}
override fun getItemViewType(position: Int): Int {
if (displayItems.isEmpty()) return if (showLoadingSpinner) 3 else 2
if (showLoadingSpinner && position == displayItems.size) return 3
return when (displayItems[position]) {
is NotifListItem.Header -> 0
is NotifListItem.Entry -> 1
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
when (viewType) {
0 -> HeaderVH(buildHeaderView(parent.context))
1 -> ItemVH(buildRowView(parent.context))
3 -> SpinnerVH(buildSpinnerView(parent.context))
else -> EmptyVH(buildEmptyView(parent.context))
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is HeaderVH -> holder.bind((displayItems[position] as NotifListItem.Header).label)
is ItemVH -> holder.bind((displayItems[position] as NotifListItem.Entry).n)
}
}
// ── Date header ───────────────────────────────────────────────────────────
inner class HeaderVH(private val tv: TextView) : RecyclerView.ViewHolder(tv) {
fun bind(label: String) { tv.text = label }
}
private fun buildHeaderView(ctx: android.content.Context): TextView {
val dp = ctx.resources.displayMetrics.density
return TextView(ctx).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
setTextColor(MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimary, Color.CYAN))
setPadding((16 * dp).toInt(), (20 * dp).toInt(), (16 * dp).toInt(), (6 * dp).toInt())
}
}
// ── Empty state ───────────────────────────────────────────────────────────
inner class EmptyVH(v: View) : RecyclerView.ViewHolder(v)
private fun buildEmptyView(ctx: android.content.Context): View {
val dp = ctx.resources.displayMetrics.density
return LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
(300 * dp).toInt()
)
addView(ImageView(ctx).apply {
setImageResource(R.drawable.ic_bell_read)
val s = (48 * dp).toInt()
layoutParams = LinearLayout.LayoutParams(s, s).apply {
gravity = Gravity.CENTER_HORIZONTAL
bottomMargin = (12 * dp).toInt()
}
alpha = 0.35f
})
addView(TextView(ctx).apply {
text = "No notifications"
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
alpha = 0.5f
gravity = Gravity.CENTER
})
}
}
// ── Loading spinner ───────────────────────────────────────────────────────
inner class SpinnerVH(v: View) : RecyclerView.ViewHolder(v)
private fun buildSpinnerView(ctx: android.content.Context): View {
val dp = ctx.resources.displayMetrics.density
val pad = (16 * dp).toInt()
val size = (28 * dp).toInt()
return LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
setPadding(pad, pad, pad, pad)
addView(ProgressBar(ctx).apply {
layoutParams = LinearLayout.LayoutParams(size, size)
})
}
}
// ── Notification row ──────────────────────────────────────────────────────
inner class ItemVH(v: View) : RecyclerView.ViewHolder(v) {
val iconBg: View = v.findViewWithTag("iconBg")
val iconIv: ImageView = v.findViewWithTag("icon")
val unreadBadge: View = v.findViewWithTag("badge")
val titleTv: TextView = v.findViewWithTag("title")
val messageTv: TextView = v.findViewWithTag("message")
val bankBadge: TextView = v.findViewWithTag("bank")
fun bind(item: AppNotification) {
titleTv.text = item.title
messageTv.text = item.message
bankBadge.text = item.bank
unreadBadge.isVisible = !item.isRead
val (iconRes, colorHex) = iconAndColor(item)
iconIv.setImageResource(iconRes)
iconIv.imageTintList = ColorStateList.valueOf(Color.parseColor(colorHex))
(iconBg.background as? GradientDrawable)
?.setColor(Color.parseColor(colorHex.replace("#", "#22")))
itemView.alpha = if (item.isRead) 0.65f else 1f
itemView.setOnClickListener { onNotificationTapped(item) }
}
}
private fun buildRowView(ctx: android.content.Context): View {
val dp = ctx.resources.displayMetrics.density
val surfaceColor = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSurface, Color.BLACK)
return LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
background = ta.getDrawable(0); ta.recycle()
isClickable = true; isFocusable = true
setPadding((16 * dp).toInt(), (12 * dp).toInt(), (16 * dp).toInt(), (12 * dp).toInt())
// Icon circle + badge overlay
val frameSize = (44 * dp).toInt()
val iconFrame = FrameLayout(ctx).apply {
layoutParams = LinearLayout.LayoutParams(frameSize, frameSize).apply {
marginEnd = (12 * dp).toInt()
}
}
// Circle background (fills the frame)
val circleSize = (40 * dp).toInt()
iconFrame.addView(View(ctx).apply {
tag = "iconBg"
layoutParams = FrameLayout.LayoutParams(circleSize, circleSize, Gravity.CENTER)
background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(Color.parseColor("#33FFFFFF"))
}
})
// Icon
val iconSize = (22 * dp).toInt()
iconFrame.addView(ImageView(ctx).apply {
tag = "icon"
layoutParams = FrameLayout.LayoutParams(iconSize, iconSize, Gravity.CENTER)
})
// Unread badge — bottom-right corner
val badgeSize = (12 * dp).toInt()
iconFrame.addView(View(ctx).apply {
tag = "badge"
layoutParams = FrameLayout.LayoutParams(badgeSize, badgeSize, Gravity.BOTTOM or Gravity.END)
background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(Color.parseColor("#EF5350"))
setStroke((2 * dp).toInt(), surfaceColor)
}
})
addView(iconFrame)
// Text column
val textCol = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
}
// Title + bank badge row
val titleRow = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
)
}
titleRow.addView(TextView(ctx).apply {
tag = "title"
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
setTypeface(null, Typeface.BOLD)
maxLines = 1
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
})
titleRow.addView(TextView(ctx).apply {
tag = "bank"
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelSmall)
alpha = 0.55f
layoutParams = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
).apply { marginStart = (6 * dp).toInt() }
})
textCol.addView(titleRow)
textCol.addView(TextView(ctx).apply {
tag = "message"
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
alpha = 0.7f
maxLines = 2
})
addView(textCol)
}
}
private fun iconAndColor(item: AppNotification): Pair<Int, String> {
if (item.bank == "MIB") return when {
item.title.contains("Transfer", ignoreCase = true) ||
item.title.contains("Payment", ignoreCase = true) -> R.drawable.ic_send to "#4CAF50"
item.title.contains("Log in", ignoreCase = true) -> R.drawable.ic_lock_open to "#2196F3"
else -> R.drawable.ic_receipt_check to "#9C27B0"
}
return when {
item.group == "INFORMATION" -> R.drawable.ic_receipt_check to "#2196F3"
item.title.contains("Received", ignoreCase = true) ||
item.title.contains("Sent", ignoreCase = true) ||
item.title.contains("Transfer", ignoreCase = true) ||
item.title.contains("Payment", ignoreCase = true) ||
item.title.contains("Paid", ignoreCase = true) ||
item.title.contains("Funds", ignoreCase = true) -> R.drawable.ic_send to "#4CAF50"
else -> R.drawable.ic_lock to "#EF5350"
}
}
}
}
@@ -7,13 +7,10 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import android.app.Activity
import android.content.Intent
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.* import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
@@ -39,7 +36,6 @@ import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.util.AccountListParser 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.BmlCardParser
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
import java.io.File import java.io.File
@@ -56,31 +52,6 @@ class PayMvQrFragment : Fragment() {
private var generateJob: Job? = null private var generateJob: Job? = null
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>() 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( override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View { ): View {
@@ -105,9 +76,6 @@ class PayMvQrFragment : Fragment() {
binding.btnSave.isEnabled = false binding.btnSave.isEnabled = false
binding.btnShare.setOnClickListener { shareQr() } binding.btnShare.setOnClickListener { shareQr() }
binding.btnSave.setOnClickListener { saveQr() } binding.btnSave.setOnClickListener { saveQr() }
binding.btnScanQr.setOnClickListener {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
} }
private fun setupDropdown() { private fun setupDropdown() {
@@ -124,6 +92,20 @@ class PayMvQrFragment : Fragment() {
selectedAccount = picked selectedAccount = picked
scheduleGenerate() 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()
}
}
}
} }
} }
@@ -3,6 +3,7 @@ package sh.sar.basedbank.ui.home
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import androidx.activity.result.contract.ActivityResultContracts
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import android.os.Bundle import android.os.Bundle
@@ -13,7 +14,6 @@ import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import android.view.animation.AccelerateInterpolator import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator import android.view.animation.DecelerateInterpolator
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
@@ -24,13 +24,39 @@ import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView 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 com.google.android.material.color.MaterialColors
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlCardClient
import sh.sar.basedbank.api.bml.BmlTapToPayClient
import sh.sar.basedbank.api.mib.MibCardsClient
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
import sh.sar.basedbank.api.mib.MibCard import sh.sar.basedbank.api.mib.MibCard
import android.text.InputType
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.databinding.FragmentCardsBinding import sh.sar.basedbank.databinding.FragmentCardsBinding
import sh.sar.basedbank.util.CardsCache import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.Totp
import sh.sar.basedbank.util.bmlapi.BmlCardParser import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.NfcPaymentUtil
import sh.sar.basedbank.util.PaymvQrParser
import kotlin.math.abs import kotlin.math.abs
class CardsFragment : Fragment() { class CardsFragment : Fragment() {
@@ -42,8 +68,42 @@ class CardsFragment : Fragment() {
private var cards: List<CardItem> = emptyList() private var cards: List<CardItem> = emptyList()
private var currentCardPosition: Int = 0 private var currentCardPosition: Int = 0
private var cardWidth: Int = 0 private var cardWidth: Int = 0
private var pendingQrAccountNumber: String? = null private var pendingQrCardNumber: String? = null
private var isManageMode: Boolean = false private var isManageMode: Boolean = false
private var managedCardKey: String? = null
private var freezeInFlight: Boolean = false
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) {
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
)
} 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()
}
}
}
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 // Carousel snapshot captured on enter, used to reverse the exit animation
private var carouselCardLayoutTop = 0f // card layout top relative to contentLayout private var carouselCardLayoutTop = 0f // card layout top relative to contentLayout
@@ -57,20 +117,6 @@ class CardsFragment : Fragment() {
private lateinit var stackAdapter: CardStackAdapter private lateinit var stackAdapter: CardStackAdapter
private val store by lazy { CredentialStore(requireContext()) } private val store by lazy { CredentialStore(requireContext()) }
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
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, pendingQrAccountNumber)
)
} else {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
}
pendingQrAccountNumber = null
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCardsBinding.inflate(inflater, container, false) _binding = FragmentCardsBinding.inflate(inflater, container, false)
return binding.root return binding.root
@@ -109,17 +155,22 @@ class CardsFragment : Fragment() {
} }
}) })
ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets -> ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
.getBoolean("bottom_nav", false) val navBarBottom = if (bottomNav?.visibility == View.VISIBLE) 0
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars()) else insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
val extraBottom = if (isBottomNav) 0 else navBar.bottom v.setPadding(0, 0, 0, (16 * resources.displayMetrics.density).toInt() + navBarBottom)
v.setPadding(0, 0, 0, (16 * resources.displayMetrics.density).toInt() + extraBottom)
insets insets
} }
viewModel.mibCards.observe(viewLifecycleOwner) { rebuildCards() } viewModel.mibCards.observe(viewLifecycleOwner) {
viewModel.accounts.observe(viewLifecycleOwner) { rebuildCards() } rebuildCards()
rebindManagedCardIfNeeded()
}
viewModel.accounts.observe(viewLifecycleOwner) {
rebuildCards()
rebindManagedCardIfNeeded()
}
val cached = CardsCache.load(requireContext()) val cached = CardsCache.load(requireContext())
if (cached.isNotEmpty()) { if (cached.isNotEmpty()) {
@@ -135,7 +186,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
// Swipe-down on the manage card to dismiss manage mode // Swipe-down on the manage card to dismiss manage mode
binding.manageCardView.root.setOnTouchListener { _, event -> binding.manageCardView.root.setOnTouchListener { _, event ->
if (!isManageMode) return@setOnTouchListener false if (!isManageMode && !isTapMode) return@setOnTouchListener false
val mgr = binding.manageCardView.root val mgr = binding.manageCardView.root
when (event.action) { when (event.action) {
android.view.MotionEvent.ACTION_DOWN -> { android.view.MotionEvent.ACTION_DOWN -> {
@@ -162,7 +213,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
val dy = (event.rawY - swipeDragStartRawY).coerceAtLeast(0f) val dy = (event.rawY - swipeDragStartRawY).coerceAtLeast(0f)
swipeIsDragging = false swipeIsDragging = false
if (dy > 130f) { if (dy > 130f) {
setManageMode(false) if (isTapMode) setTapMode(false) else setManageMode(false)
} else { } else {
// Snap back // Snap back
mgr.animate().translationY(0f).scaleX(1f).scaleY(1f) mgr.animate().translationY(0f).scaleX(1f).scaleY(1f)
@@ -182,7 +233,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
if (item is CardItem.Mib) { if (item is CardItem.Mib) {
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
} else { } else {
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber pendingQrCardNumber = (item as CardItem.Bml).account.accountNumber
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java)) qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
} }
} }
@@ -191,28 +242,180 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
binding.btnTapToPay.isEnabled = nfcAvailable binding.btnTapToPay.isEnabled = nfcAvailable
binding.btnTapToPay.setOnClickListener { binding.btnTapToPay.setOnClickListener {
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
val msg = if (item is CardItem.Mib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress if (item is CardItem.Mib) {
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
val bmlItem = item as CardItem.Bml
NfcPaymentUtil.checkAndProceed(requireContext()) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
showBiometricPromptForTap(bmlItem)
} else {
setTapMode(true, bmlItem)
}
}
} }
val wip = View.OnClickListener { val wip = View.OnClickListener {
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
} }
binding.btnChangePin.setOnClickListener(wip) binding.btnChangePin.setOnClickListener(wip)
binding.btnFreeze.setOnClickListener(wip) binding.btnFreeze.setOnClickListener {
when (val item = cards.getOrNull(currentCardPosition)) {
is CardItem.Bml -> confirmBmlFreezeToggle(item)
is CardItem.Mib -> confirmMibFreezeToggle(item)
null -> {}
}
}
binding.btnBlock.setOnClickListener(wip) binding.btnBlock.setOnClickListener(wip)
} }
private fun confirmBmlFreezeToggle(item: CardItem.Bml) {
if (freezeInFlight) return
val frozen = isBmlFrozen(item.account.statusDesc)
val titleRes = if (frozen) R.string.card_unfreeze_confirm_title else R.string.card_freeze_confirm_title
val messageRes = if (frozen) R.string.card_unfreeze_confirm_message else R.string.card_freeze_confirm_message
val confirmRes = if (frozen) R.string.card_action_unfreeze else R.string.card_action_freeze
MaterialAlertDialogBuilder(requireContext())
.setTitle(titleRes)
.setMessage(messageRes)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(confirmRes) { _, _ -> performBmlFreezeToggle(item, freeze = !frozen) }
.show()
}
private fun confirmMibFreezeToggle(item: CardItem.Mib) {
if (freezeInFlight) return
val frozen = isMibCardFrozen(item.card.cardStatus)
val titleRes = if (frozen) R.string.card_unfreeze_confirm_title else R.string.card_freeze_confirm_title
val messageRes = if (frozen) R.string.card_unfreeze_confirm_message else R.string.card_freeze_confirm_message
val confirmRes = if (frozen) R.string.card_action_unfreeze else R.string.card_action_freeze
val ctx = requireContext()
val dp = resources.displayMetrics.density
val inputLayout = TextInputLayout(ctx).apply {
hint = getString(R.string.card_freeze_comments_hint)
val pad = (16 * dp).toInt()
setPadding(pad, pad / 2, pad, 0)
}
val input = TextInputEditText(ctx).apply {
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
maxLines = 3
}
inputLayout.addView(input)
MaterialAlertDialogBuilder(ctx)
.setTitle(titleRes)
.setMessage(messageRes)
.setView(inputLayout)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(confirmRes) { _, _ ->
val comments = input.text?.toString()?.trim().orEmpty()
performMibFreezeToggle(item, freeze = !frozen, comments = comments)
}
.show()
}
private fun performMibFreezeToggle(item: CardItem.Mib, freeze: Boolean, comments: String) {
val app = requireActivity().application as BasedBankApp
val action = if (freeze) "freeze" else "unfreeze"
val successRes = if (freeze) R.string.card_freeze_success else R.string.card_unfreeze_success
val loginId = item.card.loginTag.removePrefix("mib_")
val session = app.mibSessions[loginId]
if (session == null) {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val ownerProfile = profiles.firstOrNull { it.profileId == item.card.profileId }
?: profiles.firstOrNull { it.customerId == item.card.customerId }
freezeInFlight = true
binding.btnFreeze.isEnabled = false
(activity as? HomeActivity)?.setRefreshing(true)
viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
runCatching {
app.mibMutex.withLock {
if (ownerProfile != null) {
app.mibFlowFor(loginId).switchProfile(session, ownerProfile)
}
MibCardsClient().setCardFreezeState(session, item.card.cardId, action, comments)
}
}
}
freezeInFlight = false
if (!isAdded || _binding == null) {
(activity as? HomeActivity)?.setRefreshing(false)
return@launch
}
binding.btnFreeze.isEnabled = true
(activity as? HomeActivity)?.setRefreshing(false)
val response = result.getOrNull()
if (response?.success == true) {
Toast.makeText(requireContext(), successRes, Toast.LENGTH_SHORT).show()
(activity as? HomeActivity)?.triggerRefreshCards()
} else {
val msg = response?.message?.takeIf { it.isNotBlank() }
?: result.exceptionOrNull()?.message
?: getString(R.string.card_freeze_failed)
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
}
}
}
private fun performBmlFreezeToggle(item: CardItem.Bml, freeze: Boolean) {
val app = requireActivity().application as BasedBankApp
val action = if (freeze) "freeze" else "unfreeze"
val successRes = if (freeze) R.string.card_freeze_success else R.string.card_unfreeze_success
freezeInFlight = true
binding.btnFreeze.isEnabled = false
(activity as? HomeActivity)?.setRefreshing(true)
viewLifecycleOwner.lifecycleScope.launch {
val session = app.bmlSessionFor(item.account)
if (session == null) {
freezeInFlight = false
if (_binding != null) binding.btnFreeze.isEnabled = true
(activity as? HomeActivity)?.setRefreshing(false)
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return@launch
}
val result = withContext(Dispatchers.IO) {
runCatching { BmlCardClient().setCardFreezeState(session, item.account.internalId, action) }
}
freezeInFlight = false
if (!isAdded || _binding == null) {
(activity as? HomeActivity)?.setRefreshing(false)
return@launch
}
binding.btnFreeze.isEnabled = true
(activity as? HomeActivity)?.setRefreshing(false)
val response = result.getOrNull()
if (response?.success == true) {
Toast.makeText(requireContext(), successRes, Toast.LENGTH_SHORT).show()
(activity as? HomeActivity)?.refreshBalances(item.account)
} else {
val msg = response?.message?.takeIf { it.isNotBlank() }
?: result.exceptionOrNull()?.message
?: getString(R.string.card_freeze_failed)
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
}
}
}
private fun setManageMode(enabled: Boolean) { private fun setManageMode(enabled: Boolean) {
isManageMode = enabled isManageMode = enabled
if (!enabled) managedCardKey = null
requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card) requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card)
if (enabled) enterManageMode() else exitManageMode() if (enabled) enterManageMode() else exitManageMode()
} }
private fun enterManageMode() { private fun cardItemKey(item: CardItem): String = when (item) {
val item = cards.getOrNull(currentCardPosition) ?: return is CardItem.Bml -> "bml:${item.account.accountNumber}"
is CardItem.Mib -> "mib:${item.card.cardId}"
}
// Bind card data private fun bindManageCardData(item: CardItem) {
val cv = binding.manageCardView val cv = binding.manageCardView
when (item) { when (item) {
is CardItem.Mib -> { is CardItem.Mib -> {
@@ -222,7 +425,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
if (assetPath != null) loadCardImage(cv.ivCardImage, assetPath) if (assetPath != null) loadCardImage(cv.ivCardImage, assetPath)
else cv.ivCardImage.setImageDrawable(null) else cv.ivCardImage.setImageDrawable(null)
bindCardStatus(cv.tvCardStatus, mibCardStatusLabel(item.card.cardStatus)) bindCardStatus(cv.tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
cv.root.alpha = 1f cv.root.alpha = if (isMibCardActive(item.card.cardStatus)) 1f else 0.45f
} }
is CardItem.Bml -> { is CardItem.Bml -> {
cv.tvCardOwner.text = item.account.accountBriefName cv.tvCardOwner.text = item.account.accountBriefName
@@ -233,6 +436,37 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
cv.root.alpha = if (isActive) 1f else 0.45f cv.root.alpha = if (isActive) 1f else 0.45f
} }
} }
val isFrozen = when (item) {
is CardItem.Bml -> isBmlFrozen(item.account.statusDesc)
is CardItem.Mib -> isMibCardFrozen(item.card.cardStatus)
}
binding.btnFreeze.setText(if (isFrozen) R.string.card_action_unfreeze else R.string.card_action_freeze)
// MIB doesn't allow change PIN / block while a card is frozen; BML still does.
val mibFrozen = item is CardItem.Mib && isMibCardFrozen(item.card.cardStatus)
binding.btnChangePin.isEnabled = !mibFrozen
binding.btnBlock.isEnabled = !mibFrozen
}
private fun rebindManagedCardIfNeeded() {
if (!isManageMode) return
val key = managedCardKey ?: return
val newPos = cards.indexOfFirst { cardItemKey(it) == key }
if (newPos < 0) return
if (newPos != currentCardPosition) {
currentCardPosition = newPos
binding.rvCards.scrollToPosition(newPos)
}
bindManageCardData(cards[newPos])
}
private fun isBmlFrozen(statusDesc: String): Boolean =
statusDesc.equals("Block Plastic", ignoreCase = true)
private fun enterManageMode() {
val item = cards.getOrNull(currentCardPosition) ?: return
managedCardKey = cardItemKey(item)
bindManageCardData(item)
// Capture positions BEFORE layout changes (for enter animation + exit animation later) // Capture positions BEFORE layout changes (for enter animation + exit animation later)
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) } val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
@@ -253,6 +487,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
binding.llPayButtons.visibility = View.GONE binding.llPayButtons.visibility = View.GONE
binding.llManageButtons.visibility = View.VISIBLE binding.llManageButtons.visibility = View.VISIBLE
binding.llDefaultCardRow.visibility = View.VISIBLE binding.llDefaultCardRow.visibility = View.VISIBLE
binding.llHideDashboardRow.visibility = View.VISIBLE
binding.manageCardView.root.visibility = View.VISIBLE binding.manageCardView.root.visibility = View.VISIBLE
// Set switch state (clear listener first to avoid triggering on programmatic set) // Set switch state (clear listener first to avoid triggering on programmatic set)
@@ -273,6 +508,27 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
} }
} }
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)
}
}
val isInactiveBml = item is CardItem.Bml && !item.account.statusDesc.equals("Active", ignoreCase = true)
binding.switchDefaultCard.isEnabled = !isInactiveBml
if (isInactiveBml) {
binding.switchHideFromDashboard.setOnCheckedChangeListener(null)
binding.switchHideFromDashboard.isChecked = true
binding.switchHideFromDashboard.isEnabled = false
} else {
binding.switchHideFromDashboard.isEnabled = true
}
// After layout pass, compute offsets, save carousel snapshot, and animate // After layout pass, compute offsets, save carousel snapshot, and animate
binding.contentLayout.doOnNextLayout { binding.contentLayout.doOnNextLayout {
val mgr = binding.manageCardView.root val mgr = binding.manageCardView.root
@@ -364,7 +620,9 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
binding.llPayButtons.visibility = View.VISIBLE binding.llPayButtons.visibility = View.VISIBLE
binding.llManageButtons.visibility = View.GONE binding.llManageButtons.visibility = View.GONE
binding.llDefaultCardRow.visibility = View.GONE binding.llDefaultCardRow.visibility = View.GONE
binding.llHideDashboardRow.visibility = View.GONE
binding.switchDefaultCard.setOnCheckedChangeListener(null) binding.switchDefaultCard.setOnCheckedChangeListener(null)
binding.switchHideFromDashboard.setOnCheckedChangeListener(null)
buildDots(cards.size, currentCardPosition) buildDots(cards.size, currentCardPosition)
} }
.start() .start()
@@ -377,6 +635,262 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
.start() .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 = (layoutInflater.inflate(R.layout.view_cancel_button, null, false) as MaterialButton).apply {
setOnClickListener { setTapMode(false) }
}
val colorOutlineVariant = MaterialColors.getColor(
requireContext(), com.google.android.material.R.attr.colorOutlineVariant, android.graphics.Color.LTGRAY
)
val tapDivider = View(requireContext()).apply {
setBackgroundColor(colorOutlineVariant)
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, dp.toInt().coerceAtLeast(1)
).also {
it.marginStart = (24 * dp).toInt()
it.marginEnd = (24 * dp).toInt()
it.bottomMargin = (4 * dp).toInt()
}
}
val baseCancelPaddingBottom = (24 * dp).toInt()
val cancelWrapper = LinearLayout(requireContext()).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
setPadding((16 * dp).toInt(), (8 * dp).toInt(), (16 * dp).toInt(), baseCancelPaddingBottom)
addView(cancelBtn, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT))
}
ViewCompat.setOnApplyWindowInsetsListener(cancelWrapper) { v, insets ->
val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
val navBarBottom = if (bottomNav?.visibility == View.VISIBLE) 0
else insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
v.setPadding((16 * dp).toInt(), (8 * dp).toInt(), (16 * dp).toInt(), baseCancelPaddingBottom + navBarBottom)
insets
}
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(tapDivider)
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()
(activity as? HomeActivity)?.triggerRefresh()
}
}
}
}
}
private fun rebuildCards() { private fun rebuildCards() {
// Remember which card is currently selected by identity so we can restore position after reorder // Remember which card is currently selected by identity so we can restore position after reorder
val currentCard = cards.getOrNull(currentCardPosition) val currentCard = cards.getOrNull(currentCardPosition)
@@ -386,7 +900,11 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
val bmlItems = (viewModel.accounts.value ?: emptyList()) val bmlItems = (viewModel.accounts.value ?: emptyList())
.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" } .filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
.map { CardItem.Bml(it) } .map { CardItem.Bml(it) }
val all: List<CardItem> = mibItems + bmlItems val bmlActive = bmlItems.filter { it.account.statusDesc.equals("Active", ignoreCase = true) }
val bmlInactive = bmlItems.filter { !it.account.statusDesc.equals("Active", ignoreCase = true) }
val mibActive = mibItems.filter { isMibCardActive(it.card.cardStatus) }
val mibInactive = mibItems.filter { !isMibCardActive(it.card.cardStatus) }
val all: List<CardItem> = bmlActive + mibActive + bmlInactive + mibInactive
// Move default BML card to front // Move default BML card to front
cards = if (defaultNum != null) { cards = if (defaultNum != null) {
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum } val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
@@ -411,6 +929,32 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
buildDots(cards.size, currentCardPosition) buildDots(cards.size, currentCardPosition)
updateCardInfo(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)
}
NfcPaymentUtil.checkAndProceed(requireContext()) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
showBiometricPromptForTap(targetCard)
} else {
setTapMode(true, targetCard)
}
}
}
}
} }
private fun applyCardScales() { private fun applyCardScales() {
@@ -432,7 +976,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
} }
private fun buildDots(count: Int, selected: Int) { private fun buildDots(count: Int, selected: Int) {
if (isManageMode) return if (isManageMode || isTapMode) return
binding.pageIndicator.removeAllViews() binding.pageIndicator.removeAllViews()
if (count <= 1) { if (count <= 1) {
binding.pageIndicator.visibility = View.GONE binding.pageIndicator.visibility = View.GONE
@@ -465,9 +1009,17 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
is CardItem.Mib -> item.card.cardTypeDesc is CardItem.Mib -> item.card.cardTypeDesc
is CardItem.Bml -> item.account.accountTypeName is CardItem.Bml -> item.account.accountTypeName
} }
val isInactiveBml = item is CardItem.Bml && !item.account.statusDesc.equals("Active", ignoreCase = true)
val nfcAvailable = android.nfc.NfcAdapter.getDefaultAdapter(requireContext()) != null
binding.btnTapToPay.isEnabled = !isInactiveBml && nfcAvailable
binding.btnScanToPay.isEnabled = !isInactiveBml
} }
fun onBackPressed(): Boolean { fun onBackPressed(): Boolean {
if (isTapMode) {
setTapMode(false)
return true
}
if (isManageMode) { if (isManageMode) {
setManageMode(false) setManageMode(false)
return true return true
@@ -475,12 +1027,28 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
return false return false
} }
override fun onPause() {
super.onPause()
if (isTapMode) {
BmlHostCardEmulatorService.clearToken()
BmlHostCardEmulatorService.onTransactionComplete = null
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
requireActivity().title = getString(R.string.nav_pay_with_card) requireActivity().title = getString(when {
isTapMode -> R.string.card_pay_nfc
isManageMode -> R.string.card_manage
else -> R.string.nav_pay_with_card
})
} }
override fun onDestroyView() { override fun onDestroyView() {
tapAnimView?.stopAnimation()
tapAnimView = null
BmlHostCardEmulatorService.clearToken()
BmlHostCardEmulatorService.onTransactionComplete = null
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null
} }
@@ -527,7 +1095,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
if (assetPath != null) loadCardImage(ivCardImage, assetPath) if (assetPath != null) loadCardImage(ivCardImage, assetPath)
else ivCardImage.setImageDrawable(null) else ivCardImage.setImageDrawable(null)
bindCardStatus(tvCardStatus, mibCardStatusLabel(item.card.cardStatus)) bindCardStatus(tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
itemView.alpha = 1f itemView.alpha = if (isMibCardActive(item.card.cardStatus)) 1f else 0.45f
} }
is CardItem.Bml -> { is CardItem.Bml -> {
tvCardOwner.text = item.account.accountBriefName tvCardOwner.text = item.account.accountBriefName
@@ -542,7 +1110,103 @@ 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 { 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) { fun cardImageAsset(card: MibCard): String? = when (card.cardType) {
"51" -> "cards/mib/faisa_card.png" "51" -> "cards/mib/faisa_card.png"
"53" -> "cards/mib/visa_black_platinum.png" "53" -> "cards/mib/visa_black_platinum.png"
@@ -566,9 +1230,13 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
fun mibCardStatusLabel(cardStatus: String): String? = when (cardStatus) { fun mibCardStatusLabel(cardStatus: String): String? = when (cardStatus) {
"CHST0" -> null "CHST0" -> null
"CHST20" -> "Temporary blocked by client"
else -> cardStatus else -> cardStatus
} }
fun isMibCardActive(cardStatus: String): Boolean = cardStatus == "CHST0"
fun isMibCardFrozen(cardStatus: String): Boolean = cardStatus == "CHST20"
fun bindCardStatus(tv: TextView, statusLabel: String?) { fun bindCardStatus(tv: TextView, statusLabel: String?) {
if (statusLabel == null) { tv.visibility = View.GONE; return } if (statusLabel == null) { tv.visibility = View.GONE; return }
tv.visibility = View.VISIBLE tv.visibility = View.VISIBLE
@@ -106,6 +106,8 @@ class QrScannerActivity : AppCompatActivity() {
} }
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
window.statusBarColor = android.graphics.Color.TRANSPARENT
window.navigationBarColor = android.graphics.Color.TRANSPARENT
binding = ActivityQrScannerBinding.inflate(layoutInflater) binding = ActivityQrScannerBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
// Black camera background — always use light (white) system bar icons // Black camera background — always use light (white) system bar icons
@@ -0,0 +1,80 @@
package sh.sar.basedbank.ui.home
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import sh.sar.basedbank.BuildConfig
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentSettingsAboutBinding
class SettingsAboutFragment : Fragment() {
private var _binding: FragmentSettingsAboutBinding? = null
private val binding get() = _binding!!
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsAboutBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
val basePaddingBottom = binding.root.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
insets
}
binding.tvAppName.text = getString(R.string.app_name)
binding.tvVersion.text = getString(R.string.about_version, BuildConfig.VERSION_NAME)
binding.rowMibTerms.setOnClickListener { openUrl("https://faisanet.mib.com.mv/terms") }
binding.rowBmlTerms.setOnClickListener { openUrl("https://www.bankofmaldives.com.mv/storage/file/121/10289/terms-conditions-online-banking-en.pdf") }
binding.rowFahipayTerms.setOnClickListener { openUrl("https://fahipay.mv/tos/") }
val hasMvr = BuildConfig.ACCOUNT_MVR.isNotEmpty()
val hasUsd = BuildConfig.ACCOUNT_USD.isNotEmpty()
if (!hasMvr && !hasUsd) {
binding.sectionDonate.visibility = View.GONE
} else {
if (!hasMvr) binding.btnDonateMvr.visibility = View.GONE
else binding.btnDonateMvr.setOnClickListener { openDonate(BuildConfig.ACCOUNT_MVR) }
if (!hasUsd) binding.btnDonateUsd.visibility = View.GONE
else binding.btnDonateUsd.setOnClickListener { openDonate(BuildConfig.ACCOUNT_USD) }
}
}
private fun openUrl(url: String) {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}
private fun openDonate(accountNumber: String) {
val fragment = TransferFragment.newInstance(
accountNumber = accountNumber,
displayName = getString(R.string.app_name),
subtitle = accountNumber,
colorHex = "#607D8B",
imageHash = null
)
(requireActivity() as HomeActivity).showWithBackStack(fragment)
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.settings_about)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
@@ -2,6 +2,7 @@ package sh.sar.basedbank.ui.home
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.res.Configuration
import android.graphics.Color import android.graphics.Color
import android.os.Bundle import android.os.Bundle
import android.text.InputType import android.text.InputType
@@ -16,6 +17,8 @@ import android.widget.Toast
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
import androidx.core.os.LocaleListCompat import androidx.core.os.LocaleListCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
@@ -36,8 +39,10 @@ class SettingsAppearanceFragment : Fragment() {
private lateinit var prefs: SharedPreferences private lateinit var prefs: SharedPreferences
private val slots = mutableListOf<Int>() private val slots = mutableListOf<Int>()
private val quickActions = mutableListOf<Int>() private val quickActions = mutableListOf<Int>()
private val circularSlots = mutableListOf<Int>()
private lateinit var slotAdapter: NavItemAdapter private lateinit var slotAdapter: NavItemAdapter
private lateinit var quickActionAdapter: NavItemAdapter private lateinit var quickActionAdapter: NavItemAdapter
private lateinit var circularSlotAdapter: NavItemAdapter
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false) _binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false)
@@ -46,13 +51,30 @@ class SettingsAppearanceFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
val basePaddingBottom = binding.root.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
val isBottomNav = prefs.getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
insets
}
// Navigation mode // Navigation mode
val isBottom = prefs.getBoolean("bottom_nav", false) val currentMode = NavCustomization.getNavMode(prefs)
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer) binding.navModeToggle.check(when (currentMode) {
NavCustomization.NAV_MODE_BOTTOM -> R.id.btnNavBottom
NavCustomization.NAV_MODE_CIRCULAR -> R.id.btnNavCircular
else -> R.id.btnNavDrawer
})
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked -> binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener if (!isChecked) return@addOnButtonCheckedListener
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply() val mode = when (checkedId) {
R.id.btnNavBottom -> NavCustomization.NAV_MODE_BOTTOM
R.id.btnNavCircular -> NavCustomization.NAV_MODE_CIRCULAR
else -> NavCustomization.NAV_MODE_DRAWER
}
NavCustomization.saveNavMode(prefs, mode)
(activity as? HomeActivity)?.applyNavMode() (activity as? HomeActivity)?.applyNavMode()
updateShortcutsVisibility() updateShortcutsVisibility()
} }
@@ -63,10 +85,22 @@ class SettingsAppearanceFragment : Fragment() {
quickActionAdapter = NavItemAdapter( quickActionAdapter = NavItemAdapter(
items = quickActions, items = quickActions,
onSave = { NavCustomization.saveQuickActions(prefs, quickActions) }, onSave = { NavCustomization.saveQuickActions(prefs, quickActions) },
isEnabled = { !prefs.getBoolean("bottom_nav", false) } isEnabled = { NavCustomization.getNavMode(prefs) != NavCustomization.NAV_MODE_BOTTOM }
) )
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions) { setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions) {
!prefs.getBoolean("bottom_nav", false) NavCustomization.getNavMode(prefs) != NavCustomization.NAV_MODE_BOTTOM
}
// Circular nav shortcuts
circularSlots.clear()
circularSlots.addAll(NavCustomization.getCircularSlots(prefs))
circularSlotAdapter = NavItemAdapter(
items = circularSlots,
onSave = { NavCustomization.saveCircularSlots(prefs, circularSlots) },
isEnabled = { NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_CIRCULAR }
)
setupNavItemRecyclerView(binding.rvCircularSlots, circularSlotAdapter, circularSlots) {
NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_CIRCULAR
} }
// Bottom bar shortcuts // Bottom bar shortcuts
@@ -78,10 +112,10 @@ class SettingsAppearanceFragment : Fragment() {
NavCustomization.saveSlots(prefs, slots) NavCustomization.saveSlots(prefs, slots)
(activity as? HomeActivity)?.rebuildBottomNav(prefs) (activity as? HomeActivity)?.rebuildBottomNav(prefs)
}, },
isEnabled = { prefs.getBoolean("bottom_nav", false) } isEnabled = { NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM }
) )
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) { setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) {
prefs.getBoolean("bottom_nav", false) NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM
} }
// Show labels toggle // Show labels toggle
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true) val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
@@ -102,6 +136,7 @@ class SettingsAppearanceFragment : Fragment() {
}) })
binding.themeToggle.addOnButtonCheckedListener { _, checkedId, isChecked -> binding.themeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener if (!isChecked) return@addOnButtonCheckedListener
val previousKey = prefs.getString("theme", "system")
val (key, mode) = when (checkedId) { val (key, mode) = when (checkedId) {
R.id.btnThemeLight -> "light" to AppCompatDelegate.MODE_NIGHT_NO R.id.btnThemeLight -> "light" to AppCompatDelegate.MODE_NIGHT_NO
R.id.btnThemeDark -> "dark" to AppCompatDelegate.MODE_NIGHT_YES R.id.btnThemeDark -> "dark" to AppCompatDelegate.MODE_NIGHT_YES
@@ -111,6 +146,16 @@ class SettingsAppearanceFragment : Fragment() {
AppCompatDelegate.setDefaultNightMode(mode) AppCompatDelegate.setDefaultNightMode(mode)
updateAccentState(key == "system") updateAccentState(key == "system")
updatePitchBlackState(key == "dark") updatePitchBlackState(key == "dark")
if (key == "system") {
requireActivity().recreate()
} else if (previousKey == "system") {
// setDefaultNightMode only recreates if the effective mode changes.
// If system was already dark and we switch to dark (or light→light),
// no recreation is triggered and the custom accent never gets applied.
val currentIsNight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
val newIsNight = mode == AppCompatDelegate.MODE_NIGHT_YES
if (currentIsNight == newIsNight) requireActivity().recreate()
}
} }
// Pitch black // Pitch black
@@ -125,7 +170,7 @@ class SettingsAppearanceFragment : Fragment() {
// Accent color // Accent color
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE) val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
binding.accentToggle.check(when (savedPreset) { binding.accentToggle.check(when (savedPreset) {
ThemeHelper.PRESET_RED -> R.id.btnAccentOrange ThemeHelper.PRESET_RED -> R.id.btnAccentOrange
ThemeHelper.PRESET_GREEN -> R.id.btnAccentGreen ThemeHelper.PRESET_GREEN -> R.id.btnAccentGreen
ThemeHelper.PRESET_CUSTOM -> R.id.btnAccentCustom ThemeHelper.PRESET_CUSTOM -> R.id.btnAccentCustom
else -> R.id.btnAccentBlue else -> R.id.btnAccentBlue
@@ -191,11 +236,15 @@ class SettingsAppearanceFragment : Fragment() {
} }
private fun updateShortcutsVisibility() { private fun updateShortcutsVisibility() {
val isBottom = prefs.getBoolean("bottom_nav", false) val mode = NavCustomization.getNavMode(prefs)
binding.sectionQuickActions.alpha = if (isBottom) 0.38f else 1f val isBottom = mode == NavCustomization.NAV_MODE_BOTTOM
val isCircular = mode == NavCustomization.NAV_MODE_CIRCULAR
binding.sectionQuickActions.alpha = if (!isBottom) 1f else 0.38f
binding.sectionCircularSlots.alpha = if (isCircular) 1f else 0.38f
binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
binding.switchShowLabels.isClickable = isBottom binding.switchShowLabels.isClickable = isBottom
quickActionAdapter.notifyDataSetChanged() quickActionAdapter.notifyDataSetChanged()
circularSlotAdapter.notifyDataSetChanged()
slotAdapter.notifyDataSetChanged() slotAdapter.notifyDataSetChanged()
} }
@@ -262,9 +311,10 @@ class SettingsAppearanceFragment : Fragment() {
} }
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) { private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
val isBottom = prefs.getBoolean("bottom_nav", false) val mode = NavCustomization.getNavMode(prefs)
if (items === slots && !isBottom) return if (items === slots && mode != NavCustomization.NAV_MODE_BOTTOM) return
if (items === quickActions && isBottom) return if (items === quickActions && mode == NavCustomization.NAV_MODE_BOTTOM) return
if (items === circularSlots && mode != NavCustomization.NAV_MODE_CIRCULAR) return
val ctx = requireContext() val ctx = requireContext()
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet() val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds } val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
@@ -9,6 +9,8 @@ import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import sh.sar.basedbank.R import sh.sar.basedbank.R
@@ -25,13 +27,23 @@ class SettingsFragment : Fragment() {
SettingsItem(R.drawable.ic_contacts, R.string.settings_logins, R.string.settings_desc_logins) { SettingsLoginsFragment() }, SettingsItem(R.drawable.ic_contacts, R.string.settings_logins, R.string.settings_desc_logins) { SettingsLoginsFragment() },
SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance, R.string.settings_desc_appearance) { SettingsAppearanceFragment() }, SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance, R.string.settings_desc_appearance) { SettingsAppearanceFragment() },
SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security, R.string.settings_desc_privacy_security) { SettingsSecurityFragment() }, SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security, R.string.settings_desc_privacy_security) { SettingsSecurityFragment() },
SettingsItem(R.drawable.ic_bell_filled, R.string.settings_notifications, R.string.settings_desc_notifications) { SettingsNotificationsFragment() },
SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage, R.string.settings_desc_storage) { SettingsStorageFragment() }, SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage, R.string.settings_desc_storage) { SettingsStorageFragment() },
SettingsItem(R.drawable.ic_info, R.string.settings_about, R.string.settings_desc_about) { SettingsAboutFragment() },
) )
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
inflater.inflate(R.layout.fragment_settings, container, false) inflater.inflate(R.layout.fragment_settings, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(view as? android.widget.ScrollView)?.clipToPadding = false
val basePaddingBottom = view.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
insets
}
val list = view.findViewById<LinearLayout>(R.id.settingsList) val list = view.findViewById<LinearLayout>(R.id.settingsList)
val inflater = LayoutInflater.from(requireContext()) val inflater = LayoutInflater.from(requireContext())
for (item in items) { for (item in items) {
@@ -20,6 +20,8 @@ import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -333,6 +335,14 @@ class SettingsLoginsFragment : Fragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
val basePaddingBottom = binding.root.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
insets
}
binding.btnAddAccount.setOnClickListener { binding.btnAddAccount.setOnClickListener {
startActivity(Intent(requireContext(), LoginActivity::class.java)) startActivity(Intent(requireContext(), LoginActivity::class.java))
} }
@@ -0,0 +1,203 @@
package sh.sar.basedbank.ui.home
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.provider.Settings
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.widget.SwitchCompat
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import com.google.android.material.color.MaterialColors
import sh.sar.basedbank.R
import sh.sar.basedbank.service.NotificationPollingService
class SettingsNotificationsFragment : Fragment() {
private var switchView: SwitchCompat? = null
// Step 1: notification permission — on grant, proceed to battery opt check
private val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) checkBatteryOptimization() else switchView?.isChecked = false
}
// Step 2: battery optimization — proceed to enableService regardless of user choice
private val batteryOptLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
enableService()
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val prefs = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
val scroll = ScrollView(ctx).apply { clipToPadding = false }
val col = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
val p = (20 * dp).toInt()
setPadding(p, p, p, p)
}
// Section header
col.addView(TextView(ctx).apply {
setText(R.string.settings_notif_section)
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
setTextColor(MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimary, Color.CYAN))
setPadding(0, 0, 0, (12 * dp).toInt())
})
// Enable toggle row
val sw = SwitchCompat(ctx).apply {
isChecked = prefs.getBoolean(PREF_ENABLED, false)
}
switchView = sw
sw.setOnCheckedChangeListener { _, on -> if (on) requestEnableNotifications() else disableService() }
val toggleRow = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
val vp = (10 * dp).toInt()
setPadding(0, vp, 0, vp)
}
val textCol = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
}
textCol.addView(TextView(ctx).apply {
setText(R.string.settings_notif_enable)
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
})
textCol.addView(TextView(ctx).apply {
setText(R.string.settings_notif_enable_desc)
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
alpha = 0.65f
})
toggleRow.addView(textCol)
toggleRow.addView(sw.apply {
layoutParams = (layoutParams as? LinearLayout.LayoutParams ?: LinearLayout.LayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
)).also { it.marginStart = (12 * dp).toInt() }
})
col.addView(toggleRow)
// Description
col.addView(TextView(ctx).apply {
setText(R.string.settings_notif_description)
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
alpha = 0.65f
setPadding(0, (4 * dp).toInt(), 0, (20 * dp).toInt())
})
// Notification channels nav row — same style as settings menu items
val colPad = (20 * dp).toInt()
val navRow = inflater.inflate(R.layout.item_more_nav, col, false).apply {
layoutParams = (layoutParams as LinearLayout.LayoutParams).apply {
marginStart = -colPad
marginEnd = -colPad
topMargin = (8 * dp).toInt()
}
findViewById<ImageView>(R.id.ivIcon).setImageResource(R.drawable.ic_bell_filled)
findViewById<TextView>(R.id.tvLabel).setText(R.string.settings_notif_open_system)
findViewById<TextView>(R.id.tvDescription).setText(R.string.settings_notif_channels_desc)
setOnClickListener {
startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, ctx.packageName)
})
}
}
col.addView(navRow)
scroll.addView(col)
return scroll
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val basePad = view.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
val isBottom = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getBoolean("bottom_nav", false)
val nav = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePad + if (isBottom) 0 else nav.bottom)
insets
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.settings_notifications)
}
override fun onDestroyView() {
switchView = null
super.onDestroyView()
}
// ── Enable flow ───────────────────────────────────────────────────────────────
private fun requestEnableNotifications() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED) {
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
return
}
checkBatteryOptimization()
}
private fun checkBatteryOptimization() {
val ctx = requireContext()
val pm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
if (!pm.isIgnoringBatteryOptimizations(ctx.packageName)) {
batteryOptLauncher.launch(
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${ctx.packageName}")
}
)
return
}
enableService()
}
private fun enableService() {
val ctx = requireContext()
ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
.edit().putBoolean(PREF_ENABLED, true).apply()
ctx.startForegroundService(Intent(ctx, NotificationPollingService::class.java))
switchView?.isChecked = true
}
private fun disableService() {
val ctx = requireContext()
ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
.edit().putBoolean(PREF_ENABLED, false).apply()
ctx.stopService(Intent(ctx, NotificationPollingService::class.java))
switchView?.isChecked = false
}
companion object {
const val PREF_ENABLED = "notifications_enabled"
}
}
@@ -6,6 +6,8 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentSettingsSecurityBinding import sh.sar.basedbank.databinding.FragmentSettingsSecurityBinding
@@ -22,6 +24,14 @@ class SettingsSecurityFragment : Fragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
val basePaddingBottom = binding.root.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
insets
}
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
// Change lock // Change lock
@@ -6,6 +6,8 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import sh.sar.basedbank.R import sh.sar.basedbank.R
@@ -31,6 +33,14 @@ class SettingsStorageFragment : Fragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
val basePaddingBottom = binding.root.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
insets
}
binding.btnClearCache.setOnClickListener { binding.btnClearCache.setOnClickListener {
val ctx = requireContext() val ctx = requireContext()
clearAllCaches(ctx) clearAllCaches(ctx)
@@ -86,15 +86,25 @@ class TransferFragment : Fragment() {
private var resolvedAccountNumber = "" private var resolvedAccountNumber = ""
private var resolvedRecipientName = "" private var resolvedRecipientName = ""
private var resolvedBankName = "" private var resolvedBankName = ""
private var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown
private var resolvedToOwnAccount: BankAccount? = null private var resolvedToOwnAccount: BankAccount? = null
// Selected Fahipay service when source is Fahipay and destination is a phone number // Selected Fahipay service when source is Fahipay and destination is a phone number
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL" // Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
private var selectedFahipayService: String? = null 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) // BML QR merchant payment mode (set when navigated from a card QR scan)
private var bmlQrInfo: BmlQrPayInfo? = null private var bmlQrInfo: BmlQrPayInfo? = null
private var bmlGatewayQr = false // true for pay.bml.com.mv QRs (requires pre-initiate step) 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>() private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
// BML business profile OTP flow state // BML business profile OTP flow state
@@ -125,12 +135,12 @@ class TransferFragment : Fragment() {
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
// BML card/gateway QR — hand off to dedicated payment screen // BML card/gateway QR — hand off to dedicated payment screen
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
raw.startsWith("https://pay.bml.com.mv/app/")) { if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
val fromCard = selectedAccount?.takeIf { val fromCard = selectedAccount?.takeIf {
it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT"
} }
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, fromCard?.accountNumber)) (requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, fromCard?.accountNumber))
return@registerForActivityResult return@registerForActivityResult
} }
@@ -139,6 +149,28 @@ class TransferFragment : Fragment() {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
return@registerForActivityResult 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.amount != null) binding.etAmount.setText(qr.amount)
if (qr.purpose != null) binding.etRemarks.setText(qr.purpose) if (qr.purpose != null) binding.etRemarks.setText(qr.purpose)
prefillToFromContact(qr.accountNumber, "") prefillToFromContact(qr.accountNumber, "")
@@ -191,13 +223,15 @@ class TransferFragment : Fragment() {
accountNumber: String, accountNumber: String,
displayName: String, displayName: String,
amount: String?, amount: String?,
remarks: String? remarks: String?,
fromAccountNumber: String? = null
) = TransferFragment().apply { ) = TransferFragment().apply {
arguments = Bundle().apply { arguments = Bundle().apply {
putString(ARG_ACCOUNT, accountNumber) putString(ARG_ACCOUNT, accountNumber)
putString(ARG_NAME, displayName) putString(ARG_NAME, displayName)
putString(ARG_SUBTITLE, accountNumber) putString(ARG_SUBTITLE, accountNumber)
putString(ARG_COLOR, "#607D8B") putString(ARG_COLOR, "#607D8B")
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount) if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks) if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
} }
@@ -221,11 +255,27 @@ class TransferFragment : Fragment() {
childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle -> childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener 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 label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B" val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH) val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash) 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 { binding.btnPickContact.setOnClickListener {
@@ -265,9 +315,37 @@ class TransferFragment : Fragment() {
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) { if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java)) 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) { private fun lookupBmlQrMerchant(qrUrl: String) {
bmlQrLookupAttempted = true
bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/")
val base64Url = android.util.Base64.encodeToString( val base64Url = android.util.Base64.encodeToString(
qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP) qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP)
@@ -292,6 +370,16 @@ class TransferFragment : Fragment() {
return@launch return@launch
} }
bmlQrInfo = info 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 // Auto-select the user's default BML card if no card was pre-selected
if (selectedAccount == null) { if (selectedAccount == null) {
@@ -398,6 +486,35 @@ class TransferFragment : Fragment() {
updateTransferButton() 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()
}
} }
} }
@@ -545,6 +662,7 @@ class TransferFragment : Fragment() {
} }
resolvedAccountNumber = "" resolvedAccountNumber = ""
resolvedRecipientName = "" resolvedRecipientName = ""
resolvedDestCurrency = ""
resolvedToOwnAccount = null resolvedToOwnAccount = null
selectedFahipayService = null selectedFahipayService = null
binding.cardToInfo.visibility = View.GONE binding.cardToInfo.visibility = View.GONE
@@ -561,6 +679,7 @@ class TransferFragment : Fragment() {
if (binding.cardToInfo.visibility == View.VISIBLE) { if (binding.cardToInfo.visibility == View.VISIBLE) {
resolvedAccountNumber = "" resolvedAccountNumber = ""
resolvedRecipientName = "" resolvedRecipientName = ""
resolvedDestCurrency = ""
resolvedToOwnAccount = null resolvedToOwnAccount = null
binding.cardToInfo.visibility = View.GONE binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE binding.tilTo.visibility = View.VISIBLE
@@ -573,8 +692,21 @@ class TransferFragment : Fragment() {
private fun lookupAccount() { private fun lookupAccount() {
if (selectedAccount == null) { if (selectedAccount == null) {
Toast.makeText(requireContext(), R.string.transfer_select_source_first, Toast.LENGTH_SHORT).show() val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
return 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
}
} }
val accountNumber = AccountInputParser.normalize(binding.etTo.text?.toString()?.trim() ?: "") val accountNumber = AccountInputParser.normalize(binding.etTo.text?.toString()?.trim() ?: "")
if (accountNumber.isBlank()) { if (accountNumber.isBlank()) {
@@ -631,7 +763,16 @@ class TransferFragment : Fragment() {
"IAT" -> "MALBMVMV" "IAT" -> "MALBMVMV"
else -> bmlResult.agnt ?: bmlResult.account else -> bmlResult.agnt ?: bmlResult.account
} }
MibIpsAccountInfo(accountName = bmlResult.name, accountNumber = bmlResult.account, bankId = bankId) // BML's MIB verify endpoint doesn't return the account's currency.
// Enrich via MIB lookup when a MIB session is available.
val currency = if (
inputType == AccountInputParser.InputType.MIB_ACCOUNT &&
bmlResult.currency.isBlank() && mibSess != null
) {
try { MibTransferClient().lookup(mibSess, bmlResult.account).currency }
catch (_: Exception) { "" }
} else bmlResult.currency
MibIpsAccountInfo(accountName = bmlResult.name, accountNumber = bmlResult.account, bankId = bankId, currency = currency)
} else if (mibSess != null) { } else if (mibSess != null) {
try { MibTransferClient().lookup(mibSess, accountNumber) } try { MibTransferClient().lookup(mibSess, accountNumber) }
catch (e: MibLookupException) { errorMsg = e.message; null } catch (e: MibLookupException) { errorMsg = e.message; null }
@@ -640,21 +781,29 @@ class TransferFragment : Fragment() {
errorMsg = getString(R.string.transfer_account_not_found); null errorMsg = getString(R.string.transfer_account_not_found); null
} }
} else { } else {
if (mibSess != null) { val mibInfo = if (mibSess != null) {
try { MibTransferClient().lookup(mibSess, accountNumber) } try { MibTransferClient().lookup(mibSess, accountNumber) }
catch (e: MibLookupException) { errorMsg = e.message; null } catch (e: MibLookupException) { errorMsg = e.message; null }
catch (_: Exception) { errorMsg = getString(R.string.transfer_account_not_found); null } catch (_: Exception) { errorMsg = getString(R.string.transfer_account_not_found); null }
} else { } else null
val bmlResult = try { BmlValidateClient().validateAccount(bmlSess!!, accountNumber) } catch (_: Exception) { null } if (mibInfo != null) {
mibInfo
} else if (bmlSess != null) {
val bmlResult = try { BmlValidateClient().validateAccount(bmlSess, accountNumber) } catch (_: Exception) { null }
if (bmlResult != null) { if (bmlResult != null) {
errorMsg = null
val bankId = when (bmlResult.trnType) { val bankId = when (bmlResult.trnType) {
"IAT" -> "MALBMVMV" "IAT" -> "MALBMVMV"
else -> bmlResult.agnt ?: bmlResult.account else -> bmlResult.agnt ?: bmlResult.account
} }
MibIpsAccountInfo(accountName = bmlResult.name, accountNumber = bmlResult.account, bankId = bankId) MibIpsAccountInfo(accountName = bmlResult.name, accountNumber = bmlResult.account, bankId = bankId, currency = bmlResult.currency)
} else { } else {
errorMsg = getString(R.string.transfer_account_not_found); null if (errorMsg == null) errorMsg = getString(R.string.transfer_account_not_found)
null
} }
} else {
if (errorMsg == null) errorMsg = getString(R.string.transfer_account_not_found)
null
} }
} }
} }
@@ -670,6 +819,14 @@ class TransferFragment : Fragment() {
resolvedAccountNumber = info.accountNumber resolvedAccountNumber = info.accountNumber
resolvedRecipientName = info.accountName resolvedRecipientName = info.accountName
resolvedBankName = info.bankId resolvedBankName = info.bankId
resolvedDestCurrency = info.currency
savedToSubtitle = "${info.accountNumber} · ${info.bankId}"
savedToColorHex = colorHex
savedToImageHash = when {
matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash
matchedCont?.customerImgHash != null -> matchedCont.customerImgHash
else -> null
}
if (matchedAcc != null) { if (matchedAcc != null) {
showToCard(matchedAcc) showToCard(matchedAcc)
@@ -802,6 +959,9 @@ class TransferFragment : Fragment() {
) { ) {
resolvedAccountNumber = accountNumber resolvedAccountNumber = accountNumber
resolvedRecipientName = displayName resolvedRecipientName = displayName
savedToSubtitle = subtitle
savedToColorHex = colorHex
savedToImageHash = imageHash
val contacts = viewModel.contacts.value ?: emptyList() val contacts = viewModel.contacts.value ?: emptyList()
resolvedBankName = contacts.firstOrNull { it.benefAccount == accountNumber }?.benefBankName ?: "" resolvedBankName = contacts.firstOrNull { it.benefAccount == accountNumber }?.benefBankName ?: ""
@@ -824,22 +984,37 @@ class TransferFragment : Fragment() {
updateTransferButton() updateTransferButton()
val contact = contacts.firstOrNull { it.benefAccount == accountNumber } val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
if (contact != null) { val recentImageHash: String?
RecentsCache.save(requireContext(), RecentPick( val recentIsProfileImage: Boolean
accountNumber = contact.benefAccount, when {
displayName = contact.benefNickName, ownAccount != null -> {
subtitle = subtitle, recentImageHash = ownAccount.profileImageHash
colorHex = colorHex, recentIsProfileImage = true
imageHash = imageHash, }
isProfileImage = false contact != null -> {
)) recentImageHash = contact.customerImgHash ?: imageHash
if (imageHash != null) loadToPhoto(imageHash, isProfile = false) recentIsProfileImage = false
}
else -> {
recentImageHash = imageHash
recentIsProfileImage = false
}
} }
RecentsCache.save(requireContext(), RecentPick(
accountNumber = accountNumber,
displayName = displayName,
subtitle = subtitle,
colorHex = colorHex,
imageHash = recentImageHash,
isProfileImage = recentIsProfileImage
))
if (contact != null && imageHash != null) loadToPhoto(imageHash, isProfile = false)
} }
private fun prefillToFromContact(accountNumber: String, label: String) { private fun prefillToFromContact(accountNumber: String, label: String) {
resolvedAccountNumber = "" resolvedAccountNumber = ""
resolvedRecipientName = "" resolvedRecipientName = ""
resolvedDestCurrency = ""
binding.cardToInfo.visibility = View.GONE binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE binding.btnPickContact.visibility = View.VISIBLE
@@ -857,13 +1032,20 @@ class TransferFragment : Fragment() {
message: String? = null, message: String? = null,
customView: android.view.View? = null, customView: android.view.View? = null,
biometricSubtitle: String, biometricSubtitle: String,
onConfirmed: () -> Unit onConfirmed: (AlertDialog, android.widget.FrameLayout) -> 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()) val builder = MaterialAlertDialogBuilder(requireContext())
.setTitle(title) .setTitle(title)
.setPositiveButton(R.string.transfer_confirm) { _, _ -> onConfirmed() } .setPositiveButton(R.string.transfer_confirm, null)
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
if (customView != null) builder.setView(customView) else builder.setMessage(message) .setCancelable(false)
if (customView != null) builder.setView(frame) else builder.setMessage(message)
val dialog = builder.show() val dialog = builder.show()
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false) val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false)
@@ -874,8 +1056,7 @@ class TransferFragment : Fragment() {
val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()), val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()),
object : BiometricPrompt.AuthenticationCallback() { object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
dialog.dismiss() onConfirmed(dialog, frame)
onConfirmed()
} }
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (errorCode != BiometricPrompt.ERROR_CANCELED && if (errorCode != BiometricPrompt.ERROR_CANCELED &&
@@ -894,6 +1075,10 @@ class TransferFragment : Fragment() {
.build() .build()
) )
} }
} else {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
onConfirmed(dialog, frame)
}
} }
} }
@@ -912,11 +1097,27 @@ class TransferFragment : Fragment() {
Toast.makeText(requireContext(), R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
return 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( showConfirmWithBiometric(
title = getString(R.string.transfer), title = getString(R.string.transfer),
message = "Pay ${info.currency} ${"%.2f".format(amount)} to ${info.merchantName}?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}", customView = qrConfirmView,
biometricSubtitle = "${info.currency} ${"%.2f".format(amount)}${info.merchantName}", biometricSubtitle = "${info.currency} ${"%.2f".format(amount)}${info.merchantName}",
onConfirmed = { executeBmlQrPayment(src, debitAccount, info, amount) } onConfirmed = { dialog, frame ->
showProcessingInDialog(dialog, frame)
executeBmlQrPayment(src, debitAccount, info, amount, dialog, frame)
}
) )
return return
} }
@@ -950,10 +1151,27 @@ class TransferFragment : Fragment() {
if (isSrcBml && isDestMib && currency == "USD") { if (isSrcBml && isDestMib && currency == "USD") {
val hasBmlContact = allContacts.any { it.benefCategoryId == "BML" && it.benefAccount == resolvedAccountNumber } val hasBmlContact = allContacts.any { it.benefCategoryId == "BML" && it.benefAccount == resolvedAccountNumber }
if (!hasBmlContact) { if (!hasBmlContact) {
// If we verified the dest currency via MIB fallback, the block is purely a BML API limitation.
// Otherwise (no MIB session, currency truly unknown) the generic message applies.
val msgRes = if (resolvedDestCurrency.isNotBlank())
R.string.transfer_bml_contact_required_msg_bml_limit
else
R.string.transfer_bml_contact_required_msg
val bmlProfileId = src.profileId.takeIf { it.isNotBlank() }
?: src.loginTag.removePrefix("bml_").takeIf { it.isNotBlank() }
val verified = resolvedDestCurrency.isNotBlank()
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.transfer_bml_contact_required_title) .setTitle(R.string.transfer_bml_contact_required_title)
.setMessage(R.string.transfer_bml_contact_required_msg) .setMessage(msgRes)
.setPositiveButton(R.string.close, null) .setPositiveButton(R.string.contact_save) { _, _ ->
AddContactSheetFragment.newInstance(
bmlProfileId = bmlProfileId,
accountNumber = resolvedAccountNumber,
recipientName = if (verified) resolvedRecipientName else null,
currency = if (verified) resolvedDestCurrency else null
).show(parentFragmentManager, "add_contact")
}
.setNegativeButton(R.string.close, null)
.show() .show()
return return
} }
@@ -963,26 +1181,27 @@ class TransferFragment : Fragment() {
val bankNameCapture = resolvedBankName val bankNameCapture = resolvedBankName
val capturedToAvatar = (binding.ivToPhoto.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap val capturedToAvatar = (binding.ivToPhoto.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
val destCurrency = allAccounts.firstOrNull { it.accountNumber == resolvedAccountNumber } val destCurrency = resolvedDestCurrency.ifBlank {
?.currencyName?.ifBlank { "MVR" } allAccounts.firstOrNull { it.accountNumber == resolvedAccountNumber }
?: allContacts.firstOrNull { it.benefAccount == resolvedAccountNumber } ?.currencyName?.ifBlank { "MVR" }
?.transferCyDesc?.ifBlank { "MVR" } ?: allContacts.firstOrNull { it.benefAccount == resolvedAccountNumber }
?: if (isDestMib) "MVR" else "MVR" ?.transferCyDesc?.ifBlank { "MVR" }
?: "MVR"
}
val isUsdToMvr = currency.equals("USD", ignoreCase = true) && destCurrency.equals("MVR", ignoreCase = true) val isUsdToMvr = currency.equals("USD", ignoreCase = true) && destCurrency.equals("MVR", ignoreCase = true)
val isSrcCredit = src.profileType == "BML_CREDIT" val isSrcCredit = src.profileType == "BML_CREDIT"
val mainMsg = "Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}" val doTransfer: (AlertDialog, android.widget.FrameLayout) -> Unit = { dialog, frame ->
val doTransfer: () -> Unit = {
if (isBmlBusiness) { if (isBmlBusiness) {
// Business profile: async OTP channel selection flow // Business profile: async OTP channel selection flow — dismiss dialog first
dialog.dismiss()
startBmlBusinessOtpFlow( startBmlBusinessOtpFlow(
src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks, src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks,
isSrcCard, isDestMib, currency, allAccounts, allContacts, capturedToAvatar isSrcCard, isDestMib, currency, allAccounts, allContacts, capturedToAvatar
) )
} else { } else {
showProcessingInDialog(dialog, frame)
binding.btnTransfer.isEnabled = false binding.btnTransfer.isEnabled = false
(activity as? HomeActivity)?.setRefreshing(true)
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val (ok, msg, receipt) = withContext(Dispatchers.IO) { val (ok, msg, receipt) = withContext(Dispatchers.IO) {
if (!isSrcBml) { if (!isSrcBml) {
@@ -992,14 +1211,15 @@ class TransferFragment : Fragment() {
} }
} }
binding.btnTransfer.isEnabled = true binding.btnTransfer.isEnabled = true
(activity as? HomeActivity)?.setRefreshing(false)
if (ok && receipt != null) { if (ok && receipt != null) {
ReceiptStore.save(requireContext(), receipt) ReceiptStore.save(requireContext(), receipt)
clearForm() clearForm()
val activity = requireActivity() as HomeActivity val activity = requireActivity() as HomeActivity
activity.triggerRefresh() activity.triggerRefresh()
dialog.dismiss()
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar)) activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
} else if (!ok) { } else if (!ok) {
dialog.dismiss()
if (msg == "CONNECTIVITY") { if (msg == "CONNECTIVITY") {
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet)) (activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
} else { } else {
@@ -1010,56 +1230,202 @@ class TransferFragment : Fragment() {
} }
} }
val warningView: android.view.View? = if (isUsdToMvr || isSrcCredit) { val fromTypeLabel = AccountListParser.from(src)?.typeLabel
val ctx = requireContext() ?: if (src.bank == "BML") BmlDashboardParser.productLabel(src.accountTypeName)
val dp = resources.displayMetrics.density else src.accountTypeName.ifBlank { src.profileType }
LinearLayout(ctx).apply { val fromBankLabel = when (src.bank) {
orientation = LinearLayout.VERTICAL "BML" -> "BML"
setPadding((24 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt(), 0) "FAHIPAY" -> "Fahipay"
addView(TextView(ctx).apply { text = mainMsg }) "MIB" -> "MIB"
if (isUsdToMvr) addView(TextView(ctx).apply { else -> src.bank
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) val fromDetail = listOfNotNull(fromBankLabel.ifBlank { null }, fromTypeLabel.ifBlank { null }).joinToString(" · ")
textSize = 16f
typeface = Typeface.DEFAULT_BOLD val toTypeLabel = resolvedToOwnAccount?.let { acc ->
setPadding(0, (16 * dp).toInt(), 0, 0) AccountListParser.from(acc)?.typeLabel
}) ?: if (acc.bank == "BML") BmlDashboardParser.productLabel(acc.accountTypeName)
if (isSrcCredit) addView(TextView(ctx).apply { else acc.accountTypeName.ifBlank { acc.profileType }
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) val toBankLabel = resolvedToOwnAccount?.let { acc ->
textSize = 16f when (acc.bank) {
typeface = Typeface.DEFAULT_BOLD "BML" -> "BML"
setPadding(0, (16 * dp).toInt(), 0, 0) "FAHIPAY" -> "Fahipay"
}) "MIB" -> "MIB"
else -> acc.bank
} }
} else null } ?: 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( showConfirmWithBiometric(
title = getString(R.string.transfer), title = getString(R.string.transfer),
message = if (warningView == null) mainMsg else null, customView = confirmView,
customView = warningView, biometricSubtitle = "$currency ${"%.2f".format(amount)}$destDisplay",
biometricSubtitle = "$currency $amountStr$destDisplay", onConfirmed = { dialog, frame -> doTransfer(dialog, frame) }
onConfirmed = { doTransfer() }
) )
} }
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) =
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
setTextColor(Color.RED)
textSize = 14f
setTypeface(null, Typeface.BOLD)
layoutParams = lp { topMargin = (16 * dp).toInt() }
})
}
}
}
private fun executeBmlQrPayment( private fun executeBmlQrPayment(
src: BankAccount, src: BankAccount,
debitAccount: String, debitAccount: String,
info: BmlQrPayInfo, info: BmlQrPayInfo,
amount: Double amount: Double,
dialog: AlertDialog,
frame: android.widget.FrameLayout
) { ) {
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val loginId = src.loginTag.removePrefix("bml_") val loginId = src.loginTag.removePrefix("bml_")
val session = bmlSessionFor(src) ?: run { val session = bmlSessionFor(src) ?: run {
dialog.dismiss()
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return return
} }
val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) } ?.let { Totp.generate(it) }
?: run { Toast.makeText(requireContext(), "OTP unavailable", Toast.LENGTH_SHORT).show(); return } ?: run { dialog.dismiss(); Toast.makeText(requireContext(), "OTP unavailable", Toast.LENGTH_SHORT).show(); return }
binding.btnTransfer.isEnabled = false binding.btnTransfer.isEnabled = false
(activity as? HomeActivity)?.setRefreshing(true)
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) { val result = withContext(Dispatchers.IO) {
@@ -1080,75 +1446,174 @@ class TransferFragment : Fragment() {
sh.sar.basedbank.api.bml.BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed") sh.sar.basedbank.api.bml.BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
} }
} }
(activity as? HomeActivity)?.setRefreshing(false)
if (_binding == null) return@launch if (_binding == null) return@launch
if (result == null) { if (result == null) {
dialog.dismiss()
binding.btnTransfer.isEnabled = true binding.btnTransfer.isEnabled = true
Toast.makeText(requireContext(), "Failed to initiate payment", Toast.LENGTH_LONG).show() Toast.makeText(requireContext(), "Failed to initiate payment", Toast.LENGTH_LONG).show()
return@launch return@launch
} }
if (result.success) { if (result.success) {
showBmlQrSuccessDialog( showSuccessInDialog(
merchant = result.merchant.ifBlank { info.merchantName }, dialog, frame,
amount = result.amount.ifBlank { "%.2f".format(amount) }, amountCurrency = result.currency.ifBlank { info.currency },
currency = 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()
}
} else { } else {
dialog.dismiss()
binding.btnTransfer.isEnabled = true binding.btnTransfer.isEnabled = true
Toast.makeText(requireContext(), result.errorMessage, Toast.LENGTH_LONG).show() Toast.makeText(requireContext(), result.errorMessage, Toast.LENGTH_LONG).show()
} }
} }
} }
private fun showBmlQrSuccessDialog(merchant: String, amount: String, currency: String) { 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)
val ctx = requireContext() val ctx = requireContext()
val dp = resources.displayMetrics.density val dp = resources.displayMetrics.density
val container = android.widget.LinearLayout(ctx).apply { val spinner = CircularProgressDrawable(ctx).apply {
orientation = android.widget.LinearLayout.VERTICAL setStyle(CircularProgressDrawable.LARGE)
gravity = android.view.Gravity.CENTER_HORIZONTAL setColorSchemeColors(com.google.android.material.color.MaterialColors.getColor(
setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt()) requireView(), com.google.android.material.R.attr.colorPrimary, Color.GRAY))
start()
} }
container.addView(android.widget.ImageView(ctx).apply { frame.removeAllViews()
setImageResource(R.drawable.ic_check_circle) frame.addView(LinearLayout(ctx).apply {
setColorFilter(android.graphics.Color.parseColor("#4CAF50")) orientation = LinearLayout.VERTICAL
layoutParams = android.widget.LinearLayout.LayoutParams( gravity = Gravity.CENTER_HORIZONTAL
(64 * dp).toInt(), (64 * dp).toInt() setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt())
).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL; bottomMargin = (16 * 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.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() }
})
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 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
})
})
})
val okBtn = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
okBtn?.visibility = View.VISIBLE
okBtn?.text = "OK"
okBtn?.setOnClickListener { dialog.dismiss(); onDone() }
}
private fun doMibTransfer( private fun doMibTransfer(
src: BankAccount, src: BankAccount,
destAccount: String, destAccount: String,
@@ -1620,6 +2085,7 @@ class TransferFragment : Fragment() {
resolvedAccountNumber = "" resolvedAccountNumber = ""
resolvedRecipientName = "" resolvedRecipientName = ""
resolvedBankName = "" resolvedBankName = ""
resolvedDestCurrency = ""
resolvedToOwnAccount = null resolvedToOwnAccount = null
selectedFahipayService = null selectedFahipayService = null
binding.cardToInfo.visibility = View.GONE binding.cardToInfo.visibility = View.GONE
@@ -1718,8 +2184,17 @@ class TransferFragment : Fragment() {
requireActivity().title = getString(R.string.transfer) requireActivity().title = getString(R.string.transfer)
} }
override fun onDestroyView() { override fun onDestroyView() {
super.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 _binding = null
} }
@@ -209,13 +209,14 @@ class TransferHistoryFragment : Fragment() {
cal.add(Calendar.MONTH, -state.cardMonthOffset) cal.add(Calendar.MONTH, -state.cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time) val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
state.cardMonthOffset++ state.cardMonthOffset++
BmlHistoryClient().fetchCardHistory( val cardResult = BmlHistoryClient().fetchCardHistory(
session = session, session = session,
cardId = state.account.internalId, cardId = state.account.internalId,
accountDisplayName = state.account.accountBriefName, accountDisplayName = state.account.accountBriefName,
accountNumber = state.account.accountNumber, accountNumber = state.account.accountNumber,
month = month month = month
) )
cardResult.statement + cardResult.outstanding + cardResult.unbilled
} }
else -> { else -> {
val session = app.bmlSessionFor(state.account) ?: return@async emptyList() val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
@@ -26,6 +26,8 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
@@ -111,6 +113,32 @@ class TransferReceiptFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
receiptCard.setOnClickListener { showFullScreenReceipt() } receiptCard.setOnClickListener { showFullScreenReceipt() }
val btnRow = view.findViewById<View>(R.id.btnRow)
val basePaddingBottom = btnRow.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(btnRow) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
insets
}
val receiptContainer = view.findViewById<android.widget.ScrollView>(R.id.receiptContainer)
receiptContainer.setOnTouchListener { _, _ -> true }
receiptContainer.viewTreeObserver.addOnGlobalLayoutListener(object : android.view.ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
receiptContainer.viewTreeObserver.removeOnGlobalLayoutListener(this)
val available = receiptContainer.height
val natural = receiptCard.height
if (natural > available && available > 0) {
val scale = available.toFloat() / natural
receiptCard.scaleX = scale
receiptCard.scaleY = scale
receiptCard.pivotX = receiptCard.width / 2f
receiptCard.pivotY = 0f
}
}
})
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener { view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
parentFragmentManager.popBackStack() parentFragmentManager.popBackStack()
} }
@@ -1,5 +1,6 @@
package sh.sar.basedbank.ui.login package sh.sar.basedbank.ui.login
import android.app.Activity
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
@@ -13,11 +14,13 @@ import android.os.Looper
import android.text.Editable import android.text.Editable
import android.text.TextWatcher import android.text.TextWatcher
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.util.OtpauthParser
import sh.sar.basedbank.util.Totp import sh.sar.basedbank.util.Totp
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
@@ -34,6 +37,7 @@ import sh.sar.basedbank.util.AccountCache
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.databinding.FragmentCredentialsBinding import sh.sar.basedbank.databinding.FragmentCredentialsBinding
import sh.sar.basedbank.ui.home.HomeActivity import sh.sar.basedbank.ui.home.HomeActivity
import sh.sar.basedbank.ui.home.QrScannerActivity
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
class CredentialsFragment : Fragment() { class CredentialsFragment : Fragment() {
@@ -60,6 +64,25 @@ class CredentialsFragment : Fragment() {
private var bmlLoginId: String = "" private var bmlLoginId: String = ""
private var bmlAccumulatedAccounts = mutableListOf<BankAccount>() private var bmlAccumulatedAccounts = mutableListOf<BankAccount>()
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 entries = OtpauthParser.parse(raw)
when {
entries.isEmpty() -> Toast.makeText(requireContext(), "No OTP data found in QR", Toast.LENGTH_SHORT).show()
entries.size == 1 -> binding.etOtpSeed.setText(entries[0].secret)
else -> {
val labels = entries.map { e ->
if (e.issuer.isNotBlank()) "${e.issuer} (${e.name})" else e.name.ifBlank { e.secret.take(8) + "" }
}.toTypedArray()
MaterialAlertDialogBuilder(requireContext())
.setTitle("Choose account")
.setItems(labels) { _, i -> binding.etOtpSeed.setText(entries[i].secret) }
.show()
}
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCredentialsBinding.inflate(inflater, container, false) _binding = FragmentCredentialsBinding.inflate(inflater, container, false)
return binding.root return binding.root
@@ -75,7 +98,7 @@ class CredentialsFragment : Fragment() {
binding.ivBankLogo.setImageResource(R.drawable.fahipay_logo_long) binding.ivBankLogo.setImageResource(R.drawable.fahipay_logo_long)
binding.tvSignInDesc.setText(R.string.fahipay_sign_in_desc) binding.tvSignInDesc.setText(R.string.fahipay_sign_in_desc)
binding.tilUsername.hint = getString(R.string.fahipay_id_card) binding.tilUsername.hint = getString(R.string.fahipay_id_card)
binding.tilOtpSeed.visibility = android.view.View.GONE binding.rowOtpSeed.visibility = android.view.View.GONE
binding.etOtpSeed.isEnabled = false binding.etOtpSeed.isEnabled = false
binding.etOtpSeed.isFocusable = false binding.etOtpSeed.isFocusable = false
} }
@@ -83,6 +106,9 @@ class CredentialsFragment : Fragment() {
binding.btnLogin.isEnabled = false binding.btnLogin.isEnabled = false
binding.btnLogin.setOnClickListener { attemptLogin() } binding.btnLogin.setOnClickListener { attemptLogin() }
binding.btnScanOtpSeed.setOnClickListener {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
binding.cardOtp.setOnClickListener { binding.cardOtp.setOnClickListener {
val code = binding.tvOtpCode.text.toString().replace(" ", "") val code = binding.tvOtpCode.text.toString().replace(" ", "")
@@ -31,9 +31,14 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
ThemeHelper.applyAccent(this) ThemeHelper.applyAccent(this)
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
// If security is already configured, onboarding is complete. Redirect to lock screen prefs = getSharedPreferences("prefs", MODE_PRIVATE)
// to prevent overwriting an existing PIN/pattern via direct activity launch.
if (CredentialStore(this).loadSecurityHash() != null) { // Only redirect to the lock screen if onboarding is fully complete. Checking the
// security hash alone is not sufficient — the hash is written during the PIN/pattern
// setup step (page 1) which happens *before* the user clicks "Get Started", so a
// theme change or process restart mid-onboarding would otherwise trigger this guard
// and strand the user in the lock flow without finishing onboarding.
if (prefs.getBoolean("onboarding_done", false) && CredentialStore(this).loadSecurityHash() != null) {
startActivity(Intent(this, LockActivity::class.java)) startActivity(Intent(this, LockActivity::class.java))
finish() finish()
return return
@@ -50,7 +55,6 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground)) val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK) window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
ta.recycle() ta.recycle()
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val originalBottomPadding = binding.bottomBar.paddingBottom val originalBottomPadding = binding.bottomBar.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets -> ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets ->
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars()) val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
@@ -10,6 +10,7 @@ import androidx.biometric.BiometricManager
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentOnboardingConfigureBinding import sh.sar.basedbank.databinding.FragmentOnboardingConfigureBinding
import sh.sar.basedbank.ui.home.NavCustomization
class OnboardingConfigureFragment : Fragment() { class OnboardingConfigureFragment : Fragment() {
@@ -24,12 +25,20 @@ class OnboardingConfigureFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
// Navigation — default Drawer // Navigation
val isBottom = prefs.getBoolean("bottom_nav", false) binding.navModeToggle.check(when (NavCustomization.getNavMode(prefs)) {
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer) NavCustomization.NAV_MODE_BOTTOM -> R.id.btnNavBottom
NavCustomization.NAV_MODE_CIRCULAR -> R.id.btnNavCircular
else -> R.id.btnNavDrawer
})
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked -> binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener if (!isChecked) return@addOnButtonCheckedListener
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply() val mode = when (checkedId) {
R.id.btnNavBottom -> NavCustomization.NAV_MODE_BOTTOM
R.id.btnNavCircular -> NavCustomization.NAV_MODE_CIRCULAR
else -> NavCustomization.NAV_MODE_DRAWER
}
NavCustomization.saveNavMode(prefs, mode)
} }
// Theme — default System // Theme — default System
@@ -59,6 +59,7 @@ class OnboardingFragment : Fragment() {
private fun notifyScrolledToBottom() { private fun notifyScrolledToBottom() {
if (scrolledToBottom) return if (scrolledToBottom) return
if (!isAdded) return
scrolledToBottom = true scrolledToBottom = true
parentFragmentManager.setFragmentResult(RESULT_SCROLLED_TO_BOTTOM, Bundle.EMPTY) parentFragmentManager.setFragmentResult(RESULT_SCROLLED_TO_BOTTOM, Bundle.EMPTY)
} }
@@ -8,6 +8,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.core.content.ContextCompat
import com.google.android.material.button.MaterialButton import com.google.android.material.button.MaterialButton
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding
@@ -102,8 +103,17 @@ class SecuritySetupFragment : Fragment() {
else else
com.google.android.material.R.attr.materialButtonOutlinedStyle com.google.android.material.R.attr.materialButtonOutlinedStyle
val btn = MaterialButton(requireContext(), null, style).apply { val btn = MaterialButton(requireContext(), null, style).apply {
text = key if (key == "" || key == "") {
textSize = 24f text = ""
icon = ContextCompat.getDrawable(requireContext(),
if (key == "") R.drawable.ic_backspace else R.drawable.ic_check)
iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START
iconPadding = 0
iconSize = (28 * dp).toInt()
} else {
text = key
textSize = 24f
}
insetTop = 0; insetBottom = 0 insetTop = 0; insetBottom = 0
minimumWidth = 0; minimumHeight = 0 minimumWidth = 0; minimumHeight = 0
cornerRadius = btnSize / 2 cornerRadius = btnSize / 2
@@ -7,7 +7,7 @@ package sh.sar.basedbank.util
data class AccountHistoryDisplay( data class AccountHistoryDisplay(
val name: String, val name: String,
val number: String, val number: String,
val bankPill: String?, // "BML", "FP", null for MIB (no pill) val bankPill: String?, // "BML", "FP", "MIB" — bank label shown on the pill
val typeLabel: String, // e.g. "Savings", "Current", "Visa Platinum" val typeLabel: String, // e.g. "Savings", "Current", "Visa Platinum"
val availableBalance: String, // formatted "CCY amount" val availableBalance: String, // formatted "CCY amount"
val workingBalance: String, // ledger/working balance — formatted "CCY amount" val workingBalance: String, // ledger/working balance — formatted "CCY amount"
@@ -23,6 +23,7 @@ object CardsCache {
put("phoneNumber", c.phoneNumber) put("phoneNumber", c.phoneNumber)
put("cardHolderName", c.cardHolderName) put("cardHolderName", c.cardHolderName)
put("loginTag", c.loginTag) put("loginTag", c.loginTag)
put("profileId", c.profileId)
}) })
} }
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -45,7 +46,8 @@ object CardsCache {
customerId = o.optString("customerId"), customerId = o.optString("customerId"),
phoneNumber = o.optString("phoneNumber"), phoneNumber = o.optString("phoneNumber"),
cardHolderName = o.optString("cardHolderName"), cardHolderName = o.optString("cardHolderName"),
loginTag = o.optString("loginTag") loginTag = o.optString("loginTag"),
profileId = o.optString("profileId")
) )
} }
} catch (_: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
@@ -627,6 +627,29 @@ class CredentialStore(context: Context) {
editor.apply() 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) ───────────────────────────────── // ── MIB profile visibility (per loginId) ─────────────────────────────────
/** Returns the set of MIB profile IDs the user has chosen to hide (for a given loginId). */ /** Returns the set of MIB profile IDs the user has chosen to hide (for a given loginId). */
@@ -35,6 +35,21 @@ class HistoryFetcher(private val account: BankAccount) {
// BML card pagination (month-based) // BML card pagination (month-based)
private var cardMonthOffset = 0 private var cardMonthOffset = 0
private var pendingCardOutstanding: List<BankTransaction>? = null
private var pendingCardUnbilled: List<BankTransaction>? = null
/**
* Returns and clears the card outstanding + unbilled lists captured on the first card
* fetch. Each list is only ever returned once.
*/
fun takeCardPendingSections(): Pair<List<BankTransaction>, List<BankTransaction>>? {
val o = pendingCardOutstanding
val u = pendingCardUnbilled
if (o == null && u == null) return null
pendingCardOutstanding = null
pendingCardUnbilled = null
return Pair(o ?: emptyList(), u ?: emptyList())
}
// Fahipay pagination // Fahipay pagination
private var fahipayNextStart = 0 private var fahipayNextStart = 0
@@ -90,16 +105,22 @@ class HistoryFetcher(private val account: BankAccount) {
private fun fetchBmlCard(app: BasedBankApp): List<BankTransaction> { private fun fetchBmlCard(app: BasedBankApp): List<BankTransaction> {
val session = app.bmlSessionFor(account) ?: return emptyList() val session = app.bmlSessionFor(account) ?: return emptyList()
val cal = Calendar.getInstance() val cal = Calendar.getInstance()
val isFirstFetch = cardMonthOffset == 0
cal.add(Calendar.MONTH, -cardMonthOffset) cal.add(Calendar.MONTH, -cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time) val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
cardMonthOffset++ cardMonthOffset++
return BmlHistoryClient().fetchCardHistory( val result = BmlHistoryClient().fetchCardHistory(
session = session, session = session,
cardId = account.internalId, cardId = account.internalId,
accountDisplayName = account.accountBriefName, accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber, accountNumber = account.accountNumber,
month = month month = month
) )
if (isFirstFetch) {
pendingCardOutstanding = result.outstanding
pendingCardUnbilled = result.unbilled
}
return result.statement
} }
private fun fetchBmlCasa(app: BasedBankApp): List<BankTransaction> { private fun fetchBmlCasa(app: BasedBankApp): List<BankTransaction> {
@@ -0,0 +1,53 @@
package sh.sar.basedbank.util
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.nfc.NfcAdapter
import android.nfc.cardemulation.CardEmulation
import android.provider.Settings
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.R
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
object NfcPaymentUtil {
fun checkAndProceed(context: Context, onReady: () -> Unit) {
val nfcAdapter = NfcAdapter.getDefaultAdapter(context) ?: run {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.nfc_unsupported_title)
.setMessage(R.string.nfc_unsupported_message)
.setPositiveButton(android.R.string.ok, null)
.show()
return
}
if (!nfcAdapter.isEnabled) {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.nfc_disabled_title)
.setMessage(R.string.nfc_disabled_message)
.setPositiveButton(R.string.nfc_open_settings) { _, _ ->
context.startActivity(Intent(Settings.ACTION_NFC_SETTINGS))
}
.setNegativeButton(R.string.cancel, null)
.show()
return
}
val cardEmulation = CardEmulation.getInstance(nfcAdapter)
val componentName = ComponentName(context, BmlHostCardEmulatorService::class.java)
if (!cardEmulation.isDefaultServiceForCategory(componentName, CardEmulation.CATEGORY_PAYMENT)) {
MaterialAlertDialogBuilder(context)
.setTitle(R.string.nfc_not_default_title)
.setMessage(context.getString(R.string.nfc_not_default_message,
context.applicationInfo.loadLabel(context.packageManager)))
.setPositiveButton(R.string.nfc_payment_open_settings) { _, _ ->
context.startActivity(Intent(Settings.ACTION_NFC_PAYMENT_SETTINGS))
}
.setNegativeButton(R.string.cancel, null)
.show()
return
}
onReady()
}
}
@@ -0,0 +1,137 @@
package sh.sar.basedbank.util
import android.content.Context
import org.json.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.ui.home.AppNotification
object NotificationsCache {
private const val PREFS = "notifications_cache"
private const val KEY_MIB_READ_IDS = "mib_read_ids"
private fun bmlKey(loginId: String) = "bml_notifs_$loginId"
private fun mibKey(loginId: String) = "mib_activities_$loginId"
// ── BML ─────────────────────────────────────────────────────────────────────
fun saveBml(ctx: Context, loginId: String, items: List<AppNotification>) {
val arr = JSONArray()
items.forEach { n ->
arr.put(JSONObject().apply {
put("id", n.id)
put("group", n.group)
put("title", n.title)
put("message", n.message)
put("timestampMs", n.timestampMs)
put("isRead", n.isRead)
val fields = JSONArray()
n.detailFields.forEach { (k, v) -> fields.put(JSONObject().put("k", k).put("v", v)) }
put("detailFields", fields)
})
}
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
}
fun loadBml(ctx: Context, loginId: String): List<AppNotification> {
val raw = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(bmlKey(loginId), null) ?: return emptyList()
return try {
val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i ->
val obj = arr.getJSONObject(i)
val fields = obj.optJSONArray("detailFields")
val detailFields = if (fields != null) {
(0 until fields.length()).map { j ->
val f = fields.getJSONObject(j)
f.getString("k") to f.getString("v")
}
} else emptyList()
AppNotification(
id = obj.getString("id"),
bank = "BML",
loginId = loginId,
group = obj.getString("group"),
title = obj.getString("title"),
message = obj.getString("message"),
timestampMs = obj.getLong("timestampMs"),
isRead = obj.getBoolean("isRead"),
detailFields = detailFields
)
}
} catch (_: Exception) { emptyList() }
}
// ── MIB ─────────────────────────────────────────────────────────────────────
fun saveMib(ctx: Context, loginId: String, items: List<AppNotification>) {
val arr = JSONArray()
items.forEach { n ->
arr.put(JSONObject().apply {
put("id", n.id)
put("title", n.title)
put("message", n.message)
put("timestampMs", n.timestampMs)
val fields = JSONArray()
n.detailFields.forEach { (k, v) -> fields.put(JSONObject().put("k", k).put("v", v)) }
put("detailFields", fields)
})
}
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(mibKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
}
fun loadMib(ctx: Context, loginId: String, readIds: Set<String>): List<AppNotification> {
val raw = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(mibKey(loginId), null) ?: return emptyList()
return try {
val arr = JSONArray(CacheEncryption.decrypt(raw))
(0 until arr.length()).map { i ->
val obj = arr.getJSONObject(i)
val id = obj.getString("id")
val fields = obj.optJSONArray("detailFields")
val detailFields = if (fields != null) {
(0 until fields.length()).map { j ->
val f = fields.getJSONObject(j)
f.getString("k") to f.getString("v")
}
} else emptyList()
AppNotification(
id = id,
bank = "MIB",
loginId = loginId,
group = "ALERTS",
title = obj.getString("title"),
message = obj.getString("message"),
timestampMs = obj.getLong("timestampMs"),
isRead = id in readIds,
detailFields = detailFields
)
}
} catch (_: Exception) { emptyList() }
}
// ── MIB read IDs (in-app only) ───────────────────────────────────────────────
fun getMibReadIds(ctx: Context): Set<String> {
val raw = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_MIB_READ_IDS, null) ?: return emptySet()
return try {
val arr = JSONArray(raw)
(0 until arr.length()).map { arr.getString(it) }.toSet()
} catch (_: Exception) { emptySet() }
}
fun addMibReadIds(ctx: Context, ids: Collection<String>) {
val current = getMibReadIds(ctx).toMutableSet()
current.addAll(ids)
val arr = JSONArray().apply { current.forEach { put(it) } }
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.edit().putString(KEY_MIB_READ_IDS, arr.toString()).apply()
}
fun clearAll(ctx: Context) {
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
}
}
@@ -0,0 +1,116 @@
package sh.sar.basedbank.util
import android.net.Uri
import android.util.Base64
data class OtpEntry(val name: String, val issuer: String, val secret: String)
object OtpauthParser {
fun parse(raw: String): List<OtpEntry> = when {
raw.startsWith("otpauth-migration://") -> parseMigration(raw)
raw.startsWith("otpauth://") -> parseStandard(raw)?.let { listOf(it) } ?: emptyList()
else -> emptyList()
}
private fun parseStandard(raw: String): OtpEntry? {
val uri = Uri.parse(raw)
val secret = uri.getQueryParameter("secret") ?: return null
val issuer = uri.getQueryParameter("issuer") ?: ""
val label = uri.path?.trimStart('/') ?: ""
val name = if (':' in label) label.substringAfter(':').trim() else label
return OtpEntry(name, issuer, secret.uppercase())
}
private fun parseMigration(raw: String): List<OtpEntry> {
val data = Uri.parse(raw).getQueryParameter("data") ?: return emptyList()
val bytes = try { Base64.decode(data, Base64.DEFAULT) } catch (_: Exception) { return emptyList() }
val reader = ProtobufReader(bytes)
val entries = mutableListOf<OtpEntry>()
while (reader.hasMore()) {
val tag = reader.readVarint().toInt()
val fieldNum = tag ushr 3
val wireType = tag and 0x7
if (fieldNum == 1 && wireType == 2) {
parseOtpParameters(reader.readBytes())?.let { entries.add(it) }
} else {
reader.skip(wireType)
}
}
return entries
}
private fun parseOtpParameters(bytes: ByteArray): OtpEntry? {
val reader = ProtobufReader(bytes)
var secret: ByteArray? = null
var name = ""
var issuer = ""
var type = 2 // default to TOTP
while (reader.hasMore()) {
val tag = reader.readVarint().toInt()
val fieldNum = tag ushr 3
val wireType = tag and 0x7
when (fieldNum) {
1 -> secret = reader.readBytes()
2 -> name = String(reader.readBytes(), Charsets.UTF_8)
3 -> issuer = String(reader.readBytes(), Charsets.UTF_8)
6 -> type = reader.readVarint().toInt()
else -> reader.skip(wireType)
}
}
if (type == 1) return null // skip HOTP
val secretBase32 = base32Encode(secret ?: return null)
return OtpEntry(name, issuer, secretBase32)
}
private fun base32Encode(bytes: ByteArray): String {
val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
val sb = StringBuilder()
var buffer = 0
var bitsLeft = 0
for (b in bytes) {
buffer = (buffer shl 8) or (b.toInt() and 0xFF)
bitsLeft += 8
while (bitsLeft >= 5) {
bitsLeft -= 5
sb.append(alphabet[(buffer ushr bitsLeft) and 0x1F])
}
}
if (bitsLeft > 0) sb.append(alphabet[(buffer shl (5 - bitsLeft)) and 0x1F])
return sb.toString()
}
private class ProtobufReader(private val bytes: ByteArray) {
private var pos = 0
fun hasMore() = pos < bytes.size
fun readVarint(): Long {
var result = 0L
var shift = 0
while (pos < bytes.size) {
val b = bytes[pos++].toInt() and 0xFF
result = result or ((b and 0x7F).toLong() shl shift)
if (b and 0x80 == 0) break
shift += 7
}
return result
}
fun readBytes(): ByteArray {
val len = readVarint().toInt()
val data = bytes.copyOfRange(pos, pos + len)
pos += len
return data
}
fun skip(wireType: Int) {
when (wireType) {
0 -> readVarint()
1 -> pos += 8
2 -> readBytes()
5 -> pos += 4
}
}
}
}
@@ -9,6 +9,24 @@ data class PaymvQrData(
object PaymvQrParser { 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? { fun parse(raw: String): PaymvQrData? {
return try { return try {
val root = parseTlv(raw) val root = parseTlv(raw)
@@ -23,7 +41,7 @@ object PaymvQrParser {
PaymvQrData( PaymvQrData(
accountNumber = merchantInfo?.get("03"), accountNumber = merchantInfo?.get("03"),
amount = root["54"], amount = root["54"]?.takeIf { it != "***" },
purpose = additionalData?.get("08"), purpose = additionalData?.get("08"),
merchantName = root["59"] merchantName = root["59"]
) )
@@ -40,10 +40,10 @@ object BmlCardParser {
"C8040", "C8044" -> "cards/bml/master_world.png" "C8040", "C8044" -> "cards/bml/master_world.png"
"C8030", "C8033" -> "cards/bml/master_business_debit.png" "C8030", "C8033" -> "cards/bml/master_business_debit.png"
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png" "C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png"
"C1030", "C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png" "C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
"C8905", "C8995" -> "cards/bml/visa_credit.png" "C8905", "C8995" -> "cards/bml/visa_credit.png"
"C1001", "C1011", "C1082", "C1081", "C1101", "C1111", "C1181", "C1182" -> "cards/bml/visa_debit_generic.png" "C1001", "C1011", "C1082", "C1081", "C1101", "C1111", "C1181", "C1182" -> "cards/bml/visa_debit_generic.png"
"C1005", "C1006", "C1089" -> "cards/bml/visa_debit_islamic.png" "C1005", "C1006", "C1030", "C1089" -> "cards/bml/visa_debit_islamic.png"
"C1017" -> "cards/bml/visa_infinite.png" "C1017" -> "cards/bml/visa_infinite.png"
"C1009", "C1019", "C1085", "C1086", "C1109", "C1119", "C1185", "C1186" -> "cards/bml/visa_platinum.png" "C1009", "C1019", "C1085", "C1086", "C1109", "C1119", "C1185", "C1186" -> "cards/bml/visa_platinum.png"
"C1050", "C1051", "C1087", "C1088", "C1150", "C1151", "C1187", "C1188", "C1040", "C1041", "C1047", "C1048", "C1140", "C1141", "C1147", "C1148" -> "cards/bml/visa_student_black.png" "C1050", "C1051", "C1087", "C1088", "C1150", "C1151", "C1187", "C1188", "C1040", "C1041", "C1047", "C1048", "C1140", "C1141", "C1147", "C1148" -> "cards/bml/visa_student_black.png"
@@ -10,7 +10,7 @@ object MibHistoryParser {
return AccountHistoryDisplay( return AccountHistoryDisplay(
name = account.accountBriefName, name = account.accountBriefName,
number = account.accountNumber, number = account.accountNumber,
bankPill = null, // MIB has no bank pill bankPill = "MIB",
typeLabel = MibAccountParser.productLabel(account.accountTypeName), typeLabel = MibAccountParser.productLabel(account.accountTypeName),
availableBalance = "${account.currencyName} ${account.availableBalance}", availableBalance = "${account.currencyName} ${account.availableBalance}",
workingBalance = "${account.currencyName} ${account.currentBalance}", workingBalance = "${account.currencyName} ${account.currentBalance}",
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#44808080" />
<corners android:radius="2dp" />
</shape>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M22,3H7C6.31,3 5.77,3.35 5.41,3.88L0,12L5.41,20.12C5.77,20.65 6.31,21 7,21H22C23.1,21 24,20.1 24,19V5C24,3.9 23.1,3 22,3ZM19,15.59L17.59,17L14,13.41L10.41,17L9,15.59L12.59,12L9,8.41L10.41,7L14,10.59L17.59,7L19,8.41L15.41,12L19,15.59Z" />
</vector>
+18
View File
@@ -0,0 +1,18 @@
<?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">
<!-- Bell body -->
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07-1.63,-5.64-4.5,-6.32V4c0,-0.83-0.67,-1.5-1.5,-1.5s-1.5,0.67-1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"/>
<!-- Unread notification dot (red) -->
<path
android:fillColor="#EF5350"
android:pathData="M18.5,2A3.5,3.5,0,1,0,18.5,9A3.5,3.5,0,0,0,18.5,2Z"/>
</vector>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07-1.63,-5.64-4.5,-6.32V4c0,-0.83-0.67,-1.5-1.5,-1.5s-1.5,0.67-1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"/>
</vector>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<!-- Bell outline (no fill) -->
<path
android:fillColor="@android:color/transparent"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07-1.63,-5.64-4.5,-6.32V4c0,-0.83-0.67,-1.5-1.5,-1.5s-1.5,0.67-1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"
android:strokeColor="@android:color/white"
android:strokeWidth="1.5"
android:strokeLineCap="round"
android:strokeLineJoin="round"/>
</vector>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M9,16.17L4.83,12L3.41,13.41L9,19L21,7L19.59,5.59Z" />
</vector>
+10
View File
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z"/>
</vector>
@@ -0,0 +1,22 @@
<?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">
<!-- Shackle (open - right leg lifted free) -->
<path
android:fillColor="@android:color/transparent"
android:pathData="M8.9,10V6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1"
android:strokeColor="@android:color/white"
android:strokeLineCap="round"
android:strokeWidth="2.2"/>
<!-- Body + keyhole cutout -->
<path
android:fillColor="@android:color/white"
android:fillType="evenOdd"
android:pathData="M6,8H18c1.1,0 2,0.9 2,2V20c0,1.1-0.9,2-2,2H6c-1.1,0-2,-0.9-2,-2V10c0,-1.1 0.9,-2 2,-2zM12,15c-1.1,0-2,0.9-2,2s0.9,2 2,2 2,-0.9 2,-2-0.9,-2-2,-2z"/>
</vector>
+1 -1
View File
@@ -2,7 +2,7 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item> <item>
<shape android:shape="oval"> <shape android:shape="oval">
<solid android:color="#E8B547" /> <solid android:color="@color/ic_logo_background" />
</shape> </shape>
</item> </item>
<item android:drawable="@drawable/ic_launcher_foreground" /> <item android:drawable="@drawable/ic_launcher_foreground" />
+36 -4
View File
@@ -2,9 +2,41 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:viewportWidth="24" android:viewportWidth="48"
android:viewportHeight="24"> android:viewportHeight="48">
<!-- Phone outline -->
<path <path
android:fillColor="?attr/colorOnSurface" android:fillColor="#00000000"
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"/> 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"/>
</vector> </vector>
@@ -4,13 +4,47 @@
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<group <group
android:translateX="30" android:translateX="30"
android:translateY="30" android:translateY="30"
android:scaleX="2" android:scaleX="1.0"
android:scaleY="2"> android:scaleY="1.0">
<!-- Phone outline -->
<path <path
android:fillColor="#FFFFFF" android:fillColor="#00000000"
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" /> 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"/>
</group> </group>
</vector> </vector>
@@ -87,6 +87,7 @@ app:menu="@menu/bottom_nav_menu" />
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_gravity="start" android:layout_gravity="start"
android:fitsSystemWindows="true" android:fitsSystemWindows="true"
app:headerLayout="@layout/nav_header"
app:menu="@menu/drawer_menu" /> app:menu="@menu/drawer_menu" />
</androidx.drawerlayout.widget.DrawerLayout> </androidx.drawerlayout.widget.DrawerLayout>
@@ -93,6 +93,7 @@
<!-- Divider --> <!-- Divider -->
<View <View
android:id="@+id/divider"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="1dp" android:layout_height="1dp"
android:layout_marginHorizontal="24dp" android:layout_marginHorizontal="24dp"
@@ -173,6 +174,32 @@
</LinearLayout> </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) --> <!-- Card management actions (manage mode only) -->
<LinearLayout <LinearLayout
android:id="@+id/llManageButtons" android:id="@+id/llManageButtons"
@@ -242,6 +269,13 @@
</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 --> <!-- Loading state -->
<LinearLayout <LinearLayout
android:id="@+id/loadingView" android:id="@+id/loadingView"
@@ -73,22 +73,42 @@
android:singleLine="true" /> android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout <LinearLayout
android:id="@+id/tilOtpSeed" android:id="@+id/rowOtpSeed"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:hint="@string/otp_seed" android:orientation="horizontal"
android:layout_marginBottom="8dp" android:gravity="center_vertical"
app:endIconMode="password_toggle" android:layout_marginBottom="8dp">
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputLayout
android:id="@+id/etOtpSeed" android:id="@+id/tilOtpSeed"
android:layout_width="match_parent" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:inputType="textPassword" android:layout_weight="1"
android:imeOptions="actionDone" android:hint="@string/otp_seed"
android:singleLine="true" /> app:endIconMode="password_toggle"
</com.google.android.material.textfield.TextInputLayout> style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/etOtpSeed"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textPassword"
android:imeOptions="actionDone"
android:singleLine="true" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/btnScanOtpSeed"
style="@style/Widget.Material3.Button.IconButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
app:icon="@drawable/ic_qr_scan"
android:contentDescription="@string/scan_otp_qr"
android:tooltipText="@string/scan_otp_qr" />
</LinearLayout>
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:id="@+id/tilTotpCode" android:id="@+id/tilTotpCode"
@@ -44,6 +44,14 @@
android:layout_weight="1" android:layout_weight="1"
android:text="@string/settings_nav_drawer" /> android:text="@string/settings_nav_drawer" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnNavCircular"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_nav_circular" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnNavBottom" android:id="@+id/btnNavBottom"
style="@style/Widget.Material3.Button.OutlinedButton" style="@style/Widget.Material3.Button.OutlinedButton"
+1 -11
View File
@@ -139,21 +139,11 @@
android:layout_width="0dp" android:layout_width="0dp"
android:layout_weight="1" android:layout_weight="1"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginHorizontal="4dp" android:layout_marginStart="4dp"
android:enabled="false" android:enabled="false"
android:text="@string/paymvqr_save_image" android:text="@string/paymvqr_save_image"
app:icon="@drawable/ic_save" /> app:icon="@drawable/ic_save" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnScanQr"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:text="@string/transfer_scan_qr"
app:icon="@drawable/ic_qr_scan" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
@@ -7,6 +7,14 @@
android:orientation="vertical" android:orientation="vertical"
android:background="?attr/colorSurface"> android:background="?attr/colorSurface">
<ScrollView
android:id="@+id/receiptContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:overScrollMode="never"
android:scrollbars="none">
<!-- ══════════════════════════════════════════════════════════════════════ --> <!-- ══════════════════════════════════════════════════════════════════════ -->
<!-- Renderable receipt card --> <!-- Renderable receipt card -->
<!-- ══════════════════════════════════════════════════════════════════════ --> <!-- ══════════════════════════════════════════════════════════════════════ -->
@@ -207,10 +215,13 @@
</LinearLayout> </LinearLayout>
</ScrollView>
<!-- ══════════════════════════════════════════════════════════════════════ --> <!-- ══════════════════════════════════════════════════════════════════════ -->
<!-- Action buttons — outside renderable area --> <!-- Action buttons — outside renderable area -->
<!-- ══════════════════════════════════════════════════════════════════════ --> <!-- ══════════════════════════════════════════════════════════════════════ -->
<LinearLayout <LinearLayout
android:id="@+id/btnRow"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
@@ -7,6 +7,14 @@
android:orientation="vertical" android:orientation="vertical"
android:background="?attr/colorSurface"> android:background="?attr/colorSurface">
<ScrollView
android:id="@+id/receiptContainer"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:overScrollMode="never"
android:scrollbars="none">
<!-- Renderable receipt card (header grows to fill remaining space) --> <!-- Renderable receipt card (header grows to fill remaining space) -->
<LinearLayout <LinearLayout
android:id="@+id/receiptCard" android:id="@+id/receiptCard"
@@ -236,8 +244,11 @@
</LinearLayout> </LinearLayout>
</ScrollView>
<!-- Action buttons — outside renderable area --> <!-- Action buttons — outside renderable area -->
<LinearLayout <LinearLayout
android:id="@+id/btnRow"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
@@ -0,0 +1,222 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?attr/colorSurface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="24dp">
<ImageView
android:id="@+id/ivLogo"
android:layout_width="72dp"
android:layout_height="72dp"
android:layout_gravity="center_horizontal"
android:layout_marginBottom="16dp"
android:src="@drawable/ic_logo"
android:contentDescription="@string/app_name" />
<TextView
android:id="@+id/tvAppName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
android:layout_marginBottom="4dp" />
<TextView
android:id="@+id/tvVersion"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_horizontal"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:alpha="0.6"
android:layout_marginBottom="20dp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutlineVariant"
android:layout_marginBottom="24dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/about_legal"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:layout_marginBottom="24dp" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutlineVariant"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/about_terms"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginBottom="4dp" />
<LinearLayout
android:id="@+id/rowMibTerms"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingVertical="12dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="16dp"
android:src="@drawable/mib_logo"
android:scaleType="fitCenter"
android:contentDescription="MIB" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Maldives Islamic Bank"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:src="@drawable/ic_arrow_right"
android:alpha="0.4"
android:contentDescription="@null" />
</LinearLayout>
<LinearLayout
android:id="@+id/rowBmlTerms"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingVertical="12dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="16dp"
android:src="@drawable/bml_icon"
android:scaleType="fitCenter"
android:contentDescription="BML" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Bank of Maldives"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:src="@drawable/ic_arrow_right"
android:alpha="0.4"
android:contentDescription="@null" />
</LinearLayout>
<LinearLayout
android:id="@+id/rowFahipayTerms"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingVertical="12dp"
android:background="?attr/selectableItemBackground">
<ImageView
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="16dp"
android:src="@drawable/fahipay_logo"
android:scaleType="fitCenter"
android:contentDescription="Fahipay" />
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Fahipay"
android:textAppearance="?attr/textAppearanceBodyLarge" />
<ImageView
android:layout_width="18dp"
android:layout_height="18dp"
android:src="@drawable/ic_arrow_right"
android:alpha="0.4"
android:contentDescription="@null" />
</LinearLayout>
<LinearLayout
android:id="@+id/sectionDonate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/colorOutlineVariant"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/about_donate_title"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginBottom="6dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/about_donate_desc"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:alpha="0.7"
android:layout_marginBottom="16dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDonateMvr"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
android:text="@string/about_donate_mvr" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnDonateUsd"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/about_donate_usd" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</ScrollView>
@@ -35,6 +35,14 @@
android:layout_weight="1" android:layout_weight="1"
android:text="@string/settings_nav_drawer" /> android:text="@string/settings_nav_drawer" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnNavCircular"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/settings_nav_circular" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnNavBottom" android:id="@+id/btnNavBottom"
style="@style/Widget.Material3.Button.OutlinedButton" style="@style/Widget.Material3.Button.OutlinedButton"
@@ -70,6 +78,31 @@
</LinearLayout> </LinearLayout>
<!-- Circular nav shortcuts — shown only when circular nav is active -->
<LinearLayout
android:id="@+id/sectionCircularSlots"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginBottom="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/settings_circular_shortcuts"
android:textAppearance="?attr/textAppearanceTitleMedium"
android:layout_marginTop="8dp"
android:layout_marginBottom="12dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvCircularSlots"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
android:overScrollMode="never" />
</LinearLayout>
<!-- Bottom bar shortcuts — shown only when bottom nav is active --> <!-- Bottom bar shortcuts — shown only when bottom nav is active -->
<LinearLayout <LinearLayout
android:id="@+id/sectionBottomBarShortcuts" android:id="@+id/sectionBottomBarShortcuts"
@@ -182,6 +182,55 @@
</LinearLayout> </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> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>
+9 -9
View File
@@ -26,15 +26,6 @@
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="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 <ImageButton
android:id="@+id/btnEditContact" android:id="@+id/btnEditContact"
android:layout_width="36dp" android:layout_width="36dp"
@@ -54,6 +45,15 @@
android:tint="?attr/colorError" android:tint="?attr/colorError"
android:contentDescription="@string/contact_delete" /> 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>
<LinearLayout <LinearLayout
+25
View File
@@ -0,0 +1,25 @@
<?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>
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- Drag handle -->
<View
android:layout_width="36dp"
android:layout_height="4dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="12dp"
android:layout_marginBottom="4dp"
android:background="@drawable/drag_handle_bg" />
<!-- Header row -->
<LinearLayout
android:id="@+id/notifHeader"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingHorizontal="20dp"
android:paddingVertical="8dp">
<TextView
android:id="@+id/tvNotifTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="Notifications"
android:textAppearance="?attr/textAppearanceTitleLarge"
android:textStyle="bold" />
<TextView
android:id="@+id/btnMarkAllRead"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Mark all read"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:padding="8dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:focusable="true"
android:clickable="true"
android:textColor="?attr/colorPrimary" />
</LinearLayout>
<!-- Divider -->
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:alpha="0.12"
android:background="?attr/colorOnSurface" />
<!-- Tabs -->
<com.google.android.material.tabs.TabLayout
android:id="@+id/notifTabs"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabMode="fixed"
app:tabGravity="fill" />
<!-- Pager fills remaining height -->
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/notifPager"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.button.MaterialButton
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minWidth="0dp"
android:minHeight="0dp"
android:paddingTop="14dp"
android:paddingBottom="14dp"
android:text="@string/cancel"
android:textSize="13sp"
app:icon="@drawable/ic_block"
app:iconSize="22dp"
app:iconGravity="top"
app:iconPadding="6dp" />
+6
View File
@@ -2,6 +2,12 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android" <menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_notifications"
android:icon="@drawable/ic_bell_read"
android:title="Notifications"
app:showAsAction="always" />
<item <item
android:id="@+id/action_hide_amounts" android:id="@+id/action_hide_amounts"
android:icon="@drawable/ic_visibility" android:icon="@drawable/ic_visibility"
+1
View File
@@ -4,4 +4,5 @@
<color name="seed_primary">#3F65AD</color> <color name="seed_primary">#3F65AD</color>
<color name="seed_secondary">#9AD141</color> <color name="seed_secondary">#9AD141</color>
<color name="color_unpaid">#E85D04</color> <color name="color_unpaid">#E85D04</color>
<color name="ic_logo_background">#E8B547</color>
</resources> </resources>
+49 -3
View File
@@ -35,6 +35,7 @@
<string name="password">Password</string> <string name="password">Password</string>
<string name="otp_seed">OTP Seed (TOTP Secret)</string> <string name="otp_seed">OTP Seed (TOTP Secret)</string>
<string name="otp_seed_hint">The Base32 secret from your authenticator setup</string> <string name="otp_seed_hint">The Base32 secret from your authenticator setup</string>
<string name="scan_otp_qr">Scan OTP QR</string>
<string name="login">Login</string> <string name="login">Login</string>
<!-- Lock screen --> <!-- Lock screen -->
@@ -138,7 +139,7 @@
<string name="settings_biometrics">Use biometrics</string> <string name="settings_biometrics">Use biometrics</string>
<string name="settings_biometrics_unavailable">No biometrics enrolled on this device</string> <string name="settings_biometrics_unavailable">No biometrics enrolled on this device</string>
<string name="settings_biometrics_unlock">To unlock app</string> <string name="settings_biometrics_unlock">To unlock app</string>
<string name="settings_biometrics_transfer">Confirm transfer</string> <string name="settings_biometrics_transfer">Confirm transaction</string>
<string name="biometric_transfer_title">Confirm Transfer</string> <string name="biometric_transfer_title">Confirm Transfer</string>
<string name="settings_autolock">Auto-lock</string> <string name="settings_autolock">Auto-lock</string>
<string name="autolock_off">Off</string> <string name="autolock_off">Off</string>
@@ -175,7 +176,9 @@
<string name="settings_navigation">Navigation</string> <string name="settings_navigation">Navigation</string>
<string name="settings_nav_drawer">Drawer</string> <string name="settings_nav_drawer">Drawer</string>
<string name="settings_nav_bottom">Bottom Bar</string> <string name="settings_nav_bottom">Bottom Bar</string>
<string name="settings_nav_circular">Circular</string>
<string name="settings_appearance">Appearance</string> <string name="settings_appearance">Appearance</string>
<string name="settings_circular_shortcuts">Circular Nav Shortcuts</string>
<string name="settings_bottom_bar_shortcuts">Bottom Bar Shortcuts</string> <string name="settings_bottom_bar_shortcuts">Bottom Bar Shortcuts</string>
<string name="settings_bottom_bar_show_labels">Always show bottom bar labels</string> <string name="settings_bottom_bar_show_labels">Always show bottom bar labels</string>
<string name="settings_bottom_bar_select">Choose button</string> <string name="settings_bottom_bar_select">Choose button</string>
@@ -189,6 +192,27 @@
<string name="settings_desc_appearance">Theme, language, and display options</string> <string name="settings_desc_appearance">Theme, language, and display options</string>
<string name="settings_desc_privacy_security">App lock, PIN, and security preferences</string> <string name="settings_desc_privacy_security">App lock, PIN, and security preferences</string>
<string name="settings_desc_storage">Manage cached data and storage usage</string> <string name="settings_desc_storage">Manage cached data and storage usage</string>
<string name="settings_notifications">Notifications</string>
<string name="settings_desc_notifications">Background alerts for new bank activity</string>
<string name="settings_notif_section">Background Polling</string>
<string name="settings_notif_enable">Enable background notifications</string>
<string name="settings_notif_enable_desc">Receive alerts for new transactions and activity</string>
<string name="settings_notif_description">Keeps the app running in the background and notifies you of new bank activity. A persistent status bar notification is shown while active — you can silence or hide it in notification channels.</string>
<string name="settings_notif_open_system">Notification channels</string>
<string name="settings_notif_channels_desc">Manage sounds, alerts, and silence the background service notification</string>
<string name="notif_service_title">Thijooree</string>
<string name="notif_service_desc">Checking for new bank notifications</string>
<string name="notif_channel_service">Background service</string>
<string name="settings_about">About</string>
<string name="settings_desc_about">App info, version, and legal</string>
<string name="about_version">Version %s</string>
<string name="about_short_desc">Thijooree is a native Android client for Maldivian banking services.</string>
<string name="about_terms">Terms of Service</string>
<string name="about_donate_title">Support Development</string>
<string name="about_donate_desc">If you find this app useful, a small donation goes a long way in keeping it alive and improving.</string>
<string name="about_donate_mvr">Donate in MVR</string>
<string name="about_donate_usd">Donate in USD</string>
<string name="about_legal">Thijooree is an independent, third-party app. It is not affiliated with, endorsed by, or officially supported by any bank or financial institution.\n\nThis app works by logging into your internet banking services directly using your credentials and communicating with bank APIs. It is built using techniques derived from reverse-engineered official bank apps. Behaviour may change or break without notice if banks update their systems.\n\nDhiraagu and Ooredoo APIs are used to look up details about phone numbers.\n\nThis app does not collect any analytics, telemetry, or personal data, and does not transmit any information about you or your usage to the developer. Everything stays entirely on your device.</string>
<string name="settings_logout">Log out</string> <string name="settings_logout">Log out</string>
<string name="settings_logout_confirm_title">Log out of %s?</string> <string name="settings_logout_confirm_title">Log out of %s?</string>
<string name="settings_logout_confirm_message">All cached data will be cleared and remaining accounts will be refreshed.</string> <string name="settings_logout_confirm_message">All cached data will be cleared and remaining accounts will be refreshed.</string>
@@ -238,14 +262,16 @@
<string name="transfer_lookup_account">Look up account</string> <string name="transfer_lookup_account">Look up account</string>
<string name="transfer_clear_recipient">Clear recipient</string> <string name="transfer_clear_recipient">Clear recipient</string>
<string name="transfer_pick_contact">Pick contact</string> <string name="transfer_pick_contact">Pick contact</string>
<string name="transfer_scan_qr">Scan QR</string> <string name="transfer_scan_qr">Scan to Pay</string>
<string name="qr_pick_image">Pick image</string> <string name="qr_pick_image">Pick image</string>
<string name="transfer_qr_invalid">Invalid or unsupported QR code</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_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="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="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="go_to_settings">Go to Settings</string>
<string name="transfer_select_source_first">Select a source account first</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_enter_account_first">Enter an account number first</string>
<string name="transfer_account_not_found">Account not found</string> <string name="transfer_account_not_found">Account not found</string>
<string name="transfer_session_unavailable">Session unavailable — please re-login</string> <string name="transfer_session_unavailable">Session unavailable — please re-login</string>
@@ -254,7 +280,8 @@
<string name="transfer_confirm">Confirm</string> <string name="transfer_confirm">Confirm</string>
<string name="transfer_success">Transfer Successful</string> <string name="transfer_success">Transfer Successful</string>
<string name="transfer_bml_contact_required_title">Contact Required</string> <string name="transfer_bml_contact_required_title">Contact Required</string>
<string name="transfer_bml_contact_required_msg">To send USD to a MIB account from BML, the recipient must be saved as a BML contact first. This is required by BML\'s API.\n\nPlease add this account as a BML contact, then try again.</string> <string name="transfer_bml_contact_required_msg">We couldn\'t verify the recipient\'s currency for this transfer.\n\nPlease save them as a contact, manually select the correct currency, then try again.</string>
<string name="transfer_bml_contact_required_msg_bml_limit">BML\'s API requires the recipient to be saved as a contact when sending USD to a non-BML account, even though we verified the account.\n\nPlease save them as a contact, then try again.</string>
<string name="transfer_missing_internal_id">Account data is incomplete — please re-login to refresh.</string> <string name="transfer_missing_internal_id">Account data is incomplete — please re-login to refresh.</string>
<string name="transfer_verify_payment">Verify Payment</string> <string name="transfer_verify_payment">Verify Payment</string>
<string name="transfer_send_otp_via">Send verification code via</string> <string name="transfer_send_otp_via">Send verification code via</string>
@@ -330,11 +357,30 @@
<string name="card_pay_qr">Scan to Pay</string> <string name="card_pay_qr">Scan to Pay</string>
<string name="card_pay_nfc">Tap 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="mib_qr_nfc_not_supported">Skill issue on MIB side, Not supported</string>
<string name="nfc_unsupported_title">Not Supported</string>
<string name="nfc_unsupported_message">Tap to Pay is not supported on this device.</string>
<string name="nfc_disabled_title">NFC is Off</string>
<string name="nfc_disabled_message">Turn on NFC to use Tap to Pay.</string>
<string name="nfc_open_settings">NFC Settings</string>
<string name="nfc_not_default_title">Set Default Payment App</string>
<string name="nfc_not_default_message">Set %1$s as the default contactless payment app to use Tap to Pay.</string>
<string name="nfc_payment_open_settings">Payment Settings</string>
<string name="card_manage">Manage Card</string> <string name="card_manage">Manage Card</string>
<string name="card_set_as_default">Set as Default 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_change_pin">Change PIN</string>
<string name="card_action_freeze">Freeze</string> <string name="card_action_freeze">Freeze</string>
<string name="card_action_unfreeze">Unfreeze</string>
<string name="card_action_block">Block</string> <string name="card_action_block">Block</string>
<string name="card_freeze_confirm_title">Freeze card?</string>
<string name="card_freeze_confirm_message">This will temporarily stop the card from being used. You can unfreeze it anytime you want to use it again.</string>
<string name="card_unfreeze_confirm_title">Unfreeze card?</string>
<string name="card_unfreeze_confirm_message">This will re-enable the card for transactions.</string>
<string name="card_freeze_success">Card frozen</string>
<string name="card_unfreeze_success">Card unfrozen</string>
<string name="card_freeze_failed">Failed to update card status</string>
<string name="card_freeze_comments_hint">Reason (optional)</string>
<string name="card_status_temp_blocked">Temporary blocked by client</string>
<string name="cards_empty">No cards found</string> <string name="cards_empty">No cards found</string>
<!-- Connectivity banner --> <!-- Connectivity banner -->
+24
View File
@@ -0,0 +1,24 @@
<?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>
<shortcut <shortcut
android:shortcutId="pay_with_card" android:shortcutId="tap_to_pay"
android:enabled="true" android:enabled="true"
android:icon="@drawable/ic_shortcut_pay_card" android:icon="@drawable/ic_shortcut_pay_card"
android:shortcutShortLabel="@string/nav_pay_with_card" android:shortcutShortLabel="@string/card_pay_nfc"
android:shortcutLongLabel="@string/nav_pay_with_card"> android:shortcutLongLabel="@string/card_pay_nfc">
<intent <intent
android:action="sh.sar.basedbank.OPEN_PAY_WITH_CARD" android:action="sh.sar.basedbank.TAP_TO_PAY"
android:targetPackage="sh.sar.basedbank" android:targetPackage="sh.sar.basedbank"
android:targetClass="sh.sar.basedbank.MainActivity" /> android:targetClass="sh.sar.basedbank.MainActivity" />
<categories android:name="android.shortcut.conversation" /> <categories android:name="android.shortcut.conversation" />
+21
View File
@@ -0,0 +1,21 @@
# 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 |
+10
View File
@@ -211,6 +211,16 @@ GET https://www.bankofmaldives.com.mv/internetbanking/web/profile
`302` redirect (to `/web/redirect` or similar). The server has auto-activated the sole profile and set the `blaze_identity` cookie. Skip Step 6 and proceed directly to [OAuth Token Exchange](03-oauth-token.md). `302` redirect (to `/web/redirect` or similar). The server has auto-activated the sole profile and set the `blaze_identity` cookie. Skip Step 6 and proceed directly to [OAuth Token Exchange](03-oauth-token.md).
In this fast-path the client has no real `profile_id` to track, so a placeholder `BmlProfile` is synthesized (`BmlLoginFlow.kt:127-135`):
| Field | Value |
|---|---|
| `profileId` | `username` (used as a stable temporary ID — replaced by the real customer number after `fetchUserInfo`) |
| `name` | `"Personal"` |
| `type` | `"Profile"` |
| `profileType` | `"default"` |
| `autoActivated` | `true` — sentinel; `activateProfile` skips the Step 6 GET and goes straight to OAuth |
--- ---
## Step 6 — Activate Profile ## Step 6 — Activate Profile
+4 -2
View File
@@ -135,19 +135,21 @@ POST https://www.bankofmaldives.com.mv/internetbanking/oauth/token
**Content-Type:** `application/x-www-form-urlencoded` **Content-Type:** `application/x-www-form-urlencoded`
**HTTP `User-Agent` header**: the browser/web UA (same as the initial token exchange), not the app UA. See `BmlLoginFlow.kt:341`.
| Field | Value | | Field | Value |
|---|---| |---|---|
| `grant_type` | `refresh_token` | | `grant_type` | `refresh_token` |
| `refresh_token` | Stored refresh token | | `refresh_token` | Stored refresh token |
| `client_id` | `98C83590-513F-4716-B02B-EC68B7D9E7E7` | | `client_id` | `98C83590-513F-4716-B02B-EC68B7D9E7E7` |
| `Device-ID` | Same device ID from the original login | | `Device-ID` | Same device ID from the original login |
| `User-Agent` | App user agent string | | `User-Agent` | App user agent string (form field — distinct from the HTTP header above) |
| `x-app-version` | `2.1.44.348` | | `x-app-version` | `2.1.44.348` |
```bash ```bash
curl --request POST \ curl --request POST \
--url 'https://www.bankofmaldives.com.mv/internetbanking/oauth/token' \ --url 'https://www.bankofmaldives.com.mv/internetbanking/oauth/token' \
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \ --header 'User-Agent: Mozilla/5.0 (Linux; Android {version}; {model}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/... Mobile Safari/537.36' \
--data 'grant_type=refresh_token' \ --data 'grant_type=refresh_token' \
--data 'refresh_token=def50200aabbcc...' \ --data 'refresh_token=def50200aabbcc...' \
--data 'client_id=98C83590-513F-4716-B02B-EC68B7D9E7E7' \ --data 'client_id=98C83590-513F-4716-B02B-EC68B7D9E7E7' \
+11 -6
View File
@@ -78,7 +78,7 @@ curl --request GET \
"currency": "MVR", "currency": "MVR",
"account_status": "Active", "account_status": "Active",
"prepaid_card": false, "prepaid_card": false,
"product_code": "VISA", "product_code": "C1007",
"account_visible": false, "account_visible": false,
"cardBalance": { "cardBalance": {
"AvailableLimit": 0.0, "AvailableLimit": 0.0,
@@ -94,7 +94,7 @@ curl --request GET \
"currency": "MVR", "currency": "MVR",
"account_status": "Active", "account_status": "Active",
"prepaid_card": true, "prepaid_card": true,
"product_code": "VISA", "product_code": "C1007",
"account_visible": true, "account_visible": true,
"cardBalance": { "cardBalance": {
"AvailableLimit": 200.00, "AvailableLimit": 200.00,
@@ -164,7 +164,7 @@ curl --request GET \
| Field | Type | Description | | Field | Type | Description |
|---|---|---| |---|---|---|
| `prepaid_card` | `bool` | `true` for prepaid cards | | `prepaid_card` | `bool` | `true` for prepaid cards |
| `product_code` | `string` | Card scheme (e.g. `"VISA"`) | | `product_code` | `string` | BML product code, always `Cxxxx` (e.g. `"C1007"`) — see mapping below |
| `account_visible` | `bool` | `true` for credit cards, `false` for debit cards | | `account_visible` | `bool` | `true` for credit cards, `false` for debit cards |
| `cardBalance.AvailableLimit` | `number` | Available credit/prepaid balance | | `cardBalance.AvailableLimit` | `number` | Available credit/prepaid balance |
| `cardBalance.CurrentBalance` | `number` | Current outstanding balance | | `cardBalance.CurrentBalance` | `number` | Current outstanding balance |
@@ -179,7 +179,12 @@ curl --request GET \
### Product Code → Card Name ### Product Code → Card Name
The `product_code` field identifies the specific card product. Known mappings: The `product_code` field identifies the specific card product. Resolution is two-tiered (`util/bmlapi/BmlCardParser.kt`):
1. **Network icon** (Visa / Mastercard / Amex chip in the corner) — by prefix: `C1*` → Visa, `C3*` → Amex, `C8*` → Mastercard, with `C8905` and `C8995` overridden to Visa.
2. **Card image asset** — by exact-match list below; unknown codes fall back to `defaultcard.png`.
Known asset mappings:
| `product_code` | Card name | Asset | | `product_code` | Card name | Asset |
|---|---|---| |---|---|---|
@@ -202,12 +207,12 @@ The `product_code` field identifies the specific card product. Known mappings:
| `C3009`, `C3019`, `C3029`, `C3099`, `C3088`, `C3188` | Amex Credit Gold | `amex_credit_gold` | | `C3009`, `C3019`, `C3029`, `C3099`, `C3088`, `C3188` | Amex Credit Gold | `amex_credit_gold` |
| `C1001`, `C1011`, `C1082`, `C1081`, `C1101`, `C1111`, `C1181`, `C1182` | Visa Debit Generic | `visa_debit_generic` | | `C1001`, `C1011`, `C1082`, `C1081`, `C1101`, `C1111`, `C1181`, `C1182` | Visa Debit Generic | `visa_debit_generic` |
| `C1003`, `C1013`, `C1083`, `C1084`, `C1103`, `C1113`, `C1183`, `C1184` | Visa Gold | `visa_gold` | | `C1003`, `C1013`, `C1083`, `C1084`, `C1103`, `C1113`, `C1183`, `C1184` | Visa Gold | `visa_gold` |
| `C1005`, `C1006`, `C1089` | Visa Debit Islamic | `visa_debit_islamic` | | `C1005`, `C1006`, `C1030`, `C1089` | Visa Debit Islamic | `visa_debit_islamic` |
| `C1007`, `C1027`, `C1097`, `C1107`, `C1197`, `C1077`, `C1177` | Visa Debit | `visa_debit` | | `C1007`, `C1027`, `C1097`, `C1107`, `C1197`, `C1077`, `C1177` | Visa Debit | `visa_debit` |
| `C1009`, `C1019`, `C1085`, `C1086`, `C1109`, `C1119`, `C1185`, `C1186` | Visa Platinum | `visa_platinum` | | `C1009`, `C1019`, `C1085`, `C1086`, `C1109`, `C1119`, `C1185`, `C1186` | Visa Platinum | `visa_platinum` |
| `C1017` | Visa Infinite | `visa_infinite` | | `C1017` | Visa Infinite | `visa_infinite` |
| `C1020`, `C1021` | Visa Debit Platinum | `visa_debit_platinum` | | `C1020`, `C1021` | Visa Debit Platinum | `visa_debit_platinum` |
| `C1030`, `C1090`, `C1130`, `C1033`, `C1133` | Visa Corporate | `visa_corporate` | | `C1090`, `C1130`, `C1033`, `C1133` | Visa Corporate | `visa_corporate` |
| `C1040`, `C1041`, `C1047`, `C1048`, `C1050`, `C1051`, `C1087`, `C1088`, `C1140`, `C1141`, `C1147`, `C1148`, `C1150`, `C1151`, `C1187`, `C1188` | Visa Student Black | `visa_student_black` | | `C1040`, `C1041`, `C1047`, `C1048`, `C1050`, `C1051`, `C1087`, `C1088`, `C1140`, `C1141`, `C1147`, `C1148`, `C1150`, `C1151`, `C1187`, `C1188` | Visa Student Black | `visa_student_black` |
| `C1059`, `C1062`, `C1070`, `C1072`, `C1159`, `C1162` | Mastercard Prepaid Business | `master_prepaid_business` | | `C1059`, `C1062`, `C1070`, `C1072`, `C1159`, `C1162` | Mastercard Prepaid Business | `master_prepaid_business` |
| `C1061`, `C1063`, `C1071`, `C1073`, `C1161`, `C1163` | Mastercard | `master` | | `C1061`, `C1063`, `C1071`, `C1073`, `C1161`, `C1163` | Mastercard | `master` |
+70
View File
@@ -187,6 +187,76 @@ Fall back to `bookingDate` as-is.
--- ---
## Pending History
Locked / pending amounts for a CASA account (e.g. unsettled card authorisations, holds). Returned as a flat list — no pagination.
### Endpoint
```
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/history/pending/{accountId}
```
| Path parameter | Description |
|---|---|
| `accountId` | Internal account ID (`id` field from [dashboard](04-dashboard.md)) |
### 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/history/pending/abc123def456' \
--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": [
{
"LockedID": "L00012345",
"FromDate": "2026-05-16",
"LockedAmount": 75.00,
"Description": "Card authorisation — Merchant Name"
}
]
}
```
### Response Fields
| Field | Type | Description |
|---|---|---|
| `success` | `bool` | `true` on success |
| `payload` | `array` | List of pending entries (top-level array, **not** under `payload.history`) |
### Pending Entry
| Field | Type | Description |
|---|---|---|
| `LockedID` | `string` | Unique ID for the pending entry |
| `FromDate` | `string` | Date the hold was placed |
| `LockedAmount` | `number` | Held amount — always a positive number on the wire; the client treats it as a **debit** by negating (`BmlHistoryClient.kt:184`) |
| `Description` | `string` | Free-form description (counterparty/merchant) |
> **Amount sign:** the server returns `LockedAmount` as a positive number with no debit/credit indicator. All pending entries are debits (funds reserved out of the available balance), so the client negates the value before display.
> **Currency:** not returned by the server. The client assumes MVR.
Called from `AccountHistoryFragment.kt:263` to populate the pending tab of the account history view.
---
&nbsp; &nbsp;
--- ---

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