85 Commits

Author SHA1 Message Date
shihaam da85a31bc6 release version 1.0.9
Auto Tag on Version Change / check-version (push) Successful in 4s
Build and Release APK / build (push) Successful in 3m53s
2026-05-28 02:18:38 +05:00
shihaam d292e73fd9 added support for custom per-profile image for BML and Fahipay, MIB works pending
Auto Tag on Version Change / check-version (push) Successful in 7s
2026-05-28 02:18:01 +05:00
shihaam 3d632606a0 quality of life features: logo and account type shown in trasfer page and contact picker and my accounts in contact picker, also money amount is dispayed bigger
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-28 01:14:20 +05:00
shihaam 6daeb5f72e Bug fix: contacts page infinite loading without internet
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-28 00:19:22 +05:00
shihaam c4d3c1efd4 better network error handling, fix crash when no network in transaction history page
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-28 00:14:11 +05:00
shihaam 0560c53ae3 Show no accounts found text when there are no accounts in cache
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-27 23:42:05 +05:00
shihaam a37454de00 improve clearing cache and logout (it was showing logged-out account info on dashboard
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-27 23:37:34 +05:00
shihaam daf9b0475a add zoom QR and flashlight button
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-27 23:07:01 +05:00
shihaam c4ad35e6b9 Fix bug: transfer source drop down automatically closing to update profile image
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-27 22:40:05 +05:00
shihaam 3e8ea90701 handle server timeouts instead of crashing
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-27 22:14:31 +05:00
shihaam ef919aa179 show bank/profile image in accounts and drop down
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-27 22:00:47 +05:00
shihaam c98a3e3e89 show card network in source account drop down
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-27 21:35:27 +05:00
shihaam 0654c711d6 bug fix: nav bar buttons disappearing after some updates
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-27 21:28:19 +05:00
shihaam b67368c94a unified pay with QR and tranfer confirm dialog box
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-27 21:22:04 +05:00
shihaam a6e7e61b58 added support for QR payments from BML gateway
Auto Tag on Version Change / check-version (push) Failing after 13s
2026-05-27 21:08:01 +05:00
shihaam e974a95708 added support for static QR payments from BML cards
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-27 20:32:17 +05:00
shihaam de11fbe0d3 skill issue on mib
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-27 19:00:12 +05:00
shihaam 5d8ab76477 update docs
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-27 18:35:42 +05:00
shihaam d637877167 update docs
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-27 18:04:39 +05:00
shihaam ea227bf3b9 impprove ci performance
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-24 00:29:40 +05:00
shihaam 6b3131069e update docs
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-24 00:27:59 +05:00
shihaam 8037ce3f02 Merge pull request 'add product group mapping for cards' (#4) from fix/bmlapi-card-parser-add-missing-product-groups into main
Auto Tag on Version Change / check-version (push) Successful in 4s
Reviewed-on: #4
2026-05-24 00:04:47 +05:00
flamexode cecf0bedfc add product group mapping for cards 2026-05-23 23:56:15 +05:00
shihaam 256f216da4 update docs
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-23 23:46:00 +05:00
shihaam 0a27de4a34 update bml api docs
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-23 23:33:31 +05:00
shihaam a3f8852163 some android studio bs
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-23 23:13:07 +05:00
shihaam 8e345746ed pending finaces on dashboard is now a button that takes you to finaces page 2026-05-23 23:12:50 +05:00
shihaam 473e051282 release v1.0.8
Auto Tag on Version Change / check-version (push) Successful in 6s
Build and Release APK / build (push) Successful in 4m6s
2026-05-23 22:52:27 +05:00
shihaam f9c182fe9a fix weird error on failed pin
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-23 22:47:47 +05:00
shihaam 339dae8a37 hide money value in transfer drop down with privacy mode on
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-23 22:42:48 +05:00
shihaam a6a1f28144 disable transfer button when there is issue with source bank or connectivity
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-23 22:31:27 +05:00
shihaam 523d1248bd add connetivity banners
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-23 22:23:54 +05:00
shihaam ee9f98b720 fix caching reading issue when refreshed without internet
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-23 21:49:27 +05:00
shihaam 219ca9bf00 add more card support, include credit cards in accounts
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-23 21:21:01 +05:00
shihaam e9f0cec698 compress mib cards and add prep support for bml cards
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-23 21:03:25 +05:00
shihaam 268f3dada0 fix useragents to give out actual device model os version and etc
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-22 20:50:50 +05:00
shihaam e0a554c769 fix useragents to give out actual device model os version and etc
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-22 06:53:07 +05:00
shihaam 94b280a177 version 1.0.7
Auto Tag on Version Change / check-version (push) Successful in 4s
Build and Release APK / build (push) Successful in 4m55s
2026-05-22 06:43:36 +05:00
shihaam 88c9f153e5 rm temp file 2026-05-22 06:43:11 +05:00
shihaam eb7da01b2e auto and lazy load cards to dashbaord
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-22 06:42:43 +05:00
shihaam 27270f1b7a auto unlock on correct pin
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-22 06:39:59 +05:00
shihaam fd7fcb41a6 added transfer support for bml business profiles
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-22 06:31:21 +05:00
shihaam c9ae614fc7 prep support for transfers for bml business accounts)
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-22 06:21:20 +05:00
shihaam b784085605 optimize bml refresh flow
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-22 06:01:13 +05:00
shihaam 01e5c17284 move refresh indicator to action bar to fix ui shifting
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-22 05:14:46 +05:00
shihaam 6d3c7036b5 rebranding
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-22 05:05:57 +05:00
shihaam 804712d22d cards on dashboard now
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-22 04:28:51 +05:00
shihaam f208ee6ad1 optimze mib cards loading
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-22 03:55:59 +05:00
shihaam 51dbed94d4 bug fix: paymv qr page emptu space
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-22 03:40:14 +05:00
shihaam 0b5a452046 exclude bml loans from dashboard total, transfer from and paymvQR
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-22 03:22:50 +05:00
shihaam 00297da71e Revert "fix bug that allowed to skip password setup during inital setup"
Auto Tag on Version Change / check-version (push) Successful in 3s
This reverts commit 1602d061c1.
2026-05-22 03:07:34 +05:00
shihaam 1602d061c1 fix bug that allowed to skip password setup during inital setup
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-22 03:01:21 +05:00
shihaam ddd64e8624 descriptive menus
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-22 02:03:20 +05:00
shihaam 77f367844d rework back butotn 2026-05-22 01:50:12 +05:00
shihaam e2729b1d1a add support for fetching mib cards
Auto Tag on Version Change / check-version (push) Successful in 7s
2026-05-22 01:40:14 +05:00
shihaam 105518e147 release version 1.0.6
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m52s
2026-05-21 23:24:14 +05:00
shihaam 38570615dd optmize dashboard (seperate credit section, bars for spending limits
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-21 23:23:45 +05:00
shihaam e82218e897 added support for BML loans
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-21 22:58:58 +05:00
shihaam 50150b826f remove auto lock off and optimize session keepalive for mib
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-21 22:31:58 +05:00
shihaam 2d705457f8 animate lock and eye icons in action bar (top)
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-21 01:37:37 +05:00
shihaam f03e23062b you can now hold to copy text from recipts even in full screen mode
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-21 01:04:23 +05:00
shihaam 58f1b9fd6f release v1.0.5
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 4m58s
2026-05-21 00:44:12 +05:00
shihaam 240d04ad74 Optmize business profile logins.. save privacy mode settings on launch
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-21 00:43:23 +05:00
shihaam fe507073b1 Optmize business profile logins.. save privacy mode settings on launch 2026-05-21 00:43:05 +05:00
shihaam 6d48c27391 huge refactor.. might need to revert later
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-20 22:43:29 +05:00
shihaam e894f81887 remove temp logo files
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-20 21:59:04 +05:00
shihaam acc1278b34 card history fix
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-20 01:10:35 +05:00
shihaam bc678d26ad fixes for bml contact addd drop down and loading contacts
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-20 00:53:08 +05:00
shihaam bb2a80a5e3 more edging non edge fixes
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-20 00:37:02 +05:00
shihaam b107358266 toggle to enable or disable privacy mode and also privacy mode toggle
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-20 00:29:06 +05:00
shihaam 02a53c8219 fix single profile multi login
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-20 00:02:36 +05:00
shihaam 15a02cac1c patial support for BML business profile accounts
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-19 23:30:36 +05:00
shihaam 35a1748055 add save and share icons 2026-05-19 22:24:44 +05:00
shihaam 28682bba41 half baked PayMV QR generate support
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-19 21:59:05 +05:00
shihaam 25484addfb rename activites to recent transfers and transfer history to transaction history
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-19 20:16:29 +05:00
shihaam 728c7d2aa3 view recipts full screen by pressing empty area, copy values by holding it
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-19 19:56:56 +05:00
shihaam b24949c117 add support to view previous transfer recipts 2026-05-19 19:39:20 +05:00
shihaam 28e5878668 sync bottom bar when customizing bottom bar and switch checkbox to toggles in mib logins
Auto Tag on Version Change / check-version (push) Successful in 14s
2026-05-19 19:23:03 +05:00
shihaam b1e73533f6 version 1.0.4
Auto Tag on Version Change / check-version (push) Successful in 3s
Build and Release APK / build (push) Successful in 3m45s
2026-05-19 18:26:19 +05:00
shihaam 3a5b9459a9 customize quick actions and bottom bar
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-19 18:21:36 +05:00
shihaam 9c9729e268 UI/edgetoedge non edge screen fixes
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-19 17:24:01 +05:00
shihaam 399cfbf108 transfer button enables after you fill required feilds
Auto Tag on Version Change / check-version (push) Successful in 2s
2026-05-19 16:50:49 +05:00
shihaam 19f4d01015 add long bml logo
Auto Tag on Version Change / check-version (push) Successful in 3s
2026-05-19 16:39:44 +05:00
shihaam 8c40322ff0 fahipay serialized multi-login added
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-19 16:13:36 +05:00
shihaam 782e2e7674 refactor codebase to be more module for later adding new banks.. add support for single profile mib accounts.. add suport for disabling mib profiles in settings
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-19 14:48:04 +05:00
237 changed files with 15310 additions and 4332 deletions
+1 -3
View File
@@ -1,11 +1,9 @@
services: services:
release: release:
# image: git.shihaam.dev/dockerfiles/android-builder
image: git.shihaam.dev/dockerfiles/runners/gradle image: git.shihaam.dev/dockerfiles/runners/gradle
hostname: isodroid
network_mode: host network_mode: host
env_file: .env env_file: .env
volumes: volumes:
- ./release:/release - ./release:/release
- ../../:/source - ../../:/source
# - /root/.cache/cache-runners/gradle:/root/.gradle - /root/.cache/cache-runners/gradle:/root/.gradle
+4 -4
View File
@@ -3,11 +3,11 @@
<component name="deploymentTargetSelector"> <component name="deploymentTargetSelector">
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DIALOG" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-15T13:54:16.798188666Z"> <DropdownSelection timestamp="2026-05-22T00:11:32.873305232Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" /> <DeviceId pluginId="PhysicalDevice" identifier="serial=683a9830" />
</handle> </handle>
</Target> </Target>
</DropdownSelection> </DropdownSelection>
@@ -15,7 +15,7 @@
<targets> <targets>
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=ce61d84c" /> <DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=67d022c2" />
</handle> </handle>
</Target> </Target>
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>
+9 -50
View File
@@ -1,6 +1,6 @@
# BasedBank # Thijooree
A unified Android banking app for Maldivians that combines MIB (Faisanet), BML (Bank of Maldives), and Fahipay into a single interface — with no analytics, no tracking, and no phone-home behaviour outside the banks themselves. A native Android client for Maldivian banking services. It is a pure client: requests go directly from your device to the banks' own servers using the same protocols as their official apps. No proxy, no backend, no middleman.
[![AI Slop Inside](https://sladge.net/badge.svg)](https://sladge.net) [![AI Slop Inside](https://sladge.net/badge.svg)](https://sladge.net)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE)
@@ -8,60 +8,14 @@ A unified Android banking app for Maldivians that combines MIB (Faisanet), BML (
![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?logo=jetpackcompose&logoColor=white) ![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?logo=jetpackcompose&logoColor=white)
![Maintained](https://img.shields.io/badge/Maintained-yes-green.svg) ![Maintained](https://img.shields.io/badge/Maintained-yes-green.svg)
## What it does
- **Multi-bank dashboard** — view balances across all your MIB, BML, and Fahipay accounts in one place, with a combined MVR and USD total
- **Transaction history** — paginated, searchable transaction history per account for MIB CASA, BML CASA, BML prepaid cards, and Fahipay wallet
- **Transfers** — send money between accounts and to saved contacts; supports MIB-to-MIB, BML-to-BML, and cross-bank (MIB↔BML via FAVARA)
- **Contacts** — manage saved beneficiaries across all banks; validates Dhiraagu and Ooredoo numbers and shows the account owner name before you add
- **Fahipay** — full wallet support including balance, history with merchant icons, and Fahipay favourites (Raastas, Reload, Ooredoo Bill, Dhiraagu Bill)
- **QR payments** — scan PayMV QR codes to pre-fill transfers
- **BML foreign limits** — view your foreign currency spending allowances and breakdowns by ATM / POS / ECOM
- **MIB financing** — view active financing deals
## Authentication
The app requires your existing credentials for each bank — the same username/password/OTP seed you use with the official apps. It stores them encrypted using AES-256-GCM backed by the Android Keystore (hardware secure enclave).
Each bank's 2FA uses TOTP, so you need to have your OTP seed (the same secret used by your authenticator app).
## Security
- All credentials encrypted at rest with **AES-256-GCM** (Android Keystore)
- Lock screen protected by **PBKDF2-HMAC-SHA256** (100,000 iterations) with optional biometric unlock
- **FLAG_SECURE** on by default — content hidden in app switcher and screenshots blocked
- All sensitive data excluded from Android cloud backup
- Zero analytics, crash reporters, or third-party SDKs — network traffic goes only to MIB, BML, Fahipay, and the Maldivian telecoms for number validation
See [`docs/AI_SECURITY_CHECK.md`](docs/AI_SECURITY_CHECK.md) for the full security audit.
## Supported banks
| Bank | Login | Accounts | History | Transfers | Contacts |
|---|---|---|---|---|---|
| MIB (Faisanet) | username + password + TOTP | ✓ | ✓ | ✓ | ✓ |
| BML (Bank of Maldives) | username + password + TOTP | ✓ | ✓ | ✓ | ✓ |
| Fahipay | national ID + password + TOTP | ✓ | ✓ | — | ✓ (favourites) |
## Requirements ## Requirements
- Android 8.0+ (API 26) - Android 8.0+ (API 26)
- 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
## Building ## Download
[Download latest APK](https://git.shihaam.dev/shihaam/ISODroid/releases/latest)
Open in Android Studio and run. No API keys or secrets required — all protocol constants are derived from the official apps and are included in the source.
The release signing config reads from environment variables (`KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`).
## How it works
BasedBank talks directly to each bank's existing mobile API using the same protocol as their official apps, reverse-engineered from the APKs. It does not use any intermediary server — requests go straight from your device to the bank.
- **MIB**: Blowfish/ECB encrypted JSON over HTTPS with a Diffie-Hellman session key exchange
- **BML**: PKCE OAuth 2.0 flow via the BML web login, exchanged for a Bearer token used on the mobile API
- **Fahipay**: multipart form login with TOTP, session maintained via `__Secure-sess` cookie and `authid` header
## Privacy ## Privacy
@@ -70,3 +24,8 @@ No data ever leaves your device except the API calls to the banking services the
## 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.
## License
GNU General Public License v3.0 - See [LICENSE](LICENSE) file for details
+5 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "sh.sar.basedbank" applicationId = "sh.sar.basedbank"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 2 versionCode = 8
versionName = "1.0.3" versionName = "1.0.9"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -73,6 +73,9 @@ dependencies {
// Coroutines // Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
// ZXing core for QR code generation
implementation("com.google.zxing:core:3.5.3")
// QR scanning — CameraX + zxing-cpp (MIT, same stack as BinaryEye) // QR scanning — CameraX + zxing-cpp (MIT, same stack as BinaryEye)
implementation("androidx.camera:camera-core:1.4.2") implementation("androidx.camera:camera-core:1.4.2")
implementation("androidx.camera:camera-camera2:1.4.2") implementation("androidx.camera:camera-camera2:1.4.2")
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

@@ -4,9 +4,11 @@ import android.app.Application
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import com.google.android.material.color.DynamicColors import com.google.android.material.color.DynamicColors
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.bml.BmlSession import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.fahipay.FahipaySession import sh.sar.basedbank.api.fahipay.FahipaySession
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.api.mib.MibProfile import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.MibSession import sh.sar.basedbank.api.mib.MibSession
@@ -15,35 +17,95 @@ import sh.sar.basedbank.util.CredentialStore
class BasedBankApp : Application() { class BasedBankApp : Application() {
// Held in memory after successful login; cleared on logout // Held in memory after successful login; cleared on logout
var accounts: List<MibAccount> = emptyList() var accounts: List<BankAccount> = emptyList()
var fullName: String = "" var fullName: String = ""
var mibSession: MibSession? = null
var mibProfiles: List<MibProfile> = emptyList()
/** Active BML sessions keyed by loginId (= BML username). */
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
var bmlAccounts: List<MibAccount> = emptyList()
var fahipaySession: FahipaySession? = null
var fahipayAccounts: List<MibAccount> = emptyList()
/** Returns the BML session for the given account (matched via loginTag). */ /** Active MIB sessions keyed by loginId (= MIB username). */
fun bmlSessionFor(account: MibAccount): BmlSession? = val mibSessions: MutableMap<String, MibSession> = mutableMapOf()
bmlSessions[account.loginTag.removePrefix("bml_")] val mibProfilesMap: MutableMap<String, List<MibProfile>> = mutableMapOf()
val mibLoginFlows: MutableMap<String, MibLoginFlow> = mutableMapOf()
var mibAccounts: List<BankAccount> = emptyList()
/**
* Active BML sessions keyed by profileId (a globally unique GUID per BML profile).
* Use [bmlSessionFor] to look up the session for an account.
*/
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
/** BML profiles per loginId (= BML username). */
val bmlProfilesMap: MutableMap<String, List<BmlProfile>> = mutableMapOf()
/** BML login flows per loginId — hold the web session (cookies) needed for profile activation. */
val bmlLoginFlows: MutableMap<String, BmlLoginFlow> = mutableMapOf()
var bmlAccounts: List<BankAccount> = emptyList()
/** Active Fahipay sessions keyed by loginId (= profileId). */
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
var fahipayAccounts: List<BankAccount> = emptyList()
// ─── MIB helpers ──────────────────────────────────────────────────────────
/** Returns the MIB session for the given account (matched via loginTag). */
fun mibSessionFor(account: BankAccount): MibSession? =
mibSessions[account.loginTag.removePrefix("mib_")]
/** Returns any available MIB session. */
fun anyMibSession(): MibSession? = mibSessions.values.firstOrNull()
/** Returns all MIB profiles across all logins. */
fun allMibProfiles(): List<MibProfile> = mibProfilesMap.values.flatten()
/** Returns the MibLoginFlow for a given loginId, creating and caching it if needed. */
fun mibFlowFor(loginId: String): MibLoginFlow =
mibLoginFlows.getOrPut(loginId) {
MibLoginFlow(CredentialStore(this)).also { flow ->
flow.onSessionRefreshed = { session, profiles ->
mibSessions[loginId] = session
mibProfilesMap[loginId] = profiles
}
}
}
/** Returns any available MibLoginFlow. */
fun anyMibFlow(): MibLoginFlow? = mibLoginFlows.values.firstOrNull()
// ─── BML helpers ──────────────────────────────────────────────────────────
/**
* Returns the BML session for the given account.
* Looks up by profileId first (multi-profile), falls back to loginId (legacy single-profile).
*/
fun bmlSessionFor(account: BankAccount): BmlSession? {
val byProfile = if (account.profileId.isNotBlank()) bmlSessions[account.profileId] else null
return byProfile ?: bmlSessions[account.loginTag.removePrefix("bml_")]
}
/** Returns any available BML session (for non-account-specific operations). */ /** Returns any available BML session (for non-account-specific operations). */
fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull() fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull()
/**
* Returns any active BML session for the given loginId.
* Tries all profiles for that login; falls back to legacy loginId key.
*/
fun anyBmlSessionFor(loginId: String): BmlSession? {
val profiles = bmlProfilesMap[loginId]
if (!profiles.isNullOrEmpty()) {
return profiles.firstNotNullOfOrNull { bmlSessions[it.profileId] }
}
return bmlSessions[loginId]
}
/** Returns the BmlLoginFlow for a given loginId, creating and caching it if needed. */
fun bmlFlowFor(loginId: String): BmlLoginFlow =
bmlLoginFlows.getOrPut(loginId) { BmlLoginFlow() }
// ─── Fahipay helpers ──────────────────────────────────────────────────────
/** Returns the Fahipay session for the given account (matched via loginTag = "fahipay_${profileId}"). */
fun fahipaySessionFor(account: BankAccount): FahipaySession? =
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */ /** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
val mibMutex = Mutex() val mibMutex = Mutex()
val mibLoginFlow by lazy {
MibLoginFlow(CredentialStore(this)).also { flow ->
flow.onSessionRefreshed = { session, profiles ->
mibSession = session
mibProfiles = profiles
}
}
}
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
DynamicColors.applyToActivitiesIfAvailable(this) DynamicColors.applyToActivitiesIfAvailable(this)
@@ -32,6 +32,8 @@ class LockActivity : AppCompatActivity() {
private lateinit var salt: String private lateinit var salt: String
private lateinit var storedHash: String private lateinit var storedHash: String
private var biometricsEnabled = false private var biometricsEnabled = false
private var autoUnlockPin = false
private var pinLength = 4
private var isVerifying = false private var isVerifying = false
private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE) private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE)
@@ -61,6 +63,8 @@ class LockActivity : AppCompatActivity() {
val prefs = getSharedPreferences("prefs", MODE_PRIVATE) val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
method = prefs.getString("security_method", "pin") ?: "pin" method = prefs.getString("security_method", "pin") ?: "pin"
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false) biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
autoUnlockPin = prefs.getBoolean("auto_unlock_pin", false)
pinLength = prefs.getInt("pin_length", 4)
val stored = CredentialStore(this).loadSecurityHash() ?: run { finish(); return } val stored = CredentialStore(this).loadSecurityHash() ?: run { finish(); return }
salt = stored.first salt = stored.first
@@ -134,13 +138,18 @@ class LockActivity : AppCompatActivity() {
when (key) { when (key) {
"" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() } "" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() }
"" -> if (pinDigits.size >= 4) verifyPin() "" -> if (pinDigits.size >= 4) verifyPin()
else -> if (pinDigits.size < 8) { pinDigits.add(key.toInt()); updateDots() } else -> if (pinDigits.size < 8) {
pinDigits.add(key.toInt())
updateDots()
if (autoUnlockPin && pinDigits.size == pinLength) verifyPin()
}
} }
} }
private fun updateDots() { private fun updateDots() {
val n = pinDigits.size val n = pinDigits.size
binding.tvLockPinDots.text = "".repeat(n) + "".repeat(maxOf(4 - n, 0)) val total = if (autoUnlockPin) pinLength else maxOf(n, 4)
binding.tvLockPinDots.text = "".repeat(n) + "".repeat(maxOf(total - n, 0))
} }
private fun verifyPin() { private fun verifyPin() {
@@ -194,15 +203,15 @@ class LockActivity : AppCompatActivity() {
if (remaining <= 0) return false if (remaining <= 0) return false
val secs = ((remaining + 999L) / 1000L).toInt() val secs = ((remaining + 999L) / 1000L).toInt()
val msg = getString(R.string.unlock_locked_out, secs) val msg = getString(R.string.unlock_locked_out, secs)
binding.tvLockPinDots.text = msg binding.tvPinHint.text = msg
binding.root.postDelayed({ updateDots() }, remaining) binding.root.postDelayed({ binding.tvPinHint.text = ""; updateDots() }, remaining)
return true return true
} }
private fun showFailure() { private fun showFailure() {
val msg = failureMessage() val msg = failureMessage()
binding.tvLockPinDots.text = msg binding.tvPinHint.text = msg
binding.root.postDelayed({ updateDots() }, 1200) binding.root.postDelayed({ binding.tvPinHint.text = ""; updateDots() }, 1200)
} }
private fun failureMessage(): String { private fun failureMessage(): String {
@@ -0,0 +1,207 @@
package sh.sar.basedbank.api.bml
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankServerException
data class BmlUserInfo(
val fullName: String,
val email: String,
val mobile: String,
val customerId: String,
val idCard: String,
val birthdate: String
)
class BmlAccountClient {
private val client = newBmlApiClient()
fun fetchAccounts(
session: BmlSession,
loginTag: String,
profileName: String = "Personal",
profileId: String = ""
): List<BankAccount> {
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/dashboard")).execute()
val code = resp.code
val json = resp.body?.string()
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
if (code in 500..599) throw BankServerException("BML")
return parseDashboard(json ?: return emptyList(), loginTag, profileName, profileId)
}
/** Lightweight call to verify the session is alive. Throws [AuthExpiredException] on 401/419. */
fun checkProfile(session: BmlSession) {
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/profile")).execute()
val code = resp.code
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
if (code in 500..599) throw BankServerException("BML")
}
fun fetchUserInfo(session: BmlSession): BmlUserInfo? {
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/userinfo")).execute()
val json = resp.body?.string() ?: return null
resp.close()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return null
val user = root.optJSONObject("payload")?.optJSONObject("user") ?: return null
BmlUserInfo(
fullName = user.optString("fullname").trim(),
email = user.optString("email").trim(),
mobile = user.optString("mobile_phone").trim(),
customerId = user.optString("customer_number").trim(),
idCard = user.optString("idcard").trim(),
birthdate = user.optString("birthdate").trim()
)
} catch (_: Exception) { null }
}
fun fetchLoanDetail(session: BmlSession, internalId: String): BmlLoanDetail? {
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/account/$internalId")).execute()
val code = resp.code
val json = resp.body?.string() ?: return null
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return null
val p = root.optJSONObject("payload") ?: return null
BmlLoanDetail(
loanAmount = p.optDouble("loanAmount", 0.0),
outstandingAmt = p.optDouble("outstandingAmt", 0.0),
repayAmount = p.optDouble("repayAmount", 0.0),
intRate = p.optDouble("intRate", 0.0),
loanStatus = p.optString("loanStatus"),
startDate = p.optString("startDate"),
endDate = p.optString("endDate"),
noOfRepayOverdue = p.optInt("noOfRepayOverdue", 0),
overdueAmount = p.optDouble("overdueAmount", 0.0)
)
} catch (_: Exception) { null }
}
fun fetchTransferChannels(session: BmlSession): List<BmlOtpChannel> {
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/transfer")).execute()
val json = resp.body?.string() ?: run { resp.close(); return emptyList() }
resp.close()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val arr = root.optJSONObject("payload")
?.optJSONObject("transfer")
?.optJSONArray("otpChannel") ?: return emptyList()
(0 until arr.length()).map { i ->
val ch = arr.getJSONObject(i)
BmlOtpChannel(
channel = ch.optString("channel"),
description = ch.optString("description"),
masked = ch.optString("masked")
)
}
} catch (_: Exception) { emptyList() }
}
private fun parseDashboard(
json: String,
loginTag: String,
profileName: String,
profileId: String
): List<BankAccount> {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
val casaAccounts = mutableListOf<BankAccount>()
val prepaidCards = mutableListOf<BankAccount>()
val loanAccounts = mutableListOf<BankAccount>()
for (i in 0 until dashboard.length()) {
val item = dashboard.getJSONObject(i)
val currency = item.optString("currency", "MVR")
val accountType = item.optString("account_type", "CASA")
val product = item.optString("product")
val accountNumber = item.optString("account")
val status = item.optString("account_status", "Active")
val internalId = item.optString("id", "")
if (accountType == "CASA") {
val available = item.optDouble("availableBalance", 0.0)
casaAccounts.add(BankAccount(
bank = "BML",
profileName = profileName,
profileType = "BML",
accountNumber = accountNumber,
accountBriefName = item.optString("alias"),
currencyName = currency,
accountTypeName = product,
availableBalance = "%.2f".format(available),
currentBalance = "%.2f".format(item.optDouble("ledgerBalance", 0.0)),
blockedAmount = "%.2f".format(item.optDouble("lockedAmount", 0.0)),
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
statusDesc = status,
profileImageHash = null,
loginTag = loginTag,
profileId = profileId,
internalId = internalId
))
} else if (accountType == "Loan") {
val outstanding = Math.abs(item.optDouble("availableBalance", 0.0))
loanAccounts.add(BankAccount(
bank = "BML",
profileName = profileName,
profileType = "BML_LOAN",
accountNumber = accountNumber,
accountBriefName = item.optString("alias"),
currencyName = currency,
accountTypeName = product,
availableBalance = "%.2f".format(outstanding),
currentBalance = "%.2f".format(outstanding),
blockedAmount = "0.00",
mvrBalance = "0.00",
statusDesc = status,
profileImageHash = null,
loginTag = loginTag,
profileId = profileId,
internalId = internalId
))
} else if (accountType == "Card") {
val isPrepaid = item.optBoolean("prepaid_card", false)
val productCode = item.optString("product_code", "")
val cardBalance = item.optJSONObject("cardBalance")
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
val isVisible = item.optBoolean("account_visible", false)
val cardProfileType = when {
isPrepaid -> "BML_PREPAID"
isVisible -> "BML_CREDIT" // non-prepaid, visible = credit card
else -> "BML_DEBIT" // non-prepaid, not visible = debit card
}
prepaidCards.add(BankAccount(
bank = "BML",
profileName = profileName,
profileType = cardProfileType,
productCode = productCode,
accountNumber = accountNumber,
accountBriefName = item.optString("alias").ifBlank { product },
currencyName = currency,
accountTypeName = product,
availableBalance = "%.2f".format(available),
currentBalance = "%.2f".format(current),
blockedAmount = "0.00",
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
statusDesc = status,
profileImageHash = null,
loginTag = loginTag,
profileId = profileId,
internalId = internalId
))
}
}
return casaAccounts + prepaidCards + loanAccounts
}
}
@@ -0,0 +1,22 @@
package sh.sar.basedbank.api.bml
import android.os.Build
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
internal val BML_USER_AGENT = "bml-mobile-banking/348 (${Build.MANUFACTURER}; Android ${Build.VERSION.RELEASE}; ${Build.MODEL})"
internal const val BML_APP_VERSION = "2.1.44.348"
internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
internal fun bmlApiRequest(session: BmlSession, url: String): Request =
Request.Builder().url(url)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.build()
@@ -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.JSONArray
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankContact
class BmlContactsClient {
private val client = newBmlApiClient()
fun fetchContacts(session: BmlSession, loginId: String): List<BankContact> {
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/contacts")).execute()
val json = resp.body?.string() ?: return emptyList()
resp.close()
return parseContacts(json, loginId)
}
fun saveContact(
session: BmlSession,
contactType: String,
account: String,
alias: String,
currency: String? = null,
name: String? = null,
swift: String? = null
): Boolean {
val bodyObj = JSONObject().apply {
put("contact_type", contactType)
put("account", account)
put("alias", alias)
if (currency != null) put("currency", currency)
if (name != null) put("name", name)
if (swift != null) put("swift", swift)
}
val resp = client.newCall(
Request.Builder().url("$BML_BASE_URL/api/mobile/contacts")
.post(bodyObj.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("Accept", "application/json")
.build()
).execute()
val json = resp.body?.string() ?: return false
resp.close()
return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false }
}
fun deleteContact(session: BmlSession, contactId: String): Boolean {
val body = """{"_method":"delete"}""".toRequestBody("application/json".toMediaType())
val resp = client.newCall(
Request.Builder().url("$BML_BASE_URL/api/mobile/contacts/$contactId")
.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()
).execute()
val bodyStr = resp.body?.string() ?: return false
resp.close()
return try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
}
private fun parseContacts(json: String, loginId: String): List<BankContact> {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList()
val result = mutableListOf<BankContact>()
for (i in 0 until payload.length()) {
val item = payload.getJSONObject(i)
val account = item.optString("account", "")
if (account.isBlank()) continue
result.add(BankContact(
benefNo = "bml_${item.optInt("id")}",
benefName = item.optString("name"),
benefNickName = item.optString("alias", item.optString("name")),
benefAccount = account,
benefType = "I",
bankColor = "#0066A1",
benefBankName = "Bank of Maldives",
bankCode = "",
benefStatus = item.optString("status", "S"),
transferCyDesc = item.optString("currency", "MVR"),
customerImgHash = null,
benefCategoryId = "BML",
profileId = loginId
))
}
return result
}
}
@@ -0,0 +1,64 @@
package sh.sar.basedbank.api.bml
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class BmlForeignLimitsClient {
// Foreign limits use a different host than the main BML API
private val BASE_URL = "https://app.bankofmaldives.com.mv/api/v2"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
val resp = client.newCall(
Request.Builder().url("$BASE_URL/foreign-limits")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("Accept", "application/json")
.build()
).execute()
val code = resp.code
val json = resp.body?.string()
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return parseForeignLimits(json ?: return emptyList())
}
private fun parseForeignLimits(json: String): List<BmlForeignLimit> {
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val payload = root.optJSONArray("payload") ?: return emptyList()
(0 until payload.length()).map { i ->
val item = payload.getJSONObject(i)
val usage = item.optJSONObject("usageByCategory") ?: JSONObject()
val atm = usage.optJSONObject("ATM") ?: JSONObject()
val ecom = usage.optJSONObject("ECOM") ?: JSONObject()
val pos = usage.optJSONObject("POS") ?: JSONObject()
BmlForeignLimit(
type = item.optString("type", "Debit"),
used = item.optDouble("used", 0.0),
totalLimit = item.optDouble("totalLimit", 0.0),
generalCap = item.optDouble("generalCap", 0.0),
generalRemaining = item.optDouble("generalRemaining", 0.0),
medicalRemaining = item.optDouble("medicalRemaining", 0.0),
isAtmEnabled = item.optBoolean("isAtmEnabled", false),
isPosEnabled = item.optBoolean("isPosEnabled", false),
atmRemaining = atm.optDouble("remaining", 0.0),
atmLimit = atm.optDouble("limit", 0.0),
ecomRemaining = ecom.optDouble("remaining", 0.0),
ecomLimit = ecom.optDouble("limit", 0.0),
posRemaining = pos.optDouble("remaining", 0.0),
posLimit = pos.optDouble("limit", 0.0)
)
}
} catch (_: Exception) { emptyList() }
}
}
@@ -0,0 +1,177 @@
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
import sh.sar.basedbank.api.models.BankTransaction
import java.text.SimpleDateFormat
import java.util.Locale
class BmlHistoryClient {
private val client = newBmlApiClient()
fun fetchAccountHistory(
session: BmlSession,
accountId: String,
accountDisplayName: String,
accountNumber: String,
page: Int
): Pair<List<BankTransaction>, Int> {
val resp = client.newCall(
Request.Builder().url("$BML_BASE_URL/api/mobile/account/$accountId/history/$page")
.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 Pair(emptyList(), 0)
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 Pair(emptyList(), 0)
val payload = root.optJSONObject("payload") ?: return Pair(emptyList(), 0)
val totalPages = payload.optInt("totalPages", 0)
val history = payload.optJSONArray("history") ?: return Pair(emptyList(), totalPages)
val transactions = (0 until history.length()).map { i ->
val item = history.getJSONObject(i)
val desc = item.optString("description").trim()
val narrative1 = item.optString("narrative1")
val date = when (desc) {
"Purchase" -> parsePurchaseNarrative1(narrative1) ?: item.optString("bookingDate")
"Transfer Debit", "Transfer Credit" -> parseTransferNarrative1(narrative1) ?: item.optString("bookingDate")
else -> item.optString("bookingDate")
}
BankTransaction(
id = item.optString("id"),
date = date,
description = desc,
amount = item.optDouble("amount", 0.0),
currency = item.optString("currency"),
counterpartyName = item.optString("narrative2").takeIf { it.isNotBlank() },
reference = item.optString("reference").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML"
)
}
Pair(transactions, totalPages)
} catch (_: Exception) { Pair(emptyList(), 0) }
}
fun fetchCardHistory(
session: BmlSession,
cardId: String,
accountDisplayName: String,
accountNumber: String,
month: String
): List<BankTransaction> {
val body = """{"card":"$cardId","month":"$month"}"""
.toRequestBody("application/json".toMediaType())
val resp = client.newCall(
Request.Builder().url("$BML_BASE_URL/api/mobile/card/statement").post(body)
.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()
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 emptyList()
val payload = root.optJSONObject("payload") ?: return emptyList()
val result = mutableListOf<BankTransaction>()
val authDetails = payload.optJSONObject("outstanding")
?.optJSONArray("CardOutStdAuthDetails")
if (authDetails != null) {
for (i in 0 until authDetails.length()) {
val item = authDetails.getJSONObject(i)
result.add(BankTransaction(
id = "auth_${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 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() }
}
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
private fun parsePurchaseNarrative1(narrative1: String): String? {
return try {
val parts = narrative1.split(" ")
if (parts.size < 2) null
else {
val timePart = parts[1].take(4)
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
}
} catch (_: Exception) { null }
}
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
private fun parseTransferNarrative1(narrative1: String): String? {
return try {
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
} catch (_: Exception) { null }
}
}
@@ -7,19 +7,15 @@ import okhttp3.FormBody
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.util.Totp import sh.sar.basedbank.util.Totp
import java.security.MessageDigest import java.security.MessageDigest
import java.security.SecureRandom import java.security.SecureRandom
import java.text.SimpleDateFormat
import java.util.Base64 import java.util.Base64
import java.util.Locale
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class AuthExpiredException : Exception("Session expired") class AuthExpiredException : Exception("Session expired")
@@ -29,9 +25,9 @@ class BmlLoginFlow {
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking" private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7" private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback" private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
private val APP_USER_AGENT = "bml-mobile-banking/345 (POCO; Android 14; 22101320I)" private val APP_USER_AGENT = "bml-mobile-banking/348 (${android.os.Build.MANUFACTURER}; Android ${android.os.Build.VERSION.RELEASE}; ${android.os.Build.MODEL})"
private val APP_VERSION = "2.1.43.345" private val APP_VERSION = "2.1.44.348"
private val WEB_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0" private val WEB_USER_AGENT = "Mozilla/5.0 (Android ${android.os.Build.VERSION.RELEASE}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>() private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
private val cookieJar = object : CookieJar { private val cookieJar = object : CookieJar {
@@ -53,14 +49,27 @@ class BmlLoginFlow {
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.build() .build()
private val apiClient = OkHttpClient.Builder() /** PKCE params — generated once per login and reused across all profile activations. */
.connectTimeout(30, TimeUnit.SECONDS) private var codeVerifier: String = ""
.readTimeout(30, TimeUnit.SECONDS) private var codeChallenge: String = ""
.build() private var deviceId: String = ""
/** Full login: returns a BmlSession and the account list. */ /** Profiles returned by the last successful [login] call. */
fun login(username: String, password: String, otpSeed: String): Pair<BmlSession, List<MibAccount>> { var lastProfiles: List<BmlProfile> = emptyList()
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session cookies private set
// ─── Login ────────────────────────────────────────────────────────────────
/**
* Performs web authentication (login + TOTP) and returns the list of available profiles.
* Call [activateProfile] for each profile to obtain an access token + accounts.
*/
fun login(username: String, password: String, otpSeed: String): List<BmlProfile> {
codeVerifier = generateCodeVerifier()
codeChallenge = generateCodeChallenge(codeVerifier)
deviceId = generateDeviceId()
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session
client.newCall( client.newCall(
Request.Builder().url("$BASE_URL/web/login") Request.Builder().url("$BASE_URL/web/login")
.header("User-Agent", WEB_USER_AGENT).build() .header("User-Agent", WEB_USER_AGENT).build()
@@ -82,15 +91,14 @@ class BmlLoginFlow {
loginResp.close() loginResp.close()
if (loginResp.code != 302) throw Exception("Login failed — check your username/password") if (loginResp.code != 302) throw Exception("Login failed — check your username/password")
// Step 3: GET 2FA page (refreshes blaze_session) // Step 3: GET 2FA page (refreshes session cookies)
client.newCall( client.newCall(
Request.Builder().url("$BASE_URL/web/login/2fa") Request.Builder().url("$BASE_URL/web/login/2fa")
.header("X-XSRF-TOKEN", xsrf)
.header("User-Agent", WEB_USER_AGENT).build() .header("User-Agent", WEB_USER_AGENT).build()
).execute().close() ).execute().close()
val xsrf2 = xsrfToken() ?: xsrf val xsrf2 = xsrfToken() ?: xsrf
// Step 4: POST OTP // Step 4: POST TOTP
val otp = Totp.generate(otpSeed) val otp = Totp.generate(otpSeed)
val twoFaBody = JSONObject().apply { val twoFaBody = JSONObject().apply {
put("code", otp) put("code", otp)
@@ -104,18 +112,161 @@ class BmlLoginFlow {
twoFaResp.close() twoFaResp.close()
if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed") if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed")
// Step 5: GET /web/profile (sets blaze_identity cookie for profile selection) // Step 5: GET /web/profile — multi-profile accounts return a 200 with a profile picker;
client.newCall( // single-profile accounts skip the picker and redirect straight to /web/redirect with
// blaze_identity already set in the response cookies.
val profileResp = client.newCall(
Request.Builder().url("$BASE_URL/web/profile") Request.Builder().url("$BASE_URL/web/profile")
.header("X-XSRF-TOKEN", xsrf2) .header("User-Agent", WEB_USER_AGENT).build()
).execute()
val profileCode = profileResp.code
val profileLocation = profileResp.header("Location") ?: ""
val profileBody = profileResp.body?.string() ?: ""
profileResp.close()
lastProfiles = if (profileCode == 302) {
// Any 302 from GET /web/profile means the server auto-activated the sole profile
// and blaze_identity is already set — no profile picker shown.
// Use username as a stable temporary profileId (unique per login); it will be
// replaced by the real BML customer ID after fetchUserInfo in finishBmlLogin().
listOf(BmlProfile(profileId = username, name = "Personal", type = "Profile", profileType = "default", autoActivated = true))
} else {
parseProfiles(profileBody)
}
return lastProfiles
}
// ─── Profile activation ───────────────────────────────────────────────────
/**
* Activates a profile in the current web session and returns the result.
*
* - Personal profiles (profile_type="default") succeed immediately and return [BmlActivationResult.Success].
* - Business profiles (profile_type="business") require SMS/email OTP; returns
* [BmlActivationResult.NeedsBusinessOtp] with available channels. Follow up with
* [requestBusinessOtp] + [submitBusinessOtp].
*/
fun activateProfile(profile: BmlProfile, loginTag: String): BmlActivationResult {
// Single-profile accounts: server already activated during login() and set blaze_identity.
// autoActivated=true is the sentinel for this case — skip the profile GET entirely.
if (profile.autoActivated) {
val (session, accounts) = doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
return BmlActivationResult.Success(session, accounts)
}
val xsrf = xsrfToken()
val reqBuilder = Request.Builder()
.url("$BASE_URL/web/profile/${profile.profileId}")
.header("User-Agent", WEB_USER_AGENT)
if (xsrf != null) reqBuilder.header("X-XSRF-TOKEN", xsrf)
val resp = client.newCall(reqBuilder.build()).execute()
val code = resp.code
val location = resp.header("Location") ?: ""
resp.close()
return when {
code == 409 || (code == 302 && "/web/profile/2fa/business" !in location) -> {
// Profile activated — blaze_identity cookie set in response headers.
// Any 302 that isn't to the business 2FA page means success.
val (session, accounts) = doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
BmlActivationResult.Success(session, accounts)
}
code == 302 && "/web/profile/2fa/business" in location -> {
// Business profile: server requires SMS/email OTP
val channels = fetchBusinessOtpChannels()
BmlActivationResult.NeedsBusinessOtp(channels)
}
else -> throw Exception("Profile activation failed (HTTP $code)")
}
}
/**
* Returns available OTP channels for the business 2FA page.
* Also refreshes cookies so the subsequent POST has a valid XSRF token.
*/
private fun fetchBusinessOtpChannels(): List<BmlOtpChannel> {
val resp = client.newCall(
Request.Builder().url("$BASE_URL/web/profile/2fa/business")
.header("User-Agent", WEB_USER_AGENT).build()
).execute()
val body = resp.body?.string() ?: ""
resp.close()
return parseBusinessOtpChannels(body)
}
/**
* Sends an OTP to [channel] for business profile activation.
* Must be called before [submitBusinessOtp].
*/
fun requestBusinessOtp(channel: String) {
val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
val body = JSONObject().apply {
put("code", "")
put("channel", channel)
}.toString().toRequestBody("application/json".toMediaType())
val resp = client.newCall(
Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body)
.header("X-XSRF-TOKEN", xsrf)
.header("User-Agent", WEB_USER_AGENT).build()
).execute()
val respCode = resp.code
resp.close()
if (respCode != 302) throw Exception("Failed to request OTP (HTTP $respCode)")
}
/**
* Verifies the OTP and activates the business profile.
* Returns a new [BmlSession] and accounts on success.
* @throws Exception if the OTP is invalid (retry is allowed).
*/
fun submitBusinessOtp(
channel: String,
code: String,
profile: BmlProfile,
loginTag: String
): Pair<BmlSession, List<BankAccount>> {
// Refresh XSRF token before submitting
client.newCall(
Request.Builder().url("$BASE_URL/web/profile/2fa/business")
.header("User-Agent", WEB_USER_AGENT).build() .header("User-Agent", WEB_USER_AGENT).build()
).execute().close() ).execute().close()
// Step 6: PKCE OAuth authorize → extract auth code val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
val codeVerifier = generateCodeVerifier() val body = JSONObject().apply {
val codeChallenge = generateCodeChallenge(codeVerifier) put("code", code)
val deviceId = generateDeviceId() put("channel", channel)
}.toString().toRequestBody("application/json".toMediaType())
val resp = client.newCall(
Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body)
.header("X-XSRF-TOKEN", xsrf)
.header("User-Agent", WEB_USER_AGENT).build()
).execute()
val respCode = resp.code
val location = resp.header("Location") ?: ""
resp.close()
return when {
respCode == 409 || (respCode == 302 && "/web/redirect" in location) ->
doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
respCode == 302 ->
throw Exception("Invalid OTP — please try again")
else ->
throw Exception("Business OTP verification failed (HTTP $respCode)")
}
}
// ─── OAuth + account fetch ────────────────────────────────────────────────
/**
* Completes PKCE OAuth for the currently activated profile (blaze_identity cookie set).
* Returns a fresh [BmlSession] and the profile's accounts.
*/
private fun doOAuthAndFetchAccounts(
loginTag: String,
profileName: String,
profileId: String
): Pair<BmlSession, List<BankAccount>> {
val authorizeUrl = HttpUrl.Builder() val authorizeUrl = HttpUrl.Builder()
.scheme("https").host("www.bankofmaldives.com.mv") .scheme("https").host("www.bankofmaldives.com.mv")
.addPathSegments("internetbanking/oauth/authorize") .addPathSegments("internetbanking/oauth/authorize")
@@ -135,14 +286,12 @@ class BmlLoginFlow {
Request.Builder().url(authorizeUrl) Request.Builder().url(authorizeUrl)
.header("User-Agent", WEB_USER_AGENT).build() .header("User-Agent", WEB_USER_AGENT).build()
).execute() ).execute()
val location = authorizeResp.header("Location")
authorizeResp.close() authorizeResp.close()
val location = authorizeResp.header("Location") val authCode = location?.let { Uri.parse(it).getQueryParameter("code") }
?: throw Exception("OAuth authorize did not redirect") ?: throw Exception("OAuth authorize did not return auth code")
val authCode = Uri.parse(location).getQueryParameter("code")
?: throw Exception("No auth code in OAuth redirect")
// Step 7: Exchange auth code for access token
val tokenBody = FormBody.Builder() val tokenBody = FormBody.Builder()
.add("Device-ID", deviceId) .add("Device-ID", deviceId)
.add("code", authCode) .add("code", authCode)
@@ -164,563 +313,95 @@ class BmlLoginFlow {
val tokenObj = JSONObject(tokenJson) val tokenObj = JSONObject(tokenJson)
val accessToken = tokenObj.optString("access_token") val accessToken = tokenObj.optString("access_token")
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed") .takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
val refreshToken = tokenObj.optString("refresh_token", "")
val expiresIn = tokenObj.optLong("expires_in", 0L)
val expiresAt = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000L else 0L
val session = BmlSession(accessToken = accessToken, deviceId = deviceId) val session = BmlSession(accessToken = accessToken, deviceId = deviceId, refreshToken = refreshToken, expiresAt = expiresAt)
val accounts = fetchAccounts(session, "bml_$username") val accounts = BmlAccountClient().fetchAccounts(session, loginTag, profileName, profileId)
return Pair(session, accounts) return Pair(session, accounts)
} }
// ─── Token refresh ───────────────────────────────────────────────────────
fun fetchAccounts(session: BmlSession, loginTag: String): List<MibAccount> {
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute()
val code = resp.code
val json = resp.body?.string()
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return parseDashboard(json ?: return emptyList(), loginTag)
}
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
val resp = apiClient.newCall(
Request.Builder().url("https://app.bankofmaldives.com.mv/api/v2/foreign-limits")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.header("Accept", "application/json")
.build()
).execute()
val code = resp.code
val json = resp.body?.string()
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return parseForeignLimits(json ?: return emptyList())
}
data class BmlUserInfo(
val fullName: String,
val email: String,
val mobile: String,
val customerId: String,
val idCard: String,
val birthdate: String
)
fun fetchUserInfo(session: BmlSession): BmlUserInfo? {
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/userinfo")).execute()
val json = resp.body?.string() ?: return null
resp.close()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return null
val user = root.optJSONObject("payload")?.optJSONObject("user") ?: return null
BmlUserInfo(
fullName = user.optString("fullname").trim(),
email = user.optString("email").trim(),
mobile = user.optString("mobile_phone").trim(),
customerId = user.optString("customer_number").trim(),
idCard = user.optString("idcard").trim(),
birthdate = user.optString("birthdate").trim()
)
} catch (_: Exception) { null }
}
fun validateAccount(session: BmlSession, input: String): BmlAccountValidation? {
val resp = apiClient.newCall(
Request.Builder().url("$BASE_URL/api/mobile/validate/account/$input")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.header("Accept", "application/json")
.build()
).execute()
val json = resp.body?.string() ?: return null
resp.close()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return null
val payload = root.optJSONObject("payload") ?: return null
val trnType = payload.optString("trnType", "")
val validationType = payload.optString("validationType", "")
if (validationType == "alias") {
val cdtrAcct = payload.optJSONObject("CdtrAcct") ?: return null
BmlAccountValidation(
trnType = trnType,
validationType = validationType,
account = cdtrAcct.optString("Acct"),
originalInput = input,
name = payload.optString("contact_name").trim(),
alias = null,
currency = payload.optString("currency", "MVR"),
agnt = cdtrAcct.optString("FinInstnId").takeIf { it.isNotBlank() }
)
} else {
BmlAccountValidation(
trnType = trnType,
validationType = validationType,
account = payload.optString("account"),
originalInput = input,
name = payload.optString("name"),
alias = payload.optString("alias").takeIf { it.isNotBlank() && it != "null" },
currency = payload.optString("currency", "MVR")
)
}
} catch (_: Exception) { null }
}
fun verifyMibAccount(session: BmlSession, account: String): BmlAccountValidation? {
val resp = apiClient.newCall(
Request.Builder().url("$BASE_URL/api/mobile/favara/account-verification/$account/MIB")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.header("Accept", "application/json")
.build()
).execute()
val json = resp.body?.string() ?: return null
resp.close()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return null
BmlAccountValidation(
trnType = "DOT",
validationType = "MIB",
account = root.optString("account"),
originalInput = account,
name = root.optString("name"),
alias = null,
currency = "MVR",
agnt = root.optString("agnt").takeIf { it.isNotBlank() }
)
} catch (_: Exception) { null }
}
fun saveContact(
session: BmlSession,
contactType: String,
account: String,
alias: String,
currency: String? = null,
name: String? = null,
swift: String? = null
): Boolean {
val bodyObj = JSONObject().apply {
put("contact_type", contactType)
put("account", account)
put("alias", alias)
if (currency != null) put("currency", currency)
if (name != null) put("name", name)
if (swift != null) put("swift", swift)
}
val resp = apiClient.newCall(
Request.Builder().url("$BASE_URL/api/mobile/contacts")
.post(bodyObj.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.header("Accept", "application/json")
.build()
).execute()
val json = resp.body?.string() ?: return false
resp.close()
return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false }
}
fun fetchContacts(session: BmlSession, loginId: String): List<MibBeneficiary> {
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/contacts")).execute()
val json = resp.body?.string() ?: return emptyList()
resp.close()
return parseContacts(json, loginId)
}
/** /**
* Step 1 of BML transfer: POST without OTP. Returns true if server responds code=22 (OTP ready). * Uses the saved refresh token to obtain a new access token without re-login.
* Returns a new [BmlSession] with updated tokens.
*/ */
fun initiateTransfer( fun refreshSession(session: BmlSession): BmlSession {
session: BmlSession, val body = FormBody.Builder()
debitAccount: String, .add("grant_type", "refresh_token")
creditAccount: String, .add("refresh_token", session.refreshToken)
amount: Double, .add("client_id", CLIENT_ID)
transferType: String, .add("Device-ID", session.deviceId)
currency: String, .add("User-Agent", APP_USER_AGENT)
bank: String? = null .add("x-app-version", APP_VERSION)
): Boolean {
val jo = JSONObject().apply {
put("debitAccount", debitAccount)
put("creditAccount", creditAccount)
put("debitAmount", amount)
put("transfertype", transferType)
put("currency", currency)
put("channel", "token")
if (bank != null) put("bank", bank)
}
val body = jo.toString().toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$BASE_URL/api/mobile/transfer")
.post(body)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.header("accept", "application/json")
.build() .build()
return apiClient.newCall(request).execute().use { response -> val resp = newBmlApiClient().newCall(
val bodyStr = response.body?.string() ?: return@use false Request.Builder().url("$BASE_URL/oauth/token").post(body)
try { .header("User-Agent", WEB_USER_AGENT).build()
val json = JSONObject(bodyStr)
json.optBoolean("success") && json.optInt("code") == 22
} catch (_: Exception) { false }
}
}
/**
* Step 2 of BML transfer: POST with OTP + remarks. Returns BmlTransferResult.
*/
fun confirmTransfer(
session: BmlSession,
debitAccount: String,
creditAccount: String,
amount: Double,
transferType: String,
currency: String,
otp: String,
remarks: String = "",
bank: String? = null
): BmlTransferResult {
val jo = JSONObject().apply {
put("debitAccount", debitAccount)
put("creditAccount", creditAccount)
put("debitAmount", amount)
put("transfertype", transferType)
put("currency", currency)
put("channel", "token")
put("otp", otp)
if (remarks.isNotBlank()) put("remarks", remarks)
if (bank != null) put("bank", bank)
}
val body = jo.toString().toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$BASE_URL/api/mobile/transfer")
.post(body)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.header("accept", "application/json")
.build()
return apiClient.newCall(request).execute().use { response ->
val bodyStr = response.body?.string()
?: return@use BmlTransferResult(false, errorMessage = "No response")
try {
val json = JSONObject(bodyStr)
if (!json.optBoolean("success")) {
BmlTransferResult(false, errorMessage = json.optString("message").ifBlank { "Transfer failed" })
} else {
val payload = json.optJSONObject("payload")
BmlTransferResult(
success = true,
reference = payload?.optString("reference") ?: "",
timestamp = payload?.optString("timestamp") ?: "",
message = json.optString("message")
)
}
} catch (_: Exception) { BmlTransferResult(false, errorMessage = "Parse error") }
}
}
fun deleteContact(session: BmlSession, contactId: String): Boolean {
val body = """{"_method":"delete"}""".toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$BASE_URL/api/mobile/contacts/$contactId")
.post(body)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.header("accept", "application/json")
.build()
return apiClient.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: return@use false
try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
}
}
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
private fun parsePurchaseNarrative1(narrative1: String): String? {
return try {
val parts = narrative1.split(" ")
if (parts.size < 2) null
else {
val timePart = parts[1].take(4)
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
}
} catch (_: Exception) { null }
}
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
private fun parseTransferNarrative1(narrative1: String): String? {
return try {
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
} catch (_: Exception) { null }
}
/**
* Fetches paginated transaction history for a BML CASA account.
* @return Pair of (transactions, totalPages)
*/
fun fetchAccountHistory(
session: BmlSession,
accountId: String,
accountDisplayName: String,
accountNumber: String,
page: Int
): Pair<List<Transaction>, Int> {
val resp = apiClient.newCall(
Request.Builder().url("$BASE_URL/api/mobile/account/$accountId/history/$page")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.build()
).execute() ).execute()
val code = resp.code val json = resp.body?.string() ?: throw Exception("Empty refresh response")
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
resp.close() resp.close()
if (code == 401 || code == 419) throw AuthExpiredException() val obj = JSONObject(json)
return try { val newAccess = obj.optString("access_token").takeIf { it.isNotBlank() }
val root = JSONObject(json) ?: throw Exception("Token refresh failed")
if (!root.optBoolean("success")) return Pair(emptyList(), 0) val newRefresh = obj.optString("refresh_token", "").ifBlank { session.refreshToken }
val payload = root.optJSONObject("payload") ?: return Pair(emptyList(), 0) val expiresIn = obj.optLong("expires_in", 0L)
val totalPages = payload.optInt("totalPages", 0) val expiresAt = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000L else 0L
val history = payload.optJSONArray("history") ?: return Pair(emptyList(), totalPages) return BmlSession(accessToken = newAccess, deviceId = session.deviceId, refreshToken = newRefresh, expiresAt = expiresAt)
val transactions = (0 until history.length()).map { i ->
val item = history.getJSONObject(i)
val desc = item.optString("description").trim()
val narrative1 = item.optString("narrative1")
val date = when (desc) {
"Purchase" -> parsePurchaseNarrative1(narrative1) ?: item.optString("bookingDate")
"Transfer Debit", "Transfer Credit" -> parseTransferNarrative1(narrative1) ?: item.optString("bookingDate")
else -> item.optString("bookingDate")
}
Transaction(
id = item.optString("id"),
date = date,
description = desc,
amount = item.optDouble("amount", 0.0),
currency = item.optString("currency"),
counterpartyName = item.optString("narrative2").takeIf { it.isNotBlank() },
reference = item.optString("reference").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "BML"
)
}
Pair(transactions, totalPages)
} catch (_: Exception) { Pair(emptyList(), 0) }
} }
// ─── Parsing ──────────────────────────────────────────────────────────────
/** /**
* Fetches card statement for a BML prepaid card for the given month ("YYYYMM"). * BML web responses are Inertia.js pages — the data is embedded as HTML-escaped JSON
* Returns combined outstanding authorizations + settled statement entries. * in the `data-page="..."` attribute of the root div. This extracts and unescapes it.
*/ */
fun fetchCardHistory( private fun extractInertiaJson(html: String): String? {
session: BmlSession, val match = Regex("""data-page="([^"]+)"""").find(html) ?: return null
cardId: String, return match.groupValues[1]
accountDisplayName: String, .replace("&quot;", "\"")
accountNumber: String, .replace("&amp;", "&")
month: String .replace("&#39;", "'")
): List<Transaction> { .replace("&lt;", "<")
val body = """{"card":"$cardId","month":"$month"}""" .replace("&gt;", ">")
.toRequestBody("application/json".toMediaType())
val resp = apiClient.newCall(
Request.Builder().url("$BASE_URL/api/mobile/card/statement").post(body)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.build()
).execute()
val code = resp.code
val json = resp.body?.string() ?: return emptyList()
resp.close()
if (code == 401 || code == 419) throw AuthExpiredException()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val payload = root.optJSONObject("payload") ?: return emptyList()
val result = mutableListOf<Transaction>()
// Outstanding authorizations
val authDetails = payload.optJSONObject("outstanding")
?.optJSONArray("CardOutStdAuthDetails")
if (authDetails != null) {
for (i in 0 until authDetails.length()) {
val item = authDetails.getJSONObject(i)
result.add(Transaction(
id = "auth_${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"
))
}
}
// Settled statement entries
val statement = payload.optJSONArray("cardstatement")
if (statement != null) {
for (i in 0 until statement.length()) {
val item = statement.getJSONObject(i)
result.add(Transaction(
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() }
} }
private fun apiRequest(session: BmlSession, url: String) = private fun parseProfiles(html: String): List<BmlProfile> {
Request.Builder().url(url)
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", APP_USER_AGENT)
.header("x-app-version", APP_VERSION)
.build()
private fun parseDashboard(json: String, loginTag: String): List<MibAccount> {
val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList()
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
val casaAccounts = mutableListOf<MibAccount>()
val prepaidCards = mutableListOf<MibAccount>()
for (i in 0 until dashboard.length()) {
val item = dashboard.getJSONObject(i)
val currency = item.optString("currency", "MVR")
val accountType = item.optString("account_type", "CASA")
val product = item.optString("product")
val accountNumber = item.optString("account")
val status = item.optString("account_status", "Active")
val internalId = item.optString("id", "")
if (accountType == "CASA") {
val available = item.optDouble("availableBalance", 0.0)
casaAccounts.add(MibAccount(
profileName = "Personal",
profileType = "BML",
accountNumber = accountNumber,
accountBriefName = item.optString("alias"),
currencyName = currency,
accountTypeName = product,
availableBalance = "%.2f".format(available),
currentBalance = "%.2f".format(item.optDouble("ledgerBalance", 0.0)),
blockedAmount = "%.2f".format(item.optDouble("lockedAmount", 0.0)),
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
statusDesc = status,
profileImageHash = null,
loginTag = loginTag,
internalId = internalId
))
} else if (accountType == "Card") {
val isVisible = item.optBoolean("account_visible", false)
if (!isVisible) continue // debit cards and other hidden cards — skip
val isPrepaid = item.optBoolean("prepaid_card", false)
val cardBalance = item.optJSONObject("cardBalance")
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
prepaidCards.add(MibAccount(
profileName = "Personal",
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
accountNumber = accountNumber,
accountBriefName = item.optString("alias").ifBlank { product },
currencyName = currency,
accountTypeName = product,
availableBalance = "%.2f".format(available),
currentBalance = "%.2f".format(current),
blockedAmount = "0.00",
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
statusDesc = status,
profileImageHash = null,
loginTag = loginTag,
internalId = internalId
))
}
}
return casaAccounts + prepaidCards
}
private fun parseForeignLimits(json: String): List<BmlForeignLimit> {
return try { return try {
val json = extractInertiaJson(html) ?: html
val root = JSONObject(json) val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList() val props = root.optJSONObject("props") ?: return emptyList()
val payload = root.optJSONArray("payload") ?: return emptyList() val profiles = props.optJSONArray("profiles") ?: return emptyList()
(0 until payload.length()).map { i -> (0 until profiles.length()).mapNotNull { i ->
val item = payload.getJSONObject(i) val p = profiles.getJSONObject(i)
val usage = item.optJSONObject("usageByCategory") ?: JSONObject() val profileObj = p.optJSONObject("profile") ?: return@mapNotNull null
val atm = usage.optJSONObject("ATM") ?: JSONObject() BmlProfile(
val ecom = usage.optJSONObject("ECOM") ?: JSONObject() profileId = p.optString("profile_id"),
val pos = usage.optJSONObject("POS") ?: JSONObject() name = p.optString("name"),
BmlForeignLimit( type = p.optString("type"),
type = item.optString("type", "Debit"), profileType = profileObj.optString("profile_type", "default")
used = item.optDouble("used", 0.0),
totalLimit = item.optDouble("totalLimit", 0.0),
generalCap = item.optDouble("generalCap", 0.0),
generalRemaining = item.optDouble("generalRemaining", 0.0),
medicalRemaining = item.optDouble("medicalRemaining", 0.0),
isAtmEnabled = item.optBoolean("isAtmEnabled", false),
isPosEnabled = item.optBoolean("isPosEnabled", false),
atmRemaining = atm.optDouble("remaining", 0.0),
atmLimit = atm.optDouble("limit", 0.0),
ecomRemaining = ecom.optDouble("remaining", 0.0),
ecomLimit = ecom.optDouble("limit", 0.0),
posRemaining = pos.optDouble("remaining", 0.0),
posLimit = pos.optDouble("limit", 0.0)
) )
} }
} catch (_: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
} }
private fun parseContacts(json: String, loginId: String = ""): List<MibBeneficiary> { private fun parseBusinessOtpChannels(html: String): List<BmlOtpChannel> {
val root = JSONObject(json) return try {
if (!root.optBoolean("success")) return emptyList() val json = extractInertiaJson(html) ?: html
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList() val root = JSONObject(json)
val result = mutableListOf<MibBeneficiary>() val props = root.optJSONObject("props") ?: return emptyList()
for (i in 0 until payload.length()) { val channels = props.optJSONArray("channels") ?: return emptyList()
val item = payload.getJSONObject(i) (0 until channels.length()).map { i ->
val account = item.optString("account", "") val c = channels.getJSONObject(i)
if (account.isBlank()) continue BmlOtpChannel(
result.add(MibBeneficiary( channel = c.optString("channel"),
benefNo = "bml_${item.optInt("id")}", description = c.optString("description"),
benefName = item.optString("name"), masked = c.optString("masked")
benefNickName = item.optString("alias", item.optString("name")), )
benefAccount = account, }
benefType = "I", } catch (_: Exception) { emptyList() }
bankColor = "#0066A1",
benefBankName = "Bank of Maldives",
bankCode = "",
benefStatus = item.optString("status", "S"),
transferCyDesc = item.optString("currency", "MVR"),
customerImgHash = null,
benefCategoryId = "BML",
profileId = loginId
))
}
return result
} }
private fun xsrfToken(): String? = private fun xsrfToken(): String? =
cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value
@@ -746,4 +427,5 @@ class BmlLoginFlow {
SecureRandom().nextBytes(bytes) SecureRandom().nextBytes(bytes)
return bytes.joinToString("") { "%02x".format(it) } return bytes.joinToString("") { "%02x".format(it) }
} }
} }
@@ -1,10 +1,40 @@
package sh.sar.basedbank.api.bml package sh.sar.basedbank.api.bml
import sh.sar.basedbank.api.models.BankAccount
data class BmlSession( data class BmlSession(
val accessToken: String, val accessToken: String,
val deviceId: String val deviceId: String,
val refreshToken: String = "",
val expiresAt: Long = 0L // Unix millis; 0 = unknown
) {
fun isExpired() = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
}
data class BmlProfile(
val profileId: String,
val name: String,
val type: String, // "Profile" (personal) or "Business"
val profileType: String, // "default" or "business"
val autoActivated: Boolean = false // true for single-profile accounts where server skips the picker
) )
data class BmlOtpChannel(
val channel: String,
val description: String,
val masked: String
)
sealed class BmlActivationResult {
data class Success(
val session: BmlSession,
val accounts: List<BankAccount>
) : BmlActivationResult()
data class NeedsBusinessOtp(
val channels: List<BmlOtpChannel>
) : BmlActivationResult()
}
data class BmlAccountValidation( data class BmlAccountValidation(
val trnType: String, // IAT, QTR, DOT val trnType: String, // IAT, QTR, DOT
val validationType: String, // BML, alias, MIB val validationType: String, // BML, alias, MIB
@@ -24,6 +54,34 @@ data class BmlTransferResult(
val errorMessage: String = "" val errorMessage: String = ""
) )
data class BmlLoanDetail(
val loanAmount: Double,
val outstandingAmt: Double, // negative as returned by API
val repayAmount: Double,
val intRate: Double,
val loanStatus: String,
val startDate: String, // ISO8601 e.g. "2023-10-26T00:00:00+05:00"
val endDate: String,
val noOfRepayOverdue: Int,
val overdueAmount: Double
)
data class BmlQrPayInfo(
val requestId: String, // base64-encoded full QR URL (trxn_hash)
val merchantName: String, // narrative1
val merchantAddress: String, // narrative2 + narrative3
val amount: Double, // 0.0 for static QR
val currency: String
)
data class BmlQrPayResult(
val success: Boolean,
val merchant: String = "",
val amount: String = "",
val currency: String = "",
val errorMessage: String = ""
)
data class BmlForeignLimit( data class BmlForeignLimit(
val type: String, val type: String,
val used: Double, val used: Double,
@@ -0,0 +1,153 @@
package sh.sar.basedbank.api.bml
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
class BmlQrPayClient {
private val client = newBmlApiClient()
/**
* Resolves a BML QR URL to merchant details.
* [base64Url] is the full QR URL Base64-encoded (standard, with padding).
*/
fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo {
val request = bmlApiRequest(session,
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url")
return client.newCall(request).execute().use { response ->
val body = response.body?.string() ?: throw Exception("No response")
val json = JSONObject(body)
if (!json.optBoolean("success"))
throw Exception(json.optString("message").ifBlank { "Lookup failed" })
val payload = json.getJSONObject("payload")
val addr2 = payload.optString("narrative2").trim()
val addr3 = payload.optString("narrative3").trim()
val address = listOf(addr2, addr3).filter { it.isNotBlank() }.joinToString(", ")
BmlQrPayInfo(
requestId = payload.optString("trxn_hash"),
merchantName = payload.optString("narrative1").trim(),
merchantAddress = address,
amount = payload.optString("amount").toDoubleOrNull() ?: 0.0,
currency = payload.optString("currency").ifBlank { "MVR" }
)
}
}
/**
* Pre-initiate step required for gateway QR (pay.bml.com.mv).
* POST without channel — expects code 99 (OTP channel selection required).
*/
fun preInitiatePayment(
session: BmlSession,
debitAccount: String,
requestId: String,
amount: Double,
currency: String
): Boolean {
val jo = JSONObject().apply {
put("action", "approve")
put("debitAccount", debitAccount)
put("requestId", requestId)
put("amount", amount)
put("currency", currency)
}
val request = Request.Builder()
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
.post(jo.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("accept", "application/json")
.build()
return client.newCall(request).execute().use { response ->
val body = response.body?.string() ?: return@use false
val json = try { JSONObject(body) } catch (_: Exception) { return@use false }
json.optBoolean("success") && json.optInt("code") == 99
}
}
/**
* Step 1 — initiate: POST with channel but no OTP.
* Returns true when server responds with code 22 (OTP generated).
*/
fun initiatePayment(
session: BmlSession,
debitAccount: String,
requestId: String,
amount: Double,
currency: String
): Boolean {
val jo = JSONObject().apply {
put("action", "approve")
put("debitAccount", debitAccount)
put("requestId", requestId)
put("amount", amount)
put("currency", currency)
put("channel", "token")
}
val request = Request.Builder()
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
.post(jo.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("accept", "application/json")
.build()
return client.newCall(request).execute().use { response ->
val body = response.body?.string() ?: return@use false
val json = try { JSONObject(body) } catch (_: Exception) { return@use false }
json.optBoolean("success") && json.optInt("code") == 22
}
}
/**
* Step 2 — confirm: POST with channel + OTP.
* Returns [BmlQrPayResult] with success/error.
*/
fun confirmPayment(
session: BmlSession,
debitAccount: String,
requestId: String,
amount: Double,
currency: String,
otp: String
): BmlQrPayResult {
val jo = JSONObject().apply {
put("action", "approve")
put("debitAccount", debitAccount)
put("requestId", requestId)
put("amount", amount)
put("currency", currency)
put("channel", "token")
put("otp", otp)
}
val request = Request.Builder()
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
.post(jo.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("accept", "application/json")
.build()
return client.newCall(request).execute().use { response ->
val body = response.body?.string()
?: return@use BmlQrPayResult(false, errorMessage = "No response")
val json = try { JSONObject(body) } catch (_: Exception) {
return@use BmlQrPayResult(false, errorMessage = "Parse error")
}
if (!json.optBoolean("success")) {
BmlQrPayResult(false, errorMessage = json.optString("message").ifBlank { "Payment failed" })
} else {
val payload = json.optJSONObject("payload")
BmlQrPayResult(
success = true,
merchant = payload?.optString("merchant") ?: "",
amount = payload?.optString("amount") ?: "",
currency = payload?.optString("currency") ?: currency
)
}
}
}
}
@@ -0,0 +1,100 @@
package sh.sar.basedbank.api.bml
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
class BmlTransferClient {
private val client = newBmlApiClient()
/** Step 1: initiate the transfer (triggers OTP). Returns true if the server accepted it. */
fun initiateTransfer(
session: BmlSession,
debitAccount: String,
creditAccount: String,
amount: Double,
transferType: String,
currency: String,
bank: String? = null,
channel: String = "token"
): Boolean {
val jo = JSONObject().apply {
put("debitAccount", debitAccount)
put("creditAccount", creditAccount)
put("debitAmount", amount)
put("transfertype", transferType)
put("currency", currency)
put("channel", channel)
if (bank != null) put("bank", bank)
}
val request = Request.Builder()
.url("$BML_BASE_URL/api/mobile/transfer")
.post(jo.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("accept", "application/json")
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: return@use false
try {
val json = JSONObject(bodyStr)
json.optBoolean("success") && json.optInt("code") == 22
} catch (_: Exception) { false }
}
}
/** Step 2: confirm with OTP. Returns a [BmlTransferResult] with success/reference/error. */
fun confirmTransfer(
session: BmlSession,
debitAccount: String,
creditAccount: String,
amount: Double,
transferType: String,
currency: String,
otp: String,
remarks: String = "",
bank: String? = null,
channel: String = "token"
): BmlTransferResult {
val jo = JSONObject().apply {
put("debitAccount", debitAccount)
put("creditAccount", creditAccount)
put("debitAmount", amount)
put("transfertype", transferType)
put("currency", currency)
put("channel", channel)
put("otp", otp)
if (remarks.isNotBlank()) put("remarks", remarks)
if (bank != null) put("bank", bank)
}
val request = Request.Builder()
.url("$BML_BASE_URL/api/mobile/transfer")
.post(jo.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("accept", "application/json")
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string()
?: return@use BmlTransferResult(false, errorMessage = "No response")
try {
val json = JSONObject(bodyStr)
if (!json.optBoolean("success")) {
BmlTransferResult(false, errorMessage = json.optString("message").ifBlank { "Transfer failed" })
} else {
val payload = json.optJSONObject("payload")
BmlTransferResult(
success = true,
reference = payload?.optString("reference") ?: "",
timestamp = payload?.optString("timestamp") ?: "",
message = json.optString("message")
)
}
} catch (_: Exception) { BmlTransferResult(false, errorMessage = "Parse error") }
}
}
}
@@ -0,0 +1,79 @@
package sh.sar.basedbank.api.bml
import okhttp3.Request
import org.json.JSONObject
class BmlValidateClient {
private val client = newBmlApiClient()
fun validateAccount(session: BmlSession, input: String): BmlAccountValidation? {
val resp = client.newCall(
Request.Builder().url("$BML_BASE_URL/api/mobile/validate/account/$input")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("Accept", "application/json")
.build()
).execute()
val json = resp.body?.string() ?: return null
resp.close()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return null
val payload = root.optJSONObject("payload") ?: return null
val trnType = payload.optString("trnType", "")
val validationType = payload.optString("validationType", "")
if (validationType == "alias") {
val cdtrAcct = payload.optJSONObject("CdtrAcct") ?: return null
BmlAccountValidation(
trnType = trnType,
validationType = validationType,
account = cdtrAcct.optString("Acct"),
originalInput = input,
name = payload.optString("contact_name").trim(),
alias = null,
currency = payload.optString("currency", "MVR"),
agnt = cdtrAcct.optString("FinInstnId").takeIf { it.isNotBlank() }
)
} else {
BmlAccountValidation(
trnType = trnType,
validationType = validationType,
account = payload.optString("account"),
originalInput = input,
name = payload.optString("name"),
alias = payload.optString("alias").takeIf { it.isNotBlank() && it != "null" },
currency = payload.optString("currency", "MVR")
)
}
} catch (_: Exception) { null }
}
fun verifyMibAccount(session: BmlSession, account: String): BmlAccountValidation? {
val resp = client.newCall(
Request.Builder().url("$BML_BASE_URL/api/mobile/favara/account-verification/$account/MIB")
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("Accept", "application/json")
.build()
).execute()
val json = resp.body?.string() ?: return null
resp.close()
return try {
val root = JSONObject(json)
if (!root.optBoolean("success")) return null
BmlAccountValidation(
trnType = "DOT",
validationType = "MIB",
account = root.optString("account"),
originalInput = account,
name = root.optString("name"),
alias = null,
currency = "MVR",
agnt = root.optString("agnt").takeIf { it.isNotBlank() }
)
} catch (_: Exception) { null }
}
}
@@ -0,0 +1,81 @@
package sh.sar.basedbank.api.fahipay
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankServerException
import java.util.concurrent.TimeUnit
class FahipayAccountClient {
private val BASE_URL = "https://fahipay.mv"
private val UA = "okhttp/4.12.0"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
private fun Request.Builder.auth(session: FahipaySession): Request.Builder = this
.header("authid", session.authId)
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
.header("content-type", "multipart/form-data")
.header("User-Agent", UA)
fun fetchProfile(session: FahipaySession): FahipayUserProfile {
val resp = client.newCall(
Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en")
.auth(session).build()
).execute()
val code = resp.code
val json = resp.body?.string() ?: throw Exception("Empty profile response")
resp.close()
if (code in 500..599) throw BankServerException("Fahipay")
val obj = JSONObject(json)
val props = obj.optJSONObject("props") ?: JSONObject()
return FahipayUserProfile(
fullName = obj.optString("fullname").trim(),
email = obj.optString("email").trim(),
mobile = obj.optString("mobile").trim(),
nid = obj.optString("nid").trim(),
profileId = obj.optString("profileID").trim(),
walletAccount = props.optString("acc", ""),
linkedAccounts = props.optJSONObject("accs")?.toString() ?: "{}"
)
}
fun fetchBalance(session: FahipaySession): Double {
val resp = client.newCall(
Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en")
.auth(session).build()
).execute()
val code = resp.code
val json = resp.body?.string() ?: return 0.0
resp.close()
if (code in 500..599) throw BankServerException("Fahipay")
return try {
val obj = JSONObject(json)
if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0)
} catch (_: Exception) { 0.0 }
}
fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): BankAccount =
BankAccount(
bank = "FAHIPAY",
profileName = profile.fullName.ifBlank { "Fahipay" },
profileType = "FAHIPAY",
accountNumber = profile.walletAccount,
accountBriefName = "Fahipay Wallet",
currencyName = "MVR",
accountTypeName = "Digital Wallet",
availableBalance = "%.2f".format(balance),
currentBalance = "%.2f".format(balance),
blockedAmount = "0.00",
mvrBalance = "%.2f".format(balance),
statusDesc = "Active",
profileImageHash = null,
loginTag = loginTag,
internalId = profile.profileId
)
}
@@ -0,0 +1,69 @@
package sh.sar.basedbank.api.fahipay
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.util.AccountInputParser
import java.util.concurrent.TimeUnit
class FahipayContactsClient {
private val BASE_URL = "https://fahipay.mv"
private val UA = "okhttp/4.12.0"
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
fun fetchContacts(session: FahipaySession): List<FahipayContactGroup> {
val endpoints = listOf(
Triple("FAHIPAY_RAASTAS", "Raastas", "ooredooraastas"),
Triple("FAHIPAY_RELOAD", "Reload", "dhiraagureload"),
Triple("FAHIPAY_OOREDOO_BILL", "Ooredoo Bill", "ooredoobillpay"),
Triple("FAHIPAY_DHIRAAGU_BILL", "Dhiraagu Bill", "dhiraagubillpay")
)
val result = mutableListOf<FahipayContactGroup>()
for ((catId, label, page) in endpoints) {
try {
val resp = client.newCall(
Request.Builder()
.url("$BASE_URL/api/app/favs/?page=$page&lang=en")
.header("authid", session.authId)
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
.header("User-Agent", UA)
.build()
).execute()
val json = resp.body?.string() ?: continue
resp.close()
val obj = JSONObject(json)
val groupObj = obj.optJSONObject(page) ?: continue
val contacts = mutableListOf<BankContact>()
for (key in groupObj.keys()) {
val entry = groupObj.getJSONObject(key)
val number = entry.optString("number")
val name = entry.optString("name").trim().ifBlank { number }
if (AccountInputParser.detect(number) != AccountInputParser.InputType.PHONE) continue
contacts.add(BankContact(
benefNo = "fp_${page}_$number",
benefName = "",
benefNickName = name,
benefAccount = number,
benefType = "FAHIPAY",
bankColor = "#FF6B00",
benefBankName = label,
bankCode = "",
benefStatus = "",
transferCyDesc = "",
customerImgHash = null,
benefCategoryId = catId,
profileId = ""
))
}
if (contacts.isNotEmpty()) result.add(FahipayContactGroup(catId, label, contacts))
} catch (_: Exception) {}
}
return result
}
}
@@ -0,0 +1,63 @@
package sh.sar.basedbank.api.fahipay
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import sh.sar.basedbank.api.models.BankTransaction
import java.util.concurrent.TimeUnit
class FahipayHistoryClient {
private val BASE_URL = "https://fahipay.mv"
private val UA = "okhttp/4.12.0"
private val PAGE_SIZE = 15
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
fun fetchHistory(
session: FahipaySession,
accountDisplayName: String,
accountNumber: String,
start: Int
): Pair<List<BankTransaction>, Int> {
val resp = client.newCall(
Request.Builder()
.url("$BASE_URL/actions/activity/?s=$start&l=$PAGE_SIZE&lang=en")
.header("authid", session.authId)
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
.header("content-type", "multipart/form-data")
.header("User-Agent", UA)
.build()
).execute()
val code = resp.code
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
resp.close()
if (code in 500..599) throw BankServerException("Fahipay")
return try {
val obj = JSONObject(json)
val total = obj.optInt("total", 0)
val entries = obj.optJSONArray("entries") ?: return Pair(emptyList(), total)
val list = (0 until entries.length()).map { i ->
val e = entries.getJSONObject(i)
BankTransaction(
id = e.optString("transaction"),
date = e.optString("date"),
description = e.optString("name").trim(),
amount = e.optDouble("amount", 0.0),
currency = "MVR",
counterpartyName = e.optString("details").takeIf { it.isNotBlank() },
reference = e.optString("transaction").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "FAHIPAY",
iconUrl = e.optString("icon").takeIf { it.isNotBlank() }
)
}
Pair(list, total)
} catch (_: Exception) { Pair(emptyList(), 0) }
}
}
@@ -10,19 +10,13 @@ import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import okio.Buffer import okio.Buffer
import org.json.JSONObject import org.json.JSONObject
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.Transaction
import sh.sar.basedbank.util.AccountInputParser
import java.security.SecureRandom import java.security.SecureRandom
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class FahipayLoginFlow { class FahipayLoginFlow {
private val BASE_URL = "https://fahipay.mv" private val BASE_URL = "https://fahipay.mv"
private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android 14; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36" private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
private val UA_OKHTTP = "okhttp/4.12.0"
private val PAGE_SIZE = 15
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>() private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
private val cookieJar = object : CookieJar { private val cookieJar = object : CookieJar {
@@ -144,164 +138,6 @@ class FahipayLoginFlow {
?: throw Exception("No authID in OTP response") ?: throw Exception("No authID in OTP response")
} }
fun fetchProfile(session: FahipaySession): FahipayUserProfile {
val resp = client.newCall(
Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en")
.header("authid", session.authId)
.header("content-type", "multipart/form-data")
.header("User-Agent", UA_OKHTTP)
.build()
).execute()
val json = resp.body?.string() ?: throw Exception("Empty profile response")
resp.close()
val obj = JSONObject(json)
val props = obj.optJSONObject("props") ?: JSONObject()
return FahipayUserProfile(
fullName = obj.optString("fullname").trim(),
email = obj.optString("email").trim(),
mobile = obj.optString("mobile").trim(),
nid = obj.optString("nid").trim(),
profileId = obj.optString("profileID").trim(),
walletAccount = props.optString("acc", ""),
linkedAccounts = props.optJSONObject("accs")?.toString() ?: "{}"
)
}
fun fetchBalance(session: FahipaySession): Double {
val resp = client.newCall(
Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en")
.header("authid", session.authId)
.header("content-type", "multipart/form-data")
.header("User-Agent", UA_OKHTTP)
.build()
).execute()
val json = resp.body?.string() ?: return 0.0
resp.close()
return try {
val obj = JSONObject(json)
if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0)
} catch (_: Exception) { 0.0 }
}
fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): MibAccount =
MibAccount(
profileName = profile.fullName.ifBlank { "Fahipay" },
profileType = "FAHIPAY",
accountNumber = profile.walletAccount,
accountBriefName = "Fahipay Wallet",
currencyName = "MVR",
accountTypeName = "Digital Wallet",
availableBalance = "%.2f".format(balance),
currentBalance = "%.2f".format(balance),
blockedAmount = "0.00",
mvrBalance = "%.2f".format(balance),
statusDesc = "Active",
profileImageHash = null,
loginTag = loginTag,
internalId = profile.profileId
)
/**
* Fetches paginated activity history.
* @param start offset (0-based)
* @return Pair of (transactions, total count)
*/
fun fetchHistory(
session: FahipaySession,
accountDisplayName: String,
accountNumber: String,
start: Int
): Pair<List<Transaction>, Int> {
val resp = client.newCall(
Request.Builder()
.url("$BASE_URL/actions/activity/?s=$start&l=$PAGE_SIZE&lang=en")
.header("authid", session.authId)
.header("content-type", "multipart/form-data")
.header("User-Agent", UA_OKHTTP)
.build()
).execute()
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
resp.close()
return try {
val obj = JSONObject(json)
val total = obj.optInt("total", 0)
val entries = obj.optJSONArray("entries") ?: return Pair(emptyList(), total)
val list = (0 until entries.length()).map { i ->
val e = entries.getJSONObject(i)
Transaction(
id = e.optString("transaction"),
date = e.optString("date"),
description = e.optString("name").trim(),
amount = e.optDouble("amount", 0.0),
currency = "MVR",
counterpartyName = e.optString("details").takeIf { it.isNotBlank() },
reference = e.optString("transaction").takeIf { it.isNotBlank() },
accountNumber = accountNumber,
accountDisplayName = accountDisplayName,
source = "FAHIPAY",
iconUrl = e.optString("icon").takeIf { it.isNotBlank() }
)
}
Pair(list, total)
} catch (_: Exception) { Pair(emptyList(), 0) }
}
/**
* Fetches Fahipay saved favourites for the 4 service groups.
* Only includes entries whose number is a valid 7-digit Maldivian phone number (starts with 7 or 9).
* Groups with no valid entries are omitted.
*/
fun fetchContacts(session: FahipaySession): List<FahipayContactGroup> {
val endpoints = listOf(
Triple("FAHIPAY_RAASTAS", "Raastas", "ooredooraastas"),
Triple("FAHIPAY_RELOAD", "Reload", "dhiraagureload"),
Triple("FAHIPAY_OOREDOO_BILL", "Ooredoo Bill", "ooredoobillpay"),
Triple("FAHIPAY_DHIRAAGU_BILL", "Dhiraagu Bill", "dhiraagubillpay")
)
val result = mutableListOf<FahipayContactGroup>()
for ((catId, label, page) in endpoints) {
try {
val resp = client.newCall(
Request.Builder()
.url("$BASE_URL/api/app/favs/?page=$page&lang=en")
.header("authid", session.authId)
.header("User-Agent", UA_OKHTTP)
.build()
).execute()
val json = resp.body?.string() ?: continue
resp.close()
val obj = JSONObject(json)
// Empty group comes back as a JSON array [], not an object — optJSONObject returns null
val groupObj = obj.optJSONObject(page) ?: continue
val contacts = mutableListOf<MibBeneficiary>()
for (key in groupObj.keys()) {
val entry = groupObj.getJSONObject(key)
val number = entry.optString("number")
val name = entry.optString("name").trim().ifBlank { number }
if (AccountInputParser.detect(number) != AccountInputParser.InputType.PHONE) continue
contacts.add(MibBeneficiary(
benefNo = "fp_${page}_$number",
benefName = "",
benefNickName = name,
benefAccount = number,
benefType = "FAHIPAY",
bankColor = "#FF6B00",
benefBankName = label,
bankCode = "",
benefStatus = "",
transferCyDesc = "",
customerImgHash = null,
benefCategoryId = catId,
profileId = ""
))
}
if (contacts.isNotEmpty()) result.add(FahipayContactGroup(catId, label, contacts))
} catch (_: Exception) {}
}
return result
}
private fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf( private fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
"device[available]" to "true", "device[available]" to "true",
"device[platform]" to "Android", "device[platform]" to "Android",
@@ -1,5 +1,7 @@
package sh.sar.basedbank.api.fahipay package sh.sar.basedbank.api.fahipay
import sh.sar.basedbank.api.models.BankContact
data class FahipaySession( data class FahipaySession(
val authId: String, val authId: String,
val sessionCookie: String val sessionCookie: String
@@ -23,5 +25,5 @@ data class FahipayLoginStep(
data class FahipayContactGroup( data class FahipayContactGroup(
val categoryId: String, val categoryId: String,
val label: String, val label: String,
val contacts: List<sh.sar.basedbank.api.mib.MibBeneficiary> val contacts: List<BankContact>
) )
@@ -0,0 +1,63 @@
package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class MibCardsClient {
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
private val client = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build()
private fun cookieHeader(session: MibSession) =
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
"mbnonce=${session.nonceGenerator}; time-tracker=597"
fun fetchCards(session: MibSession, loginTag: String): List<MibCard> {
val body = FormBody.Builder()
.add("name", "")
.add("start", "1")
.add("end", "50")
.add("includeCount", "1")
.build()
val request = Request.Builder()
.url("$BASE_WV_URL/ajaxDebitCard/fetchCardInfos")
.post(body)
.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("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*")
.header("Origin", BASE_WV_URL)
.header("Referer", "$BASE_WV_URL//debitCards?dashurl=1")
.build()
return client.newCall(request).execute().use { response ->
val bodyStr = response.body?.string() ?: return emptyList()
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return emptyList() }
if (!json.optBoolean("success")) return emptyList()
val data = json.optJSONArray("data") ?: return emptyList()
(0 until data.length()).map { i ->
val item = data.getJSONObject(i)
MibCard(
cardId = item.optString("cardId"),
maskedCardNumber = item.optString("maskedCardNumber"),
cardStatus = item.optString("cardStatus"),
cardType = item.optString("cardType"),
cardTypeDesc = item.optString("cardTypeDesc"),
customerId = item.optString("customerId"),
phoneNumber = item.optString("phoneNumber"),
cardHolderName = item.optString("cardHolderName"),
loginTag = loginTag
)
}
}
}
}
@@ -1,5 +1,6 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -24,7 +25,7 @@ class MibContactsClient {
.header("Cookie", cookieHeader(session)) .header("Cookie", cookieHeader(session))
.header( .header(
"User-Agent", "User-Agent",
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36" "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") .header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*") .header("Accept", "*/*")
@@ -1,5 +1,6 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -27,7 +28,7 @@ class MibFinancingClient {
.header("Cookie", cookieHeader) .header("Cookie", cookieHeader)
.header( .header(
"User-Agent", "User-Agent",
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36" "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", "mv.com.mib.faisamobilex") .header("X-Requested-With", "mv.com.mib.faisamobilex")
.get() .get()
@@ -1,9 +1,11 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.json.JSONObject import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class MibHistoryClient { class MibHistoryClient {
@@ -50,7 +52,7 @@ class MibHistoryClient {
.header("Cookie", cookieHeader(session)) .header("Cookie", cookieHeader(session))
.header( .header(
"User-Agent", "User-Agent",
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36" "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") .header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*") .header("Accept", "*/*")
@@ -59,6 +61,7 @@ class MibHistoryClient {
.build() .build()
return client.newCall(request).execute().use { response -> return client.newCall(request).execute().use { response ->
if (response.code in 500..599) throw BankServerException("MIB")
val bodyStr = response.body?.string() ?: return Pair(emptyList(), 0) val bodyStr = response.body?.string() ?: return Pair(emptyList(), 0)
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return Pair(emptyList(), 0) } val json = try { JSONObject(bodyStr) } catch (_: Exception) { return Pair(emptyList(), 0) }
if (!json.optBoolean("success")) return Pair(emptyList(), 0) if (!json.optBoolean("success")) return Pair(emptyList(), 0)
@@ -29,6 +29,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null
// Stored after login so the session can be silently recovered on 419 // Stored after login so the session can be silently recovered on 419
@Volatile private var loginId: String = ""
@Volatile private var storedUsername: String? = null @Volatile private var storedUsername: String? = null
@Volatile private var storedPasswordHash: String? = null @Volatile private var storedPasswordHash: String? = null
@Volatile private var storedOtpSeed: String? = null @Volatile private var storedOtpSeed: String? = null
@@ -38,7 +39,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS)
.addInterceptor { chain -> .addNetworkInterceptor { chain ->
val req = chain.request().newBuilder() val req = chain.request().newBuilder()
.header("User-Agent", "android/1.0") .header("User-Agent", "android/1.0")
.header("Accept", "application/json") .header("Accept", "application/json")
@@ -58,11 +59,12 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
* Returns list of accounts from all profiles on success. * Returns list of accounts from all profiles on success.
*/ */
fun login(username: String, passwordHash: String, otpSeed: String): List<MibAccount> { fun login(username: String, passwordHash: String, otpSeed: String): List<MibAccount> {
loginId = username
storedUsername = username storedUsername = username
storedPasswordHash = passwordHash storedPasswordHash = passwordHash
storedOtpSeed = otpSeed storedOtpSeed = otpSeed
val appId = getOrCreateAppId() val appId = getOrCreateAppId()
val keys = credentialStore.loadMibKeys() val keys = credentialStore.loadMibKeys(loginId)
return if (keys != null) { return if (keys != null) {
regularLogin(username, passwordHash, appId, keys.first, keys.second) regularLogin(username, passwordHash, appId, keys.first, keys.second)
@@ -106,7 +108,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
val keyData = otpResp.getJSONArray("data").getJSONObject(0) val keyData = otpResp.getJSONArray("data").getJSONObject(0)
val key1 = keyData.getString("key1") val key1 = keyData.getString("key1")
val key2 = keyData.getString("key2") val key2 = keyData.getString("key2")
credentialStore.saveMibKeys(key1, key2) credentialStore.saveMibKeys(loginId, key1, key2)
return regularLogin(username, passwordHash, appId, key1, key2) return regularLogin(username, passwordHash, appId, key1, key2)
} }
@@ -136,10 +138,52 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
} }
val profiles = parseProfiles(loginResp) val profiles = parseProfiles(loginResp)
lastSession = session2 lastSession = session2
lastProfiles = profiles lastProfiles = profiles // keep ALL profiles so settings can show them all
return fetchAllProfiles(session2, profiles, "mib_$username")
val hidden = credentialStore.getHiddenMibProfileIds(loginId)
// When the server already selected the profile and returned balances in A41
// (single-profile case: profileSelected=true), use those accounts directly
// without making an extra P47 call (which the server ignores or rejects).
if (loginResp.optBoolean("profileSelected", false)) {
val a41Balances = loginResp.optJSONArray("accountBalance")
if (a41Balances != null && a41Balances.length() > 0) {
val selectedId = loginResp.optString("selectedProfileId")
val profile = profiles.firstOrNull { it.profileId == selectedId }
?: profiles.firstOrNull()
if (profile != null && (hidden.isEmpty() || profile.profileId !in hidden)) {
val allAccounts = mutableListOf<MibAccount>()
for (i in 0 until a41Balances.length()) {
val a = a41Balances.getJSONObject(i)
allAccounts.add(
MibAccount(
bank = "MIB",
profileName = profile.name,
profileType = profile.profileType,
productCode = profile.cifType,
accountNumber = a.optString("accountNumber"),
accountBriefName = a.optString("accountBriefName"),
currencyName = a.optString("currencyName"),
accountTypeName = a.optString("accountTypeName"),
availableBalance = a.optString("availableBalance"),
currentBalance = a.optString("currentBalance"),
blockedAmount = a.optString("blockedAmount"),
mvrBalance = a.optString("mvrBalance"),
statusDesc = a.optString("statusDesc"),
profileImageHash = profile.customerImage,
loginTag = "mib_$username",
profileId = profile.profileId
)
)
}
return allAccounts
}
}
}
val visibleProfiles = if (hidden.isEmpty()) profiles else profiles.filter { it.profileId !in hidden }
return fetchAllProfiles(session2, visibleProfiles, "mib_$username")
} }
// ─── Helpers ───────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────
@@ -271,8 +315,10 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
val a = accountBalances.getJSONObject(i) val a = accountBalances.getJSONObject(i)
allAccounts.add( allAccounts.add(
MibAccount( MibAccount(
bank = "MIB",
profileName = profile.name, profileName = profile.name,
profileType = profile.profileType, profileType = profile.profileType,
productCode = profile.cifType,
accountNumber = a.optString("accountNumber"), accountNumber = a.optString("accountNumber"),
accountBriefName = a.optString("accountBriefName"), accountBriefName = a.optString("accountBriefName"),
currencyName = a.optString("currencyName"), currencyName = a.optString("currencyName"),
@@ -310,44 +356,6 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
} }
} }
data class MibPersonalProfile(
val fullName: String,
val username: String,
val email: String,
val mobile: String,
val enrolled: String
)
/** Fetches the customer's profile info from the Faisanet personal profile page. */
fun fetchPersonalProfile(session: MibSession): MibPersonalProfile? {
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
val request = Request.Builder()
.url("https://faisamobilex-wv.mib.com.mv/personalProfile")
.get()
.header("Cookie", cookieHeader)
.build()
return try {
val resp = client.newCall(request).execute()
val html = resp.body?.string() ?: return null
resp.close()
fun scrape(label: String): String {
val r = Regex("""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE))
return r.find(html)?.groupValues?.get(1)?.trim() ?: ""
}
val nameRegex = Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
val fullName = nameRegex.find(html)?.groupValues?.get(1)?.trim() ?: return null
MibPersonalProfile(
fullName = fullName,
username = scrape("Username:"),
email = scrape("Email:"),
mobile = scrape("Mobile no:"),
enrolled = scrape("Enrolled:")
)
} catch (_: Exception) { null }
}
/** Fetches a profile image via P41. Returns base64 JPEG string, or null if not found. */ /** Fetches a profile image via P41. Returns base64 JPEG string, or null if not found. */
fun fetchProfileImage(session: MibSession, imageHash: String): String? { fun fetchProfileImage(session: MibSession, imageHash: String): String? {
val payload = baseData(session, "P41").apply { val payload = baseData(session, "P41").apply {
@@ -358,6 +366,34 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
return resp.optString("profileImage").takeIf { it.isNotBlank() } return resp.optString("profileImage").takeIf { it.isNotBlank() }
} }
/**
* Uploads a profile image via P40.
* [imageBase64] is a base64-encoded JPEG string.
* Returns the new imageHash on success, or null on failure.
*/
fun uploadProfileImage(session: MibSession, profile: MibProfile, imageBase64: String): String? {
val payload = baseData(session, "P40").apply {
put("profileId", profile.profileId)
put("profileImage", imageBase64)
}
val resp = doRequest(session, payload, "n")
if (!resp.optBoolean("success", false)) return null
return resp.optString("imageHash").takeIf { it.isNotBlank() }
?: resp.optString("customerImage").takeIf { it.isNotBlank() }
}
/**
* Deletes the profile image via P42.
* Returns true on success.
*/
fun deleteProfileImage(session: MibSession, profile: MibProfile): Boolean {
val payload = baseData(session, "P42").apply {
put("profileId", profile.profileId)
}
val resp = doRequest(session, payload, "n")
return resp.optBoolean("success", false)
}
private fun post(body: FormBody): String { private fun post(body: FormBody): String {
val request = Request.Builder() val request = Request.Builder()
.url(BASE_URL) .url(BASE_URL)
@@ -365,6 +401,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
.build() .build()
val response = client.newCall(request).execute() val response = client.newCall(request).execute()
if (response.code == 419) throw SessionExpiredException() if (response.code == 419) throw SessionExpiredException()
if (response.code in 500..599) throw sh.sar.basedbank.api.models.BankServerException("MIB")
return response.body?.string() ?: throw IllegalStateException("Empty response body") return response.body?.string() ?: throw IllegalStateException("Empty response body")
} }
@@ -389,11 +426,11 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
private fun generateOtp(seed: String): String = Totp.generate(seed) private fun generateOtp(seed: String): String = Totp.generate(seed)
private fun getOrCreateAppId(): String { private fun getOrCreateAppId(): String {
var id = credentialStore.loadMibAppId() var id = credentialStore.loadMibAppId(loginId)
if (id == null) { if (id == null) {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("") id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("")
credentialStore.saveMibAppId(id) credentialStore.saveMibAppId(loginId, id)
} }
return id return id
} }
@@ -1,5 +1,16 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.models.BankTransaction
// Kept for source compatibility within the mib package
typealias MibAccount = BankAccount
typealias MibBeneficiary = BankContact
typealias Transaction = BankTransaction
typealias MibBeneficiaryCategory = BankContactCategory
data class MibSession( data class MibSession(
val appId: String, val appId: String,
val xxid: String, val xxid: String,
@@ -19,23 +30,6 @@ data class MibProfile(
val customerImage: String? val customerImage: String?
) )
data class MibAccount(
val profileName: String,
val profileType: String,
val accountNumber: String,
val accountBriefName: String,
val currencyName: String,
val accountTypeName: String,
val availableBalance: String,
val currentBalance: String,
val blockedAmount: String,
val mvrBalance: String,
val statusDesc: String,
val profileImageHash: String?,
val loginTag: String = "",
val profileId: String = "", // MIB profile ID; empty for BML accounts
val internalId: String = "" // BML internal UUID; empty for MIB accounts
)
data class MibTransferResult( data class MibTransferResult(
val success: Boolean, val success: Boolean,
@@ -44,27 +38,6 @@ data class MibTransferResult(
val errorMessage: String = "" val errorMessage: String = ""
) )
data class MibBeneficiaryCategory(
val id: String,
val categoryName: String,
val numBenef: Int
)
data class MibBeneficiary(
val benefNo: String,
val benefName: String,
val benefNickName: String,
val benefAccount: String,
val benefType: String, // L=Local, I=Internal(MIB), S=Swift
val bankColor: String,
val benefBankName: String,
val bankCode: String,
val benefStatus: String,
val transferCyDesc: String,
val customerImgHash: String?,
val benefCategoryId: String, // "0" = uncategorized
val profileId: String = "" // MIB profile ID; empty for BML contacts
)
data class MibIpsAccountInfo( data class MibIpsAccountInfo(
val accountName: String, val accountName: String,
@@ -72,18 +45,17 @@ data class MibIpsAccountInfo(
val bankId: String val bankId: String
) )
data class Transaction(
val id: String, data class MibCard(
val date: String, // "YYYY-MM-DD HH:mm:ss" for MIB, ISO8601 for BML val cardId: String,
val description: String, val maskedCardNumber: String,
val amount: Double, // negative = debit, positive = credit val cardStatus: String,
val currency: String, val cardType: String,
val counterpartyName: String?, val cardTypeDesc: String,
val reference: String?, val customerId: String,
val accountNumber: String, val phoneNumber: String,
val accountDisplayName: String, val cardHolderName: String,
val source: String, // "MIB", "BML", "BML_CARD", "FAHIPAY" val loginTag: String
val iconUrl: String? = null // merchant icon URL (Fahipay only)
) )
data class MibFinanceDeal( data class MibFinanceDeal(
@@ -0,0 +1,50 @@
package sh.sar.basedbank.api.mib
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
data class MibPersonalProfile(
val fullName: String,
val username: String,
val email: String,
val mobile: String,
val enrolled: String
)
class MibProfileClient {
private val client = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
fun fetchPersonalProfile(session: MibSession): MibPersonalProfile? {
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
val request = Request.Builder()
.url("https://faisamobilex-wv.mib.com.mv/personalProfile")
.get()
.header("Cookie", cookieHeader)
.build()
return try {
val resp = client.newCall(request).execute()
val html = resp.body?.string() ?: return null
resp.close()
fun scrape(label: String): String {
val r = Regex("""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE))
return r.find(html)?.groupValues?.get(1)?.trim() ?: ""
}
val nameRegex = Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
val fullName = nameRegex.find(html)?.groupValues?.get(1)?.trim() ?: return null
MibPersonalProfile(
fullName = fullName,
username = scrape("Username:"),
email = scrape("Email:"),
mobile = scrape("Mobile no:"),
enrolled = scrape("Enrolled:")
)
} catch (_: Exception) { null }
}
}
@@ -1,5 +1,6 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -26,7 +27,7 @@ class MibTransferClient {
.header("Cookie", cookieHeader(session)) .header("Cookie", cookieHeader(session))
.header( .header(
"User-Agent", "User-Agent",
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36" "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") .header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*") .header("Accept", "*/*")
@@ -68,6 +69,7 @@ class MibTransferClient {
.withWvHeaders(session) .withWvHeaders(session)
.build() .build()
return client.newCall(request).execute().use { response -> return client.newCall(request).execute().use { response ->
if (response.code == 419) throw SessionExpiredException()
val bodyStr = response.body?.string() ?: "" val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null } val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (json == null || !json.optBoolean("success")) { if (json == null || !json.optBoolean("success")) {
@@ -0,0 +1,75 @@
package sh.sar.basedbank.api.models
/** Thrown by a bank API client when the server returns an HTTP 5xx response. */
class BankServerException(val bankName: String) : Exception("Server error from $bankName")
/**
* Unified account model used across all banks (MIB, BML, Fahipay, ...).
* The [bank] field identifies which bank owns this account.
*/
data class BankAccount(
val bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow
val profileName: String,
val profileType: String,
val productCode: String = "", // bank-specific product/subtype code: MIB: CIF type label ("Individual", "Sole Propr"); BML: card product code ("C8201", "C1007")
val accountNumber: String,
val accountBriefName: String,
val currencyName: String,
val accountTypeName: String,
val availableBalance: String,
val currentBalance: String,
val blockedAmount: String,
val mvrBalance: String,
val statusDesc: String,
val profileImageHash: String?,
val loginTag: String = "",
val profileId: String = "", // profile ID used by the bank; empty if not applicable
val internalId: String = "" // bank-internal UUID or ID; empty if not applicable
)
/**
* Unified contact/beneficiary model used across all banks.
* Each bank may interpret fields differently; see per-bank notes below.
*/
data class BankContact(
val benefNo: String,
val benefName: String,
val benefNickName: String,
val benefAccount: String,
val benefType: String, // MIB: L=Local, I=Internal, S=Swift; BML: "I"; Fahipay: "FAHIPAY"
val bankColor: String,
val benefBankName: String,
val bankCode: String,
val benefStatus: String,
val transferCyDesc: String,
val customerImgHash: String?,
val benefCategoryId: String, // MIB: numeric category ID or "0"; BML: "BML"; Fahipay: "FAHIPAY"
val profileId: String = "" // owning profile ID; empty where not applicable
)
/**
* Contact category (group) used across MIB and Fahipay.
*/
data class BankContactCategory(
val id: String,
val categoryName: String,
val numBenef: Int
)
/**
* Unified transaction model used across all banks.
* [source] identifies the originating bank/account type.
*/
data class BankTransaction(
val id: String,
val date: String, // "YYYY-MM-DD HH:mm:ss" (MIB/BML normalised) or ISO8601
val description: String,
val amount: Double, // negative = debit, positive = credit
val currency: String,
val counterpartyName: String?,
val reference: String?,
val accountNumber: String,
val accountDisplayName: String,
val source: String, // "MIB", "BML", "BML_CARD", "FAHIPAY"
val iconUrl: String? = null // merchant icon URL (Fahipay only)
)
@@ -9,8 +9,9 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.Transaction import sh.sar.basedbank.api.models.BankTransaction
import sh.sar.basedbank.util.AccountHistoryDisplay
import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding
import sh.sar.basedbank.databinding.ItemDateHeaderBinding import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
@@ -20,12 +21,13 @@ import java.util.Date
import java.util.Locale import java.util.Locale
class AccountHistoryAdapter( class AccountHistoryAdapter(
private val account: MibAccount private val account: BankAccount,
private val display: AccountHistoryDisplay
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
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: Transaction) : Item() data class Trx(val transaction: BankTransaction) : Item()
} }
private val displayItems = mutableListOf<Item>() private val displayItems = mutableListOf<Item>()
@@ -34,7 +36,18 @@ class AccountHistoryAdapter(
private val iconUrlCache = mutableMapOf<String, Bitmap>() private val iconUrlCache = mutableMapOf<String, Bitmap>()
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
var onIconUrlNeeded: ((url: String) -> Unit)? = null var onIconUrlNeeded: ((url: String) -> Unit)? = null
var onTransferClick: ((MibAccount) -> Unit)? = null var onTransferClick: ((BankAccount) -> Unit)? = null
private var hideAmounts: Boolean = false
fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return
hideAmounts = hide
notifyItemChanged(0) // refresh header card
// refresh all transaction rows
for (i in displayItems.indices) {
if (displayItems[i] is Item.Trx) notifyItemChanged(i + 1)
}
}
fun updateImage(counterpartyName: String, bitmap: Bitmap) { fun updateImage(counterpartyName: String, bitmap: Bitmap) {
imageCache[counterpartyName] = bitmap imageCache[counterpartyName] = bitmap
@@ -71,7 +84,7 @@ class AccountHistoryAdapter(
* Display the given (already sorted + filtered) list with date group headers. * Display the given (already sorted + filtered) list with date group headers.
* Silently resets the loading footer so notifyDataSetChanged covers everything. * Silently resets the loading footer so notifyDataSetChanged covers everything.
*/ */
fun setTransactions(transactions: List<Transaction>) { fun setTransactions(transactions: List<BankTransaction>) {
_showLoadingFooter = false _showLoadingFooter = false
displayItems.clear() displayItems.clear()
lastInsertedDateKey = "" lastInsertedDateKey = ""
@@ -92,7 +105,7 @@ class AccountHistoryAdapter(
* Appends [newTransactions] (assumed to be older than all existing items) using incremental * Appends [newTransactions] (assumed to be older than all existing items) using incremental
* notifications, so the RecyclerView doesn't reset scroll position. * notifications, so the RecyclerView doesn't reset scroll position.
*/ */
fun appendTransactions(newTransactions: List<Transaction>) { fun appendTransactions(newTransactions: List<BankTransaction>) {
if (newTransactions.isEmpty()) return if (newTransactions.isEmpty()) return
if (_showLoadingFooter) { if (_showLoadingFooter) {
val pos = itemCount - 1 val pos = itemCount - 1
@@ -138,7 +151,7 @@ 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(account) is HeaderVH -> holder.bind(display)
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label) is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label)
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction) is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction)
else -> Unit else -> Unit
@@ -147,37 +160,20 @@ class AccountHistoryAdapter(
inner class HeaderVH(private val b: ItemAccountHistoryHeaderBinding) : inner class HeaderVH(private val b: ItemAccountHistoryHeaderBinding) :
RecyclerView.ViewHolder(b.root) { RecyclerView.ViewHolder(b.root) {
fun bind(acc: MibAccount) { fun bind(d: AccountHistoryDisplay) {
b.tvHeaderAccountName.text = acc.accountBriefName b.tvHeaderAccountName.text = d.name
b.tvHeaderAccountNumber.text = acc.accountNumber b.tvHeaderAccountNumber.text = d.number
b.tvHeaderPillBank.text = when { b.tvHeaderPillBank.text = d.bankPill
acc.profileType.startsWith("BML") -> "BML" b.tvHeaderPillType.text = d.typeLabel
acc.profileType == "FAHIPAY" -> "FP" b.tvHeaderAvailable.text = if (hideAmounts) maskAmount(d.availableBalance) else d.availableBalance
else -> null b.tvHeaderBalance.text = if (hideAmounts) maskAmount(d.workingBalance) else d.workingBalance
} if (d.blockedBalance != null) {
b.tvHeaderPillType.text = friendlyType(acc.accountTypeName) b.tvHeaderBlocked.text = if (hideAmounts) maskAmount(d.blockedBalance) else d.blockedBalance
b.tvHeaderAvailable.text = "${acc.currencyName} ${acc.availableBalance}"
b.tvHeaderBalance.text = "${acc.currencyName} ${acc.currentBalance}"
val blocked = acc.blockedAmount.toDoubleOrNull() ?: 0.0
if (blocked > 0.0) {
b.tvHeaderBlocked.text = "${acc.currencyName} ${acc.blockedAmount}"
b.llHeaderBlocked.visibility = View.VISIBLE b.llHeaderBlocked.visibility = View.VISIBLE
} else { } else {
b.llHeaderBlocked.visibility = View.GONE b.llHeaderBlocked.visibility = View.GONE
} }
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(acc) } b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) }
}
private fun friendlyType(raw: String): String {
val u = raw.trim().uppercase()
return when {
u.contains("SAVING") -> "Savings"
u.contains("CURRENT") -> "Current"
u.contains("WADIAH") -> "Islamic"
u.contains("VISA") || u.contains("MASTERCARD") || u.contains("AMEX") -> "Card"
u.contains("PREPAID") -> "Prepaid"
else -> raw.trim().take(12)
}
} }
} }
@@ -188,7 +184,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: Transaction) { fun bind(trx: BankTransaction) {
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
@@ -226,17 +222,22 @@ class AccountHistoryAdapter(
b.tvDate.text = formatTime(trx.date) b.tvDate.text = formatTime(trx.date)
val sign = if (isCredit) "+" else "-" if (hideAmounts) {
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount)) b.tvAmount.text = "${trx.currency} ••••••"
b.tvAmount.text = "$sign ${trx.currency} $absAmt" b.tvAmount.setTextColor(Color.parseColor("#888888"))
b.tvAmount.setTextColor( } else {
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043") val sign = if (isCredit) "+" else "-"
) val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
b.tvAmount.setTextColor(
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
)
}
b.root.setOnClickListener { showDetail(trx) } b.root.setOnClickListener { showDetail(trx) }
} }
private fun showDetail(trx: Transaction) { private fun showDetail(trx: BankTransaction) {
val ctx = b.root.context val ctx = b.root.context
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
val details = buildString { val details = buildString {
@@ -297,6 +298,11 @@ class AccountHistoryAdapter(
return FULL_DATE_FMT.format(date) return FULL_DATE_FMT.format(date)
} }
fun maskAmount(formatted: String): String {
val currency = formatted.substringBefore(' ', formatted)
return "$currency ••••••"
}
fun sourceColor(source: String) = when (source) { fun sourceColor(source: String) = when (source) {
"MIB" -> "#FE860E" "MIB" -> "#FE860E"
"BML", "BML_CARD" -> "#0066A1" "BML", "BML_CARD" -> "#0066A1"
@@ -10,6 +10,8 @@ import android.util.Base64
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 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 androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -17,23 +19,19 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow import sh.sar.basedbank.api.models.BankServerException
import sh.sar.basedbank.api.mib.MibAccount
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibHistoryClient import sh.sar.basedbank.api.models.BankTransaction
import sh.sar.basedbank.api.mib.Transaction
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.ContactImageCache import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.HistoryFetcher
import sh.sar.basedbank.util.MerchantIconCache import sh.sar.basedbank.util.MerchantIconCache
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
class AccountHistoryFragment : Fragment() { class AccountHistoryFragment : Fragment() {
@@ -42,29 +40,21 @@ class AccountHistoryFragment : Fragment() {
private val viewModel: HomeViewModel by activityViewModels() private val viewModel: HomeViewModel by activityViewModels()
private lateinit var adapter: AccountHistoryAdapter private lateinit var adapter: AccountHistoryAdapter
private lateinit var account: MibAccount private lateinit var account: BankAccount
private lateinit var fetcher: HistoryFetcher
private val allTransactions = mutableListOf<Transaction>() private val allTransactions = mutableListOf<BankTransaction>()
private var searchQuery = "" private var searchQuery = ""
private var firstPageDone = false private var firstPageDone = false
private val pendingImageNames = mutableSetOf<String>() private val pendingImageNames = mutableSetOf<String>()
private val pendingIconUrls = mutableSetOf<String>() private val pendingIconUrls = mutableSetOf<String>()
// Pagination state
private var mibNextStart = 1
private var mibTotalCount = -1 // -1 = unknown; loaded on first fetch
private var bmlNextPage = 1
private var bmlTotalPages = -1
private var cardMonthOffset = 0 // 0 = current month, 1 = prev, etc.
private var fahipayNextStart = 0
private var fahipayTotal = -1
private var isLoading = false private var isLoading = false
private val pageSize = 10 private val pageSize = 10
companion object { companion object {
private const val ARG_ACCOUNT_NUMBER = "account_number" private const val ARG_ACCOUNT_NUMBER = "account_number"
fun newInstance(account: MibAccount) = AccountHistoryFragment().apply { fun newInstance(account: BankAccount) = AccountHistoryFragment().apply {
arguments = Bundle().apply { arguments = Bundle().apply {
putString(ARG_ACCOUNT_NUMBER, account.accountNumber) putString(ARG_ACCOUNT_NUMBER, account.accountNumber)
} }
@@ -79,15 +69,29 @@ class AccountHistoryFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val accountNumber = requireArguments().getString(ARG_ACCOUNT_NUMBER) ?: return val accountNumber = requireArguments().getString(ARG_ACCOUNT_NUMBER) ?: return
account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return
fetcher = HistoryFetcher(account)
adapter = AccountHistoryAdapter(account) val historyDisplay = AccountHistoryParser.from(account) ?: return
adapter = AccountHistoryAdapter(account, historyDisplay)
adapter.onImageNeeded = { name -> loadContactImage(name) } adapter.onImageNeeded = { name -> loadContactImage(name) }
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) } adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
adapter.onTransferClick = { acc -> adapter.onTransferClick = { acc ->
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(acc)) (activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(acc))
} }
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
}
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
if (dy <= 0 || isLoading) return if (dy <= 0 || isLoading) return
@@ -104,11 +108,10 @@ class AccountHistoryFragment : Fragment() {
override fun afterTextChanged(s: Editable?) { override fun afterTextChanged(s: Editable?) {
searchQuery = s?.toString()?.trim() ?: "" searchQuery = s?.toString()?.trim() ?: ""
filterAndDisplay() filterAndDisplay()
if (searchQuery.isNotBlank() && hasMore() && !isLoading) loadNextPage() if (searchQuery.isNotBlank() && fetcher.hasMore() && !isLoading) loadNextPage()
} }
}) })
// Load cache immediately, then fetch fresh data in background
val cached = TransactionCache.load(requireContext(), account.accountNumber) val cached = TransactionCache.load(requireContext(), account.accountNumber)
if (cached.isNotEmpty()) { if (cached.isNotEmpty()) {
allTransactions.addAll(cached) allTransactions.addAll(cached)
@@ -116,6 +119,14 @@ class AccountHistoryFragment : Fragment() {
} }
(activity as? HomeActivity)?.setRefreshing(true) (activity as? HomeActivity)?.setRefreshing(true)
loadNextPage() loadNextPage()
binding.swipeRefresh.setOnRefreshListener {
if (isLoading) {
binding.swipeRefresh.isRefreshing = false
} else {
resetAndReload()
}
}
} }
override fun onResume() { override fun onResume() {
@@ -133,19 +144,26 @@ class AccountHistoryFragment : Fragment() {
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
} }
private fun isMib() = !account.profileType.startsWith("BML") && account.profileType != "FAHIPAY" private fun resetAndReload() {
private fun isBmlCard() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" allTransactions.clear()
private fun isFahipay() = account.profileType == "FAHIPAY" pendingImageNames.clear()
pendingIconUrls.clear()
private fun hasMore(): Boolean = when { firstPageDone = false
isFahipay() -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal fetcher = HistoryFetcher(account)
isMib() -> mibTotalCount < 0 || mibNextStart <= mibTotalCount // Restore cache immediately so data stays visible while refreshing
isBmlCard() -> cardMonthOffset < 3 // load up to 3 months val cached = TransactionCache.load(requireContext(), account.accountNumber)
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages if (cached.isNotEmpty()) {
allTransactions.addAll(cached)
filterAndDisplay()
} else {
adapter.setTransactions(emptyList())
binding.emptyView.visibility = View.GONE
}
loadNextPage()
} }
private fun loadNextPage() { private fun loadNextPage() {
if (isLoading || !hasMore()) return if (isLoading || !fetcher.hasMore()) return
isLoading = true isLoading = true
if (firstPageDone && allTransactions.isNotEmpty()) { if (firstPageDone && allTransactions.isNotEmpty()) {
@@ -155,76 +173,34 @@ class AccountHistoryFragment : Fragment() {
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
lifecycleScope.launch { lifecycleScope.launch {
val transactions: List<Transaction> = withContext(Dispatchers.IO) { val transactions = try {
when { fetcher.fetchNextPage(app, pageSize)
isFahipay() -> { } catch (e: java.io.IOException) {
val session = app.fahipaySession ?: return@withContext emptyList() (activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
val flow = FahipayLoginFlow() null
flow.setSessionCookie(session.sessionCookie) } catch (e: BankServerException) {
val (list, total) = flow.fetchHistory( (activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_server_error, e.bankName))
session = session, null
accountDisplayName = account.accountBriefName, } catch (_: Exception) {
accountNumber = account.accountNumber, null
start = fahipayNextStart
)
if (total > 0) fahipayTotal = total
fahipayNextStart += list.size
list
}
isMib() -> {
val session = app.mibSession ?: return@withContext emptyList()
app.mibMutex.withLock {
val profile = app.mibProfiles.firstOrNull { it.profileId == account.profileId }
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
val (list, total) = MibHistoryClient().fetchHistory(
session = session,
accountNo = account.accountNumber,
accountDisplayName = account.accountBriefName,
start = mibNextStart,
pageSize = pageSize
)
if (total > 0) mibTotalCount = total
mibNextStart += list.size.coerceAtLeast(pageSize)
list
}
}
isBmlCard() -> {
val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
cardMonthOffset++
BmlLoginFlow().fetchCardHistory(
session = session,
cardId = account.internalId,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
month = month
)
}
else -> {
val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
session = session,
accountId = account.internalId,
accountDisplayName = account.accountBriefName,
accountNumber = account.accountNumber,
page = bmlNextPage
)
if (totalPages > 0) bmlTotalPages = totalPages
bmlNextPage++
list
}
}
} }
isLoading = false isLoading = false
if (_binding == null) return@launch
if (!firstPageDone) { if (!firstPageDone) {
firstPageDone = true firstPageDone = true
(activity as? HomeActivity)?.setRefreshing(false) (activity as? HomeActivity)?.setRefreshing(false)
binding.swipeRefresh.isRefreshing = false
} }
if (transactions == null) {
adapter.showLoadingFooter = false
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
return@launch
}
(activity as? HomeActivity)?.hideConnectivityBanner()
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 }
@@ -233,7 +209,6 @@ class AccountHistoryFragment : Fragment() {
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) } allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
TransactionCache.save(requireContext(), account.accountNumber, allTransactions) TransactionCache.save(requireContext(), account.accountNumber, allTransactions)
if (searchQuery.isBlank()) { if (searchQuery.isBlank()) {
// Append incrementally to preserve scroll position
val sorted = newOnes.sortedByDescending { AccountHistoryAdapter.parseDateMillis(it.date) } val sorted = newOnes.sortedByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
adapter.appendTransactions(sorted) adapter.appendTransactions(sorted)
binding.emptyView.visibility = View.GONE binding.emptyView.visibility = View.GONE
@@ -243,7 +218,7 @@ class AccountHistoryFragment : Fragment() {
} else { } else {
adapter.showLoadingFooter = false adapter.showLoadingFooter = false
} }
if (searchQuery.isNotBlank() && hasMore()) loadNextPage() if (searchQuery.isNotBlank() && fetcher.hasMore()) loadNextPage()
} else { } else {
adapter.showLoadingFooter = false adapter.showLoadingFooter = false
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
@@ -261,7 +236,7 @@ class AccountHistoryFragment : Fragment() {
return return
} }
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val sess = app.mibSession ?: return val sess = app.anyMibSession() ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
@@ -288,8 +263,7 @@ class AccountHistoryFragment : Fragment() {
val response = client.newCall(Request.Builder().url(url).build()).execute() val response = client.newCall(Request.Builder().url(url).build()).execute()
val bytes = response.body?.bytes() ?: return@launch val bytes = response.body?.bytes() ?: return@launch
response.close() response.close()
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
?: return@launch
MerchantIconCache.save(requireContext(), url, bitmap) MerchantIconCache.save(requireContext(), url, bitmap)
withContext(Dispatchers.Main) { adapter.updateIconUrl(url, bitmap) } withContext(Dispatchers.Main) { adapter.updateIconUrl(url, bitmap) }
} catch (_: Exception) { } catch (_: Exception) {
@@ -3,71 +3,85 @@ package sh.sar.basedbank.ui.home
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.ItemAccountBinding import sh.sar.basedbank.databinding.ItemAccountBinding
import sh.sar.basedbank.databinding.ItemCardBinding import sh.sar.basedbank.databinding.ItemCardBinding
import sh.sar.basedbank.databinding.ItemDateHeaderBinding import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.util.BmlDashboardParser import sh.sar.basedbank.util.AccountListDisplay
import sh.sar.basedbank.util.MibAccountParser import sh.sar.basedbank.util.AccountListParser
class AccountsAdapter( class AccountsAdapter(
accounts: List<MibAccount>, accounts: List<BankAccount>,
private val onAccountClick: (MibAccount) -> Unit = {} private val onAccountClick: (BankAccount) -> Unit = {},
/** Optional loader for MIB per-profile images: (hash, onLoaded) */
private val profileImageLoader: ((String, (Bitmap) -> Unit) -> Unit)? = null,
/** Optional loader for local (BML/Fahipay) profile images: (loginTag, profileId, onLoaded) */
private val localProfileImageLoader: ((String, String, (Bitmap) -> Unit) -> Unit)? = null
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var onTransferClick: ((BankAccount) -> Unit)? = null
private var hideAmounts: Boolean = false
private sealed class Item { private sealed class Item {
data class SectionTitle(val label: String) : Item() data class SectionTitle(val label: String) : Item()
data class Account(val account: MibAccount) : Item() data class Account(val account: BankAccount, val display: AccountListDisplay) : Item()
data class Card(val account: MibAccount) : Item() data class Card(val account: BankAccount, val display: AccountListDisplay) : Item()
} }
private val items: MutableList<Item> = buildItems(accounts).toMutableList() private val items: MutableList<Item> = buildItems(accounts).toMutableList()
fun updateAccounts(accounts: List<MibAccount>) { fun updateAccounts(accounts: List<BankAccount>) {
items.clear() items.clear()
items.addAll(buildItems(accounts)) items.addAll(buildItems(accounts))
notifyDataSetChanged() notifyDataSetChanged()
} }
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList { fun setHideAmounts(hide: Boolean) {
val nonPrepaid = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" } if (hideAmounts == hide) return
val prepaid = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" } hideAmounts = hide
notifyDataSetChanged()
}
// Group non-prepaid accounts by their derived section title, preserving order private fun buildItems(accounts: List<BankAccount>): List<Item> = buildList {
val groups = LinkedHashMap<String, MutableList<MibAccount>>() val displayed = accounts.mapNotNull { acc -> AccountListParser.from(acc)?.let { acc to it } }
for (acc in nonPrepaid) { val nonCards = displayed.filter { !it.second.isCard }
val cards = displayed.filter { it.second.isCard }
val groups = LinkedHashMap<String, MutableList<Pair<BankAccount, AccountListDisplay>>>()
for ((acc, display) in nonCards) {
val title = sectionTitle(acc) val title = sectionTitle(acc)
groups.getOrPut(title) { mutableListOf() }.add(acc) groups.getOrPut(title) { mutableListOf() }.add(acc to display)
} }
for ((title, group) in groups) { for ((title, group) in groups) {
add(Item.SectionTitle(title)) add(Item.SectionTitle(title))
group.forEach { add(Item.Account(it)) } group.forEach { (acc, display) -> add(Item.Account(acc, display)) }
} }
if (prepaid.isNotEmpty()) { if (cards.isNotEmpty()) {
add(Item.SectionTitle("Cards · Bank of Maldives")) add(Item.SectionTitle("Cards · Bank of Maldives"))
prepaid.forEach { add(Item.Card(it)) } cards.forEach { (acc, display) -> add(Item.Card(acc, display)) }
} }
} }
private fun sectionTitle(account: MibAccount): String { private fun sectionTitle(account: BankAccount): String {
val profileLabel = when (account.profileType) { val bankName = when (account.bank) {
"0" -> "Personal" "BML" -> "Bank of Maldives"
"1" -> "Business" "FAHIPAY" -> "Fahipay"
else -> account.profileName "MIB" -> "Maldives Islamic Bank"
else -> account.bank
} }
val bank = when { val profileLabel = when (account.bank) {
account.profileType.startsWith("BML") -> "Bank of Maldives" "MIB" -> account.productCode.ifBlank { account.profileName }
account.profileType == "FAHIPAY" -> "Fahipay" else -> account.profileName
else -> "Maldives Islamic Bank"
} }
return if (profileLabel.isNotBlank()) "$profileLabel · $bank" else bank return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
} }
override fun getItemViewType(position: Int) = when (items[position]) { override fun getItemViewType(position: Int) = when (items[position]) {
@@ -79,17 +93,17 @@ class AccountsAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context) val inflater = LayoutInflater.from(parent.context)
return when (viewType) { return when (viewType) {
TYPE_HEADER -> SectionViewHolder(ItemDateHeaderBinding.inflate(inflater, parent, false)) TYPE_HEADER -> SectionViewHolder(ItemDateHeaderBinding.inflate(inflater, parent, false))
TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false)) TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false))
else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false)) else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
} }
} }
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (val item = items[position]) { when (val item = items[position]) {
is Item.SectionTitle -> (holder as SectionViewHolder).bind(item) is Item.SectionTitle -> (holder as SectionViewHolder).bind(item)
is Item.Account -> (holder as AccountViewHolder).bind(item.account) is Item.Account -> (holder as AccountViewHolder).bind(item.account, item.display)
is Item.Card -> (holder as CardViewHolder).bind(item.account) is Item.Card -> (holder as CardViewHolder).bind(item.account, item.display)
} }
} }
@@ -104,42 +118,64 @@ class AccountsAdapter(
private inner class AccountViewHolder(private val binding: ItemAccountBinding) : private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(account: MibAccount) { private var boundHash: String? = null
binding.tvAccountName.text = account.accountBriefName
binding.tvAccountNumber.text = account.accountNumber fun bind(account: BankAccount, display: AccountListDisplay) {
val label = if (account.profileType.startsWith("BML")) binding.tvAccountName.text = display.name
BmlDashboardParser.productLabel(account.accountTypeName) binding.tvAccountNumber.text = display.number
else binding.tvAccountType.text = display.typeLabel
MibAccountParser.productLabel(account.accountTypeName) binding.tvBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
binding.tvPillType.text = label binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
binding.root.setOnClickListener { onAccountClick(account) } binding.root.setOnClickListener { onAccountClick(account) }
binding.root.setOnLongClickListener { binding.root.setOnLongClickListener {
copyToClipboard(it.context, account.accountNumber) copyToClipboard(it.context, display.number)
true true
} }
val staticLogo = when (account.bank) {
"BML" -> R.drawable.bml_logo_vector
"FAHIPAY" -> R.drawable.fahipay_logo
"MIB" -> R.drawable.mib_logo
else -> null
}
if (staticLogo != null) binding.ivBankLogo.setImageResource(staticLogo)
else binding.ivBankLogo.setImageDrawable(null)
val hash = account.profileImageHash
boundHash = hash
when {
account.bank == "MIB" && hash != null && profileImageLoader != null -> {
profileImageLoader.invoke(hash) { bitmap ->
if (boundHash == hash) binding.ivBankLogo.setImageBitmap(bitmap)
}
}
(account.bank == "BML" || account.bank == "FAHIPAY") && localProfileImageLoader != null -> {
localProfileImageLoader.invoke(account.loginTag, account.profileId) { bitmap ->
if (boundHash == hash) binding.ivBankLogo.setImageBitmap(bitmap)
}
}
}
} }
} }
private inner class CardViewHolder(private val binding: ItemCardBinding) : private inner class CardViewHolder(private val binding: ItemCardBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(account: MibAccount) { fun bind(account: BankAccount, display: AccountListDisplay) {
binding.ivCardBrand.setImageResource(cardBrandIcon(account.accountTypeName)) binding.ivCardBrand.setImageResource(display.cardBrandIcon)
binding.tvCardName.text = account.accountBriefName binding.tvCardName.text = display.name
binding.tvCardNumber.text = account.accountNumber binding.tvCardNumber.text = display.number
binding.tvCardProduct.text = BmlDashboardParser.productLabel(account.accountTypeName) binding.tvCardProduct.text = display.typeLabel
binding.layoutCardBalance.visibility = View.VISIBLE binding.layoutCardBalance.visibility = View.VISIBLE
binding.tvCardBalance.text = "${account.currencyName} ${account.availableBalance}" binding.tvCardBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
if (display.statusLabel != null) {
val isActive = account.statusDesc.equals("Active", ignoreCase = true) binding.tvCardStatus.text = display.statusLabel
if (isActive) {
binding.tvCardStatus.visibility = View.GONE
binding.root.alpha = 1f
} else {
binding.tvCardStatus.text = account.statusDesc
binding.tvCardStatus.visibility = View.VISIBLE binding.tvCardStatus.visibility = View.VISIBLE
binding.root.alpha = 0.45f binding.root.alpha = 0.45f
} else {
binding.tvCardStatus.visibility = View.GONE
binding.root.alpha = 1f
} }
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
binding.root.setOnClickListener { onAccountClick(account) } binding.root.setOnClickListener { onAccountClick(account) }
} }
} }
@@ -149,18 +185,15 @@ class AccountsAdapter(
private const val TYPE_ACCOUNT = 1 private const val TYPE_ACCOUNT = 1
private const val TYPE_CARD = 2 private const val TYPE_CARD = 2
fun maskAmount(formatted: String): String {
val currency = formatted.substringBefore(' ', formatted)
return "$currency ••••••"
}
private fun copyToClipboard(context: Context, accountNumber: String) { private fun copyToClipboard(context: Context, accountNumber: String) {
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber)) cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber))
Toast.makeText(context, "Account number copied", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Account number copied", Toast.LENGTH_SHORT).show()
} }
private fun cardBrandIcon(productName: String): Int = when {
productName.contains("AMEX", ignoreCase = true) ||
productName.contains("AMERICAN EXPRESS", ignoreCase = true) -> R.drawable.americanexpress
productName.contains("VISA", ignoreCase = true) -> R.drawable.visa
productName.contains("MASTERCARD", ignoreCase = true) -> R.drawable.mastercard
else -> R.drawable.ic_nav_card
}
} }
} }
@@ -1,14 +1,25 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Bundle import android.os.Bundle
import android.util.Base64
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 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 androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
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.R
import sh.sar.basedbank.databinding.FragmentAccountsBinding import sh.sar.basedbank.databinding.FragmentAccountsBinding
import sh.sar.basedbank.util.ProfileImageStore
class AccountsFragment : Fragment() { class AccountsFragment : Fragment() {
@@ -16,6 +27,7 @@ class AccountsFragment : Fragment() {
private val binding get() = _binding!! private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels() private val viewModel: HomeViewModel by activityViewModels()
private lateinit var adapter: AccountsAdapter private lateinit var adapter: AccountsAdapter
private val profileImageCache = mutableMapOf<String, Bitmap>()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentAccountsBinding.inflate(inflater, container, false) _binding = FragmentAccountsBinding.inflate(inflater, container, false)
@@ -23,13 +35,74 @@ class AccountsFragment : Fragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = AccountsAdapter(emptyList()) { account -> val app = requireActivity().application as BasedBankApp
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account)) adapter = AccountsAdapter(
accounts = emptyList(),
onAccountClick = { account ->
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
},
profileImageLoader = { hash, onLoaded ->
profileImageCache[hash]?.let { onLoaded(it); return@AccountsAdapter }
viewLifecycleOwner.lifecycleScope.launch {
val bitmap = withContext(Dispatchers.IO) {
try {
val session = app.anyMibSession() ?: return@withContext null
val b64 = app.anyMibFlow()?.fetchProfileImage(session, hash) ?: return@withContext null
val bytes = Base64.decode(b64, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} catch (_: Exception) { null }
}
if (bitmap != null) {
profileImageCache[hash] = bitmap
onLoaded(bitmap)
}
}
},
localProfileImageLoader = { loginTag, profileId, onLoaded ->
val cacheKey = "$loginTag|$profileId"
profileImageCache[cacheKey]?.let { onLoaded(it); return@AccountsAdapter }
viewLifecycleOwner.lifecycleScope.launch {
val bitmap = withContext(Dispatchers.IO) {
val ctx = requireContext()
if (loginTag.startsWith("bml_") && profileId.isNotBlank()) {
ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(profileId))
} else if (loginTag.startsWith("fahipay_")) {
val loginId = ProfileImageStore.loginIdFromTag(loginTag)
ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
} else null
}
if (bitmap != null) {
profileImageCache[cacheKey] = bitmap
onLoaded(bitmap)
}
}
}
)
adapter.onTransferClick = { account ->
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(account))
} }
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) } val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
}
viewModel.accounts.observe(viewLifecycleOwner) {
adapter.updateAccounts(it)
binding.emptyView.visibility = if (it.isEmpty()) View.VISIBLE else View.GONE
}
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefresh()
binding.swipeRefresh.isRefreshing = false
}
} }
override fun onResume() { override fun onResume() {
@@ -0,0 +1,108 @@
package sh.sar.basedbank.ui.home
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.databinding.ItemTransactionBinding
import sh.sar.basedbank.util.ReceiptStore
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class ActivitiesAdapter(
private val onItemClick: (ReceiptStore.Entry) -> Unit
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item {
data class DateHeader(val label: String) : Item()
data class ReceiptItem(val entry: ReceiptStore.Entry) : Item()
}
private val displayItems = mutableListOf<Item>()
fun setEntries(entries: List<ReceiptStore.Entry>) {
displayItems.clear()
var lastDateKey = ""
for (entry in entries) {
val dateKey = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date(entry.savedAt))
if (dateKey != lastDateKey) {
displayItems.add(Item.DateHeader(formatDateHeader(entry.savedAt)))
lastDateKey = dateKey
}
displayItems.add(Item.ReceiptItem(entry))
}
notifyDataSetChanged()
}
override fun getItemCount() = displayItems.size
override fun getItemViewType(position: Int) =
if (displayItems[position] is Item.DateHeader) TYPE_DATE_HEADER else TYPE_RECEIPT
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return if (viewType == TYPE_DATE_HEADER)
DateHeaderVH(ItemDateHeaderBinding.inflate(inflater, parent, false))
else
ReceiptVH(ItemTransactionBinding.inflate(inflater, parent, false))
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
when (holder) {
is DateHeaderVH -> holder.bind((displayItems[position] as Item.DateHeader).label)
is ReceiptVH -> holder.bind((displayItems[position] as Item.ReceiptItem).entry)
}
}
inner class DateHeaderVH(private val b: ItemDateHeaderBinding) :
RecyclerView.ViewHolder(b.root) {
fun bind(label: String) { b.tvDateHeader.text = label }
}
inner class ReceiptVH(private val b: ItemTransactionBinding) :
RecyclerView.ViewHolder(b.root) {
fun bind(entry: ReceiptStore.Entry) {
val d = entry.data
val colorHex = d.fromColorHex.takeIf { it.isNotBlank() } ?: "#607D8B"
val initial = d.toLabel.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
b.fvAvatar.background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY })
}
b.tvInitial.visibility = android.view.View.VISIBLE
b.tvInitial.text = initial
b.tvCounterparty.text = d.toLabel
b.tvCounterparty.visibility = android.view.View.VISIBLE
b.tvDescription.text = buildString {
append(d.fromLabel)
if (d.toBank.isNotBlank()) append(" · ${d.toBank}")
}
b.tvDate.text = formatTime(entry.savedAt)
b.tvAmount.text = "- ${d.currency} ${d.amount}"
b.tvAmount.setTextColor(Color.parseColor("#FF7043"))
b.root.setOnClickListener { onItemClick(entry) }
}
}
private fun formatDateHeader(millis: Long): String {
val sdf = SimpleDateFormat("EEEE, d MMMM yyyy", Locale.US)
return sdf.format(Date(millis))
}
private fun formatTime(millis: Long): String {
val sdf = SimpleDateFormat("HH:mm", Locale.US)
return sdf.format(Date(millis))
}
companion object {
private const val TYPE_DATE_HEADER = 0
private const val TYPE_RECEIPT = 1
}
}
@@ -0,0 +1,95 @@
package sh.sar.basedbank.ui.home
import android.content.Context
import android.os.Bundle
import android.text.Editable
import android.text.TextWatcher
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 androidx.recyclerview.widget.LinearLayoutManager
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentActivitiesBinding
import sh.sar.basedbank.util.ReceiptStore
class ActivitiesFragment : Fragment() {
private var _binding: FragmentActivitiesBinding? = null
private val binding get() = _binding!!
private lateinit var adapter: ActivitiesAdapter
private val allEntries = mutableListOf<ReceiptStore.Entry>()
private var searchQuery = ""
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentActivitiesBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = ActivitiesAdapter { entry ->
(activity as? HomeActivity)?.showWithBackStack(
TransferReceiptFragment.newInstance(entry.data, null)
)
}
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext()
.getSharedPreferences("prefs", Context.MODE_PRIVATE)
.getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
}
binding.etSearch.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) {
searchQuery = s?.toString()?.trim() ?: ""
filterAndDisplay()
}
})
loadEntries()
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_activities)
// Reload in case a new receipt was added while we were away
loadEntries()
}
private fun loadEntries() {
allEntries.clear()
allEntries.addAll(ReceiptStore.loadAll(requireContext()))
filterAndDisplay()
}
private fun filterAndDisplay() {
val filtered = if (searchQuery.isBlank()) allEntries
else allEntries.filter { entry ->
entry.data.toLabel.contains(searchQuery, ignoreCase = true) ||
entry.data.fromLabel.contains(searchQuery, ignoreCase = true) ||
entry.data.toAccount.contains(searchQuery, ignoreCase = true) ||
entry.data.toBank.contains(searchQuery, ignoreCase = true) ||
entry.data.mibReferenceNo.contains(searchQuery, ignoreCase = true) ||
entry.data.bmlReference.contains(searchQuery, ignoreCase = true)
}
adapter.setEntries(filtered)
binding.emptyView.visibility = if (filtered.isEmpty()) View.VISIBLE else View.GONE
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}
@@ -30,8 +30,9 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlAccountValidation import sh.sar.basedbank.api.bml.BmlAccountValidation
import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.bml.BmlContactsClient
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory import sh.sar.basedbank.api.bml.BmlValidateClient
import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibProfile import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.MibTransferClient import sh.sar.basedbank.api.mib.MibTransferClient
@@ -50,6 +51,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val label: String, val label: String,
val isBml: Boolean, val isBml: Boolean,
val mibProfile: MibProfile? = null, val mibProfile: MibProfile? = null,
val mibLoginId: String? = null,
val bmlLoginId: String? = null, val bmlLoginId: String? = null,
val subtitle: String = "" val subtitle: String = ""
) )
@@ -63,7 +65,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private var selectedImageBase64: String = "" private var selectedImageBase64: String = ""
private var selectedCategoryId: String = "0" private var selectedCategoryId: String = "0"
private var categories: List<MibBeneficiaryCategory> = emptyList() private var categories: List<BankContactCategory> = emptyList()
private val imagePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? -> private val imagePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
uri ?: return@registerForActivityResult uri ?: return@registerForActivityResult
@@ -91,14 +93,19 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private fun buildDestinations(): List<DestinationOption> { private fun buildDestinations(): List<DestinationOption> {
val list = mutableListOf<DestinationOption>() val list = mutableListOf<DestinationOption>()
for (profile in app.mibProfiles) { for ((loginId, profiles) in app.mibProfilesMap) {
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile)) for (profile in profiles) {
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile, mibLoginId = loginId, subtitle = profile.cifType))
}
} }
val store = CredentialStore(requireContext()) val store = CredentialStore(requireContext())
for ((loginId, _) in app.bmlSessions) { for ((loginId, profiles) in app.bmlProfilesMap) {
val ownerName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() } ?: loginId val fullName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
val profileName = app.bmlAccounts.firstOrNull { it.loginTag == "bml_$loginId" }?.profileName ?: "" for (profile in profiles) {
list.add(DestinationOption("BML · $ownerName", isBml = true, bmlLoginId = loginId, subtitle = profileName)) if (app.bmlSessions.containsKey(profile.profileId)) {
list.add(DestinationOption("BML · ${fullName ?: profile.name}", isBml = true, bmlLoginId = profile.profileId, subtitle = profile.name))
}
}
} }
return list return list
} }
@@ -238,18 +245,16 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
private fun lookupForBml(input: String): BmlAccountValidation? { private fun lookupForBml(input: String): BmlAccountValidation? {
val loginId = selectedDest?.bmlLoginId ?: return null val loginId = selectedDest?.bmlLoginId ?: return null
val bmlSess = app.bmlSessions[loginId] ?: return null val bmlSess = app.bmlSessions[loginId] ?: return null
val bmlFlow = BmlLoginFlow()
// 1) Try BML validate // 1) Try BML validate
val validated = try { bmlFlow.validateAccount(bmlSess, input) } catch (_: Exception) { null } val validated = try { BmlValidateClient().validateAccount(bmlSess, input) } catch (_: Exception) { null }
if (validated != null) return validated if (validated != null) return validated
// 2) Try BML MIB verify // 2) Try BML MIB verify
val mibVerified = try { bmlFlow.verifyMibAccount(bmlSess, input) } catch (_: Exception) { null } val mibVerified = try { BmlValidateClient().verifyMibAccount(bmlSess, input) } catch (_: Exception) { null }
if (mibVerified != null) return mibVerified if (mibVerified != null) return mibVerified
// 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML) // 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML)
val mibSess = app.mibSession ?: return null val mibSess = app.anyMibSession() ?: return null
return try { return try {
val info = MibTransferClient().lookup(mibSess, input) val info = MibTransferClient().lookup(mibSess, input)
BmlAccountValidation( BmlAccountValidation(
@@ -266,11 +271,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
} }
private fun lookupForMib(dest: DestinationOption, input: String): BmlAccountValidation? { private fun lookupForMib(dest: DestinationOption, input: String): BmlAccountValidation? {
val mibSess = app.mibSession ?: return null val loginId = dest.mibLoginId ?: return null
val mibSess = app.mibSessions[loginId] ?: return null
val profile = dest.mibProfile ?: return null val profile = dest.mibProfile ?: return null
val mibResult = try { val mibResult = try {
app.mibLoginFlow.switchProfile(mibSess, profile) app.mibFlowFor(loginId).switchProfile(mibSess, profile)
val info = MibTransferClient().lookup(mibSess, input) val info = MibTransferClient().lookup(mibSess, input)
BmlAccountValidation( BmlAccountValidation(
trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL", trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL",
@@ -288,7 +294,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
// MIB lookup failed (e.g. BML USD account) — fall back to BML validate // MIB lookup failed (e.g. BML USD account) — fall back to BML validate
val bmlSess = app.anyBmlSession() ?: return null val bmlSess = app.anyBmlSession() ?: return null
return try { BmlLoginFlow().validateAccount(bmlSess, input) } catch (_: Exception) { null } return try { BmlValidateClient().validateAccount(bmlSess, input) } catch (_: Exception) { null }
} }
private fun showLookupResult(validation: BmlAccountValidation, input: String) { private fun showLookupResult(validation: BmlAccountValidation, input: String) {
@@ -414,23 +420,23 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val loginId = selectedDest?.bmlLoginId ?: return false val loginId = selectedDest?.bmlLoginId ?: return false
val bmlSess = app.bmlSessions[loginId] ?: return false val bmlSess = app.bmlSessions[loginId] ?: return false
val lookup = bmlLookup ?: return false val lookup = bmlLookup ?: return false
val bmlFlow = BmlLoginFlow()
val account = lookup.account val account = lookup.account
return when { return when {
account.matches(Regex("^7\\d{12}$")) -> account.matches(Regex("^7\\d{12}$")) ->
// BML account → IAT // BML account → IAT
bmlFlow.saveContact(bmlSess, "IAT", account, alias) BmlContactsClient().saveContact(bmlSess, "IAT", account, alias)
account.matches(Regex("^9\\d{16}$")) -> account.matches(Regex("^9\\d{16}$")) ->
// MIB internal → DOT; swift is BML's internal UUID for MIB bank // MIB internal → DOT; swift is BML's internal UUID for MIB bank
bmlFlow.saveContact(bmlSess, "DOT", account, alias, BmlContactsClient().saveContact(bmlSess, "DOT", account, alias,
currency = lookup.currency, name = lookup.name, swift = MIB_SWIFT_ON_BML) currency = lookup.currency, name = lookup.name, swift = MIB_SWIFT_ON_BML)
else -> false else -> false
} }
} }
private fun saveToMib(alias: String): Boolean { private fun saveToMib(alias: String): Boolean {
val mibSess = app.mibSession ?: return false
val dest = selectedDest ?: return false val dest = selectedDest ?: return false
val loginId = dest.mibLoginId ?: return false
val mibSess = app.mibSessions[loginId] ?: return false
val profile = dest.mibProfile ?: return false val profile = dest.mibProfile ?: return false
val account = mibLookupAccount ?: return false val account = mibLookupAccount ?: return false
val currency = binding.etCurrency.text?.toString()?.trim() ?: "MVR" val currency = binding.etCurrency.text?.toString()?.trim() ?: "MVR"
@@ -442,7 +448,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
val name = bmlLookup?.name ?: "" val name = bmlLookup?.name ?: ""
return try { return try {
app.mibLoginFlow.switchProfile(mibSess, profile) app.mibFlowFor(loginId).switchProfile(mibSess, profile)
MibContactsClient().createContact( MibContactsClient().createContact(
session = mibSess, session = mibSess,
benefType = benefType, benefType = benefType,
@@ -481,15 +487,16 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
if (dest.isBml) { if (dest.isBml) {
val loginId = dest.bmlLoginId ?: return@launch val loginId = dest.bmlLoginId ?: return@launch
val bmlSess = app.bmlSessions[loginId] ?: return@launch val bmlSess = app.bmlSessions[loginId] ?: return@launch
val fresh = BmlLoginFlow().fetchContacts(bmlSess, loginId) val fresh = BmlContactsClient().fetchContacts(bmlSess, loginId)
val existing = viewModel.contacts.value ?: emptyList() val existing = viewModel.contacts.value ?: emptyList()
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
viewModel.contacts.postValue(merged) viewModel.contacts.postValue(merged)
if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, fresh) if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, fresh)
} else { } else {
val profile = dest.mibProfile ?: return@launch val profile = dest.mibProfile ?: return@launch
val mibSess = app.mibSession ?: return@launch val mibLoginId = dest.mibLoginId ?: return@launch
app.mibLoginFlow.switchProfile(mibSess, profile) val mibSess = app.mibSessions[mibLoginId] ?: return@launch
app.mibFlowFor(mibLoginId).switchProfile(mibSess, profile)
val fresh = MibContactsClient().fetchContacts(mibSess) val fresh = MibContactsClient().fetchContacts(mibSess)
.map { it.copy(profileId = profile.profileId) } .map { it.copy(profileId = profile.profileId) }
val existing = viewModel.contacts.value ?: emptyList() val existing = viewModel.contacts.value ?: emptyList()
@@ -0,0 +1,388 @@
package sh.sar.basedbank.ui.home
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.os.Bundle
import android.util.Base64
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.Filter
import android.widget.Filterable
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
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.BmlQrPayClient
import sh.sar.basedbank.api.bml.BmlQrPayInfo
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentBmlQrPayBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.Totp
class BmlQrPayFragment : Fragment() {
private var _binding: FragmentBmlQrPayBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private var merchantInfo: BmlQrPayInfo? = null
private var selectedAccount: BankAccount? = null
companion object {
private const val ARG_QR_URL = "qr_url"
private const val ARG_FROM_ACCOUNT = "from_account"
fun newInstance(qrUrl: String, fromAccountNumber: String? = null) = BmlQrPayFragment().apply {
arguments = Bundle().apply {
putString(ARG_QR_URL, qrUrl)
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
}
}
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentBmlQrPayBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
setupFromDropdown()
binding.etAmount.addTextChangedListener { updatePayButton() }
binding.btnClearFromInfo.setOnClickListener {
selectedAccount = null
binding.cardFromInfo.visibility = View.GONE
binding.tilFrom.visibility = View.VISIBLE
binding.actvFrom.setText("", false)
updatePayButton()
}
binding.btnPay.setOnClickListener { initiatePay() }
val qrUrl = arguments?.getString(ARG_QR_URL) ?: run {
requireActivity().onBackPressedDispatcher.onBackPressed(); return
}
lookupMerchant(qrUrl)
}
private fun setupFromDropdown() {
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
val bmlAccounts = accounts.filter {
it.bank == "BML" &&
it.profileType != "BML_LOAN" &&
it.profileType != "BML_CREDIT"
}
val adapter = BmlAccountAdapter(bmlAccounts)
binding.actvFrom.setAdapter(adapter)
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
val picked = adapter.getItem(position) as? BankAccount ?: return@setOnItemClickListener
selectedAccount = picked
showFromCard(picked)
updatePayButton()
}
// Pre-select card passed in from the card wallet/dashboard
val preselect = arguments?.getString(ARG_FROM_ACCOUNT)
if (preselect != null && selectedAccount == null) {
bmlAccounts.firstOrNull { it.accountNumber == preselect }?.let {
selectedAccount = it
showFromCard(it)
updatePayButton()
}
}
}
}
private fun showFromCard(account: BankAccount) {
binding.tvFromAccountName.text = account.accountBriefName
binding.tvFromAccountNumber.text = account.accountNumber
val currency = account.currencyName.ifBlank { "MVR" }
binding.tvFromBalance.text = "$currency ${account.availableBalance}"
binding.ivFromPhoto.setImageBitmap(makeInitialsBitmap(account.accountBriefName, "#0066A1"))
binding.tilFrom.visibility = View.GONE
binding.cardFromInfo.visibility = View.VISIBLE
// Update amount prefix to match account currency
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
}
private fun lookupMerchant(qrUrl: String) {
val base64Url = Base64.encodeToString(qrUrl.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
val app = requireActivity().application as BasedBankApp
val session = app.anyBmlSession() ?: run {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
requireActivity().onBackPressedDispatcher.onBackPressed()
return
}
binding.tvLookingUp.visibility = View.VISIBLE
binding.cardMerchant.visibility = View.GONE
viewLifecycleOwner.lifecycleScope.launch {
val info = withContext(Dispatchers.IO) {
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
catch (_: Exception) { null }
}
if (_binding == null) return@launch
binding.tvLookingUp.visibility = View.GONE
if (info == null) {
Toast.makeText(requireContext(), R.string.bml_qr_lookup_failed, Toast.LENGTH_LONG).show()
requireActivity().onBackPressedDispatcher.onBackPressed()
return@launch
}
merchantInfo = info
populateMerchant(info)
}
}
private fun populateMerchant(info: BmlQrPayInfo) {
binding.tvMerchantName.text = info.merchantName
binding.tvMerchantAddress.text = info.merchantAddress
binding.ivMerchantIcon.setImageBitmap(makeInitialsBitmap(info.merchantName, "#0066A1"))
binding.cardMerchant.visibility = View.VISIBLE
// Dynamic QR: pre-fill amount and lock the field
if (info.amount > 0.0) {
binding.etAmount.setText("%.2f".format(info.amount))
binding.tilAmount.isEnabled = false
}
updatePayButton()
}
private fun updatePayButton() {
val merchant = merchantInfo ?: run { binding.btnPay.isEnabled = false; return }
val account = selectedAccount ?: run { binding.btnPay.isEnabled = false; return }
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
binding.btnPay.isEnabled = amount > 0.0
}
private fun initiatePay() {
val info = merchantInfo ?: return
val account = selectedAccount ?: run {
Toast.makeText(requireContext(), R.string.bml_qr_select_account, Toast.LENGTH_SHORT).show()
return
}
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
val amount = amountStr.toDoubleOrNull()
if (amount == null || amount <= 0) {
binding.tilAmount.error = "Enter a valid amount"
return
}
binding.tilAmount.error = null
val debitAccount = account.internalId.ifBlank {
Toast.makeText(requireContext(), R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
return
}
val currency = info.currency
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.transfer)
.setMessage("Pay $currency ${"%.2f".format(amount)} to ${info.merchantName}?\n\nFrom: ${account.accountBriefName} · ${account.accountNumber}")
.setPositiveButton(R.string.transfer_confirm) { _, _ ->
executePay(account, debitAccount, info.requestId, amount, currency, info.merchantName)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun executePay(
account: BankAccount,
debitAccount: String,
requestId: String,
amount: Double,
currency: String,
merchantName: String
) {
val app = requireActivity().application as BasedBankApp
val loginId = account.loginTag.removePrefix("bml_")
val session = app.bmlSessionFor(account) ?: run {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return
}
val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) }
?: run {
Toast.makeText(requireContext(), "OTP unavailable", Toast.LENGTH_SHORT).show()
return
}
binding.btnPay.isEnabled = false
(activity as? HomeActivity)?.setRefreshing(true)
viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
try {
val initiated = BmlQrPayClient().initiatePayment(session, debitAccount, requestId, amount, currency)
if (!initiated) return@withContext null
val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) } ?: otp
BmlQrPayClient().confirmPayment(session, debitAccount, requestId, amount, currency, confirmOtp)
} catch (e: Exception) {
sh.sar.basedbank.api.bml.BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
}
}
(activity as? HomeActivity)?.setRefreshing(false)
if (_binding == null) return@launch
if (result == null) {
binding.btnPay.isEnabled = true
Toast.makeText(requireContext(), "Failed to initiate payment", Toast.LENGTH_LONG).show()
return@launch
}
if (result.success) {
showSuccessDialog(
merchant = result.merchant.ifBlank { merchantName },
amount = result.amount.ifBlank { "%.2f".format(amount) },
currency = result.currency.ifBlank { currency }
)
} else {
binding.btnPay.isEnabled = true
Toast.makeText(requireContext(), result.errorMessage, Toast.LENGTH_LONG).show()
}
}
}
private fun showSuccessDialog(merchant: String, amount: String, currency: String) {
val ctx = requireContext()
val dp = resources.displayMetrics.density
val container = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
}
// Green checkmark icon
container.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() }
})
// Amount
container.addView(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, Color.BLACK))
gravity = Gravity.CENTER
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply { gravity = Gravity.CENTER_HORIZONTAL; bottomMargin = (8 * dp).toInt() }
})
// Merchant name
container.addView(TextView(ctx).apply {
text = merchant
textSize = 14f
setTextColor(com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY))
gravity = Gravity.CENTER
layoutParams = LinearLayout.LayoutParams(
LinearLayout.LayoutParams.WRAP_CONTENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply { gravity = 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 makeInitialsBitmap(name: String, colorHex: String): Bitmap {
val sizePx = (resources.displayMetrics.density * 40).toInt()
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
paint.color = bgColor
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
paint.color = Color.WHITE
paint.textSize = sizePx * 0.42f
paint.textAlign = Paint.Align.CENTER
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val metrics = paint.fontMetrics
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
return bm
}
override fun onResume() {
super.onResume()
requireActivity().title = "BML QR Pay"
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private inner class BmlAccountAdapter(private val accounts: List<BankAccount>) :
BaseAdapter(), Filterable {
override fun getCount() = accounts.size
override fun getItem(position: Int) = accounts[position]
override fun getItemId(position: Int) = position.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup) =
getDropDownView(position, convertView, parent)
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val acc = accounts[position]
val b = if (convertView?.tag is ItemAccountDropdownBinding) {
convertView.tag as ItemAccountDropdownBinding
} else {
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
.also { it.root.tag = it }
}
val ownerPrefix = if (acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
b.tvDropdownAccountNumber.text = acc.accountNumber
b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}"
b.root.alpha = 1f
return b.root
}
override fun getFilter() = object : Filter() {
override fun performFiltering(c: CharSequence?) =
FilterResults().apply { values = accounts; count = accounts.size }
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
override fun convertResultToString(r: Any?) =
(r as? BankAccount)?.let {
val prefix = if (it.profileName.isNotBlank()) "${it.profileName} · " else ""
"$prefix${it.accountBriefName}"
} ?: ""
}
}
}
@@ -0,0 +1,139 @@
package sh.sar.basedbank.ui.home
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentCardSettingsBinding
import sh.sar.basedbank.util.bmlapi.BmlCardParser
class CardSettingsFragment : Fragment() {
private var _binding: FragmentCardSettingsBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCardSettingsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val adapter = CardSettingsAdapter(emptyList(), requireContext())
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
}
binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefresh()
}
val updateCardList = {
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
val bmlItems = (viewModel.accounts.value ?: emptyList())
.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
.map { CardItem.Bml(it) }
val all = mibItems + bmlItems
adapter.update(all)
binding.loadingView.visibility = View.GONE
binding.swipeRefresh.isRefreshing = false
binding.emptyView.visibility = if (all.isEmpty()) View.VISIBLE else View.GONE
binding.recyclerView.visibility = if (all.isEmpty()) View.GONE else View.VISIBLE
}
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
if (viewModel.mibCards.value == null) {
binding.loadingView.visibility = View.VISIBLE
(activity as? HomeActivity)?.triggerRefreshCards()
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_card_settings)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private inner class CardSettingsAdapter(
private var cards: List<CardItem>,
private val context: Context
) : RecyclerView.Adapter<CardSettingsAdapter.VH>() {
fun update(newCards: List<CardItem>) {
cards = newCards
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
VH(LayoutInflater.from(context).inflate(R.layout.item_card_settings_entry, parent, false))
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position])
override fun getItemCount() = cards.size
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
private val tvCardType: TextView = view.findViewById(R.id.tvCardType)
private val tvCardStatus: TextView = view.findViewById(R.id.tvCardStatus)
private val btnChangePin: View = view.findViewById(R.id.btnChangePin)
private val btnFreeze: View = view.findViewById(R.id.btnFreeze)
private val btnBlock: View = view.findViewById(R.id.btnBlock)
fun bind(item: CardItem) {
when (item) {
is CardItem.Mib -> {
tvCardOwner.text = item.card.cardHolderName
tvCardNumber.text = PayWithCardFragment.formatMasked(item.card.maskedCardNumber)
tvCardType.text = item.card.cardTypeDesc
val assetPath = PayWithCardFragment.cardImageAsset(item.card)
if (assetPath != null) PayWithCardFragment.loadCardImage(ivCardImage, assetPath)
else ivCardImage.setImageDrawable(null)
PayWithCardFragment.bindCardStatus(tvCardStatus, PayWithCardFragment.mibCardStatusLabel(item.card.cardStatus))
itemView.alpha = 1f
}
is CardItem.Bml -> {
tvCardOwner.text = item.account.accountBriefName
tvCardNumber.text = PayWithCardFragment.formatMasked(item.account.accountNumber)
tvCardType.text = item.account.accountTypeName
PayWithCardFragment.loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
val isActive = item.account.statusDesc.equals("Active", ignoreCase = true)
val bmlStatus = item.account.statusDesc.takeUnless { isActive }
PayWithCardFragment.bindCardStatus(tvCardStatus, bmlStatus)
itemView.alpha = if (isActive) 1f else 0.45f
}
}
val wip = View.OnClickListener {
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
btnChangePin.setOnClickListener(wip)
btnFreeze.setOnClickListener(wip)
btnBlock.setOnClickListener(wip)
}
}
}
}
@@ -31,7 +31,9 @@ class ContactPickerAdapter(
val isSameAsFrom: Boolean = false, val isSameAsFrom: Boolean = false,
val isManualEntry: Boolean = false, val isManualEntry: Boolean = false,
val imageHash: String? = null, val imageHash: String? = null,
val inactiveReason: String? = null val inactiveReason: String? = null,
val balance: String? = null,
val bankLogoRes: Int? = null
) : PickerItem() ) : PickerItem()
} }
@@ -89,14 +91,31 @@ class ContactPickerAdapter(
binding.tvPrimary.text = item.displayName binding.tvPrimary.text = item.displayName
binding.tvSecondary.text = item.subtitle binding.tvSecondary.text = item.subtitle
val cached = item.imageHash?.let { imageCache[it] } if (item.balance != null) {
if (cached != null) { binding.tvBalance.text = item.balance
binding.ivIcon.setImageBitmap(cached) binding.tvBalance.visibility = android.view.View.VISIBLE
} else { } else {
val iconChar = if (item.isManualEntry) "" else item.displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?" binding.tvBalance.visibility = android.view.View.GONE
val iconColor = if (item.isManualEntry) "#546E7A" else item.colorHex }
binding.ivIcon.setImageBitmap(makeInitialsBitmap(iconChar, iconColor, binding.ivIcon.context))
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash) val cached = item.imageHash?.let { imageCache[it] }
when {
cached != null -> {
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivIcon.setImageBitmap(cached)
}
item.bankLogoRes != null -> {
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
binding.ivIcon.setImageResource(item.bankLogoRes)
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
}
else -> {
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
val iconChar = if (item.isManualEntry) "" else item.displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val iconColor = if (item.isManualEntry) "#546E7A" else item.colorHex
binding.ivIcon.setImageBitmap(makeInitialsBitmap(iconChar, iconColor, binding.ivIcon.context))
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
}
} }
binding.root.alpha = if (item.isSameAsFrom || item.inactiveReason != null) 0.4f else 1.0f binding.root.alpha = if (item.isSameAsFrom || item.inactiveReason != null) 0.4f else 1.0f
@@ -24,7 +24,11 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.databinding.SheetContactPickerBinding import sh.sar.basedbank.databinding.SheetContactPickerBinding
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.ProfileImageStore
import sh.sar.basedbank.util.RecentsCache import sh.sar.basedbank.util.RecentsCache
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
class ContactPickerSheetFragment : BottomSheetDialogFragment() { class ContactPickerSheetFragment : BottomSheetDialogFragment() {
@@ -36,7 +40,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
private val sharedImageCache = mutableMapOf<String, Bitmap>() private val sharedImageCache = mutableMapOf<String, Bitmap>()
private val profileImageHashes = mutableSetOf<String>() private val profileImageHashes = mutableSetOf<String>()
private val app get() = requireActivity().application as BasedBankApp private val app get() = requireActivity().application as BasedBankApp
private val session get() = app.mibSession private val session get() = app.anyMibSession()
private var fromAccountNumber: String = "" private var fromAccountNumber: String = ""
private var mediator: TabLayoutMediator? = null private var mediator: TabLayoutMediator? = null
@@ -145,8 +149,9 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
viewModel.contacts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() } viewModel.contacts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() } viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
(activity as? HomeActivity)?.loadAllContacts() (activity as? HomeActivity)?.triggerRefresh()
} }
private fun attachMediator(pages: List<TabDef>) { private fun attachMediator(pages: List<TabDef>) {
@@ -183,6 +188,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
private fun buildPageItems(tabTag: String?): List<ContactPickerAdapter.PickerItem> { private fun buildPageItems(tabTag: String?): List<ContactPickerAdapter.PickerItem> {
val search = binding.etSheetSearch.text?.toString()?.trim() ?: "" val search = binding.etSheetSearch.text?.toString()?.trim() ?: ""
val hide = viewModel.hideAmounts.value ?: false
val items = mutableListOf<ContactPickerAdapter.PickerItem>() val items = mutableListOf<ContactPickerAdapter.PickerItem>()
if (tabTag == RECENTS_TAG) { if (tabTag == RECENTS_TAG) {
@@ -209,11 +215,11 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber } val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
val fromCurrency = fromAccount?.currencyName ?: "" val fromCurrency = fromAccount?.currencyName ?: ""
val fromLoginTag = fromAccount?.loginTag ?: "" val fromLoginTag = fromAccount?.loginTag ?: ""
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT" val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT" || fromAccount?.profileType == "BML_DEBIT"
if (tabTag == MY_ACCOUNTS_TAG) { if (tabTag == MY_ACCOUNTS_TAG) {
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" } val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" }
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" } val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
val filteredRegular = if (search.isBlank()) regularAccounts else regularAccounts.filter { val filteredRegular = if (search.isBlank()) regularAccounts else regularAccounts.filter {
it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search) it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
@@ -223,16 +229,29 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
for (acc in filteredRegular) { for (acc in filteredRegular) {
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash) if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
val isSame = acc.accountNumber == fromAccountNumber val isSame = acc.accountNumber == fromAccountNumber
val parsedBalance = AccountListParser.from(acc)?.balance
?: "${acc.currencyName} ${acc.availableBalance}"
val balance = if (hide) maskAmount(parsedBalance) else parsedBalance
val logoRes = when (acc.bank) {
"BML" -> R.drawable.bml_logo_vector
"FAHIPAY" -> R.drawable.fahipay_logo
"MIB" -> R.drawable.mib_logo
else -> null
}
val localKey = localImageKeyFor(acc)
if (localKey != null) profileImageHashes.add("local:$localKey")
items.add(ContactPickerAdapter.PickerItem.Row( items.add(ContactPickerAdapter.PickerItem.Row(
accountNumber = acc.accountNumber, accountNumber = acc.accountNumber,
displayName = acc.accountBriefName, displayName = acc.accountBriefName,
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}", subtitle = acc.accountNumber,
colorHex = "#FE860E", colorHex = "#FE860E",
isSameAsFrom = isSame, isSameAsFrom = isSame,
imageHash = acc.profileImageHash, imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
inactiveReason = if (isSame) null inactiveReason = if (isSame) null
else if (fromIsCard && acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account" else if (fromIsCard && acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
else currencyMismatchReason(fromCurrency, acc.currencyName) else currencyMismatchReason(fromCurrency, acc.currencyName),
balance = balance,
bankLogoRes = logoRes
)) ))
} }
} }
@@ -246,17 +265,26 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash) if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
val isSame = acc.accountNumber == fromAccountNumber val isSame = acc.accountNumber == fromAccountNumber
val isActive = acc.statusDesc.equals("Active", ignoreCase = true) val isActive = acc.statusDesc.equals("Active", ignoreCase = true)
val isDebit = acc.profileType == "BML_DEBIT"
val parsedBalance = if (isDebit) null
else AccountListParser.from(acc)?.balance ?: "${acc.currencyName} ${acc.availableBalance}"
val balance = parsedBalance?.let { if (hide) maskAmount(it) else it }
val logoRes = BmlCardParser.cardNetworkIcon(acc) ?: R.drawable.bml_logo_vector
val localKey = localImageKeyFor(acc)
if (localKey != null) profileImageHashes.add("local:$localKey")
items.add(ContactPickerAdapter.PickerItem.Row( items.add(ContactPickerAdapter.PickerItem.Row(
accountNumber = acc.accountNumber, accountNumber = acc.accountNumber,
displayName = acc.accountBriefName, displayName = acc.accountBriefName,
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}", subtitle = acc.accountNumber,
colorHex = "#FE860E", colorHex = "#FE860E",
isSameAsFrom = isSame, isSameAsFrom = isSame,
imageHash = acc.profileImageHash, imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
inactiveReason = if (isSame) null inactiveReason = if (isSame) null
else if (!isActive) acc.statusDesc else if (!isActive) acc.statusDesc
else if (acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account" else if (acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
else currencyMismatchReason(fromCurrency, acc.currencyName) else currencyMismatchReason(fromCurrency, acc.currencyName),
balance = balance,
bankLogoRes = logoRes
)) ))
} }
} }
@@ -287,11 +315,22 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
private fun fetchImage(hash: String) { private fun fetchImage(hash: String) {
if (!pendingHashes.add(hash)) return if (!pendingHashes.add(hash)) return
// Local image keys for BML/Fahipay (prefixed with "local:")
if (hash.startsWith("local:")) {
val key = hash.removePrefix("local:")
lifecycleScope.launch(Dispatchers.IO) {
val bitmap = ProfileImageStore.load(requireContext(), key) ?: run {
pendingHashes.remove(hash); return@launch
}
withContext(Dispatchers.Main) { pagerAdapter.updateImage(hash, bitmap) }
}
return
}
val sess = session ?: return val sess = session ?: return
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {
val base64 = if (hash in profileImageHashes) { val base64 = if (hash in profileImageHashes) {
app.mibLoginFlow.fetchProfileImage(sess, hash) app.anyMibFlow()?.fetchProfileImage(sess, hash)
} else { } else {
MibContactsClient().fetchProfileImageBase64(sess, hash) MibContactsClient().fetchProfileImageBase64(sess, hash)
} ?: return@launch } ?: return@launch
@@ -306,9 +345,24 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
} }
} }
private fun maskAmount(formatted: String): String {
val currency = formatted.substringBefore(' ', formatted)
return "$currency ••••••"
}
private fun currencyMismatchReason(fromCurrency: String, toCurrency: String): String? = private fun currencyMismatchReason(fromCurrency: String, toCurrency: String): String? =
if (fromCurrency == "MVR" && toCurrency == "USD") "Cannot transfer from MVR to USD account" else null if (fromCurrency == "MVR" && toCurrency == "USD") "Cannot transfer from MVR to USD account" else null
/** Returns the ProfileImageStore key for BML/Fahipay accounts, or null for MIB/others. */
private fun localImageKeyFor(acc: sh.sar.basedbank.api.models.BankAccount): String? = when (acc.bank) {
"BML" -> if (acc.profileId.isNotBlank()) sh.sar.basedbank.util.ProfileImageStore.bmlKey(acc.profileId) else null
"FAHIPAY" -> {
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(acc.loginTag)
if (loginId.isNotBlank()) sh.sar.basedbank.util.ProfileImageStore.fahipayKey(loginId) else null
}
else -> null
}
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null
@@ -12,23 +12,23 @@ import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.databinding.ItemContactBinding import sh.sar.basedbank.databinding.ItemContactBinding
import sh.sar.basedbank.util.ContactDisplay
class ContactsAdapter( class ContactsAdapter(
private val imageCache: MutableMap<String, Bitmap>, private val imageCache: MutableMap<String, Bitmap>,
private val onImageNeeded: (hash: String) -> Unit, private val onImageNeeded: (hash: String) -> Unit,
private val onDeleteClick: (MibBeneficiary) -> Unit, private val onDeleteClick: (ContactDisplay) -> Unit,
private val onTransferClick: (MibBeneficiary) -> Unit private val onTransferClick: (ContactDisplay) -> Unit
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() { ) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
private var allContacts: List<MibBeneficiary> = emptyList() private var allContacts: List<ContactDisplay> = emptyList()
private var displayed: List<MibBeneficiary> = emptyList() private var displayed: List<ContactDisplay> = emptyList()
private var activeCategoryId: String? = null private var activeCategoryId: String? = null
private var searchQuery: String = "" private var searchQuery: String = ""
fun updateContacts(contacts: List<MibBeneficiary>) { fun updateContacts(contacts: List<ContactDisplay>) {
allContacts = contacts allContacts = contacts
applyFilter() applyFilter()
} }
@@ -36,7 +36,7 @@ class ContactsAdapter(
fun updateImage(hash: String, bitmap: Bitmap) { fun updateImage(hash: String, bitmap: Bitmap) {
imageCache[hash] = bitmap imageCache[hash] = bitmap
displayed.forEachIndexed { index, contact -> displayed.forEachIndexed { index, contact ->
if (contact.customerImgHash == hash) notifyItemChanged(index) if (contact.imageHash == hash) notifyItemChanged(index)
} }
} }
@@ -48,11 +48,11 @@ class ContactsAdapter(
private fun applyFilter() { private fun applyFilter() {
displayed = allContacts.filter { contact -> displayed = allContacts.filter { contact ->
val matchesCategory = activeCategoryId == null || contact.benefCategoryId == activeCategoryId val matchesCategory = activeCategoryId == null || contact.categoryId == activeCategoryId
val matchesSearch = searchQuery.isBlank() || val matchesSearch = searchQuery.isBlank() ||
contact.benefNickName.contains(searchQuery, ignoreCase = true) || contact.name.contains(searchQuery, ignoreCase = true) ||
contact.benefName.contains(searchQuery, ignoreCase = true) || contact.realName.contains(searchQuery, ignoreCase = true) ||
contact.benefAccount.contains(searchQuery) contact.accountNumber.contains(searchQuery)
matchesCategory && matchesSearch matchesCategory && matchesSearch
} }
notifyDataSetChanged() notifyDataSetChanged()
@@ -76,7 +76,7 @@ class ContactsAdapter(
binding.root.setOnLongClickListener { binding.root.setOnLongClickListener {
val pos = holder.bindingAdapterPosition val pos = holder.bindingAdapterPosition
if (pos == RecyclerView.NO_POSITION) return@setOnLongClickListener false if (pos == RecyclerView.NO_POSITION) return@setOnLongClickListener false
val account = displayed[pos].benefAccount val account = displayed[pos].accountNumber
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("account", account)) clipboard.setPrimaryClip(ClipData.newPlainText("account", account))
Toast.makeText(it.context, account, Toast.LENGTH_SHORT).show() Toast.makeText(it.context, account, Toast.LENGTH_SHORT).show()
@@ -88,7 +88,7 @@ class ContactsAdapter(
override fun onBindViewHolder(holder: ViewHolder, position: Int) { override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val contact = displayed[position] val contact = displayed[position]
val cachedImage = contact.customerImgHash?.let { hash -> val cachedImage = contact.imageHash?.let { hash ->
imageCache[hash] ?: run { onImageNeeded(hash); null } imageCache[hash] ?: run { onImageNeeded(hash); null }
} }
holder.bind(contact, cachedImage) holder.bind(contact, cachedImage)
@@ -99,21 +99,24 @@ class ContactsAdapter(
inner class ViewHolder(val binding: ItemContactBinding) : inner class ViewHolder(val binding: ItemContactBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(contact: MibBeneficiary, photo: Bitmap?) { fun bind(contact: ContactDisplay, photo: Bitmap?) {
val isFahipay = contact.benefType == "FAHIPAY" binding.tvContactName.text = contact.name
binding.tvContactName.text = contact.benefNickName binding.tvContactAccount.text = contact.accountNumber
binding.tvContactAccount.text = contact.benefAccount binding.tvRealName.text = contact.detail ?: ""
binding.tvRealName.text = if (isFahipay) "" else "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}" binding.tvRealName.visibility =
binding.tvRealName.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
binding.btnTransferContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE binding.btnTransferContact.visibility =
binding.btnEditContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
binding.btnDeleteContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE binding.btnEditContact.visibility =
if (contact.canEdit) android.view.View.VISIBLE else android.view.View.GONE
binding.btnDeleteContact.visibility =
if (contact.canDelete) android.view.View.VISIBLE else android.view.View.GONE
if (photo != null) { if (photo != null) {
binding.ivContactPhoto.setImageBitmap(photo) binding.ivContactPhoto.setImageBitmap(photo)
} else { } else {
binding.ivContactPhoto.setImageBitmap( binding.ivContactPhoto.setImageBitmap(
makeInitialsBitmap(contact.benefNickName, contact.bankColor) makeInitialsBitmap(contact.name, contact.bankColor)
) )
} }
} }
@@ -8,7 +8,9 @@ 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.appcompat.app.AlertDialog import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
@@ -21,13 +23,15 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibBeneficiary
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.databinding.FragmentContactsBinding import sh.sar.basedbank.databinding.FragmentContactsBinding
import sh.sar.basedbank.util.ContactDisplay
import sh.sar.basedbank.util.ContactImageCache import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.ContactListParser
import sh.sar.basedbank.util.ContactManager
import sh.sar.basedbank.util.ContactsCache import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.TransferNetwork
class ContactsFragment : Fragment() { class ContactsFragment : Fragment() {
@@ -38,9 +42,9 @@ class ContactsFragment : Fragment() {
private val pendingHashes = mutableSetOf<String>() private val pendingHashes = mutableSetOf<String>()
private val sharedImageCache = mutableMapOf<String, Bitmap>() private val sharedImageCache = mutableMapOf<String, Bitmap>()
private val app get() = requireActivity().application as BasedBankApp private val app get() = requireActivity().application as BasedBankApp
private val session get() = app.mibSession private val session get() = app.anyMibSession()
private var allContacts: List<MibBeneficiary> = emptyList() private var allContacts: List<ContactDisplay> = emptyList()
private var currentSearch: String = "" private var currentSearch: String = ""
private var mediator: TabLayoutMediator? = null private var mediator: TabLayoutMediator? = null
private lateinit var pagerAdapter: ContactsPagerAdapter private lateinit var pagerAdapter: ContactsPagerAdapter
@@ -53,9 +57,9 @@ class ContactsFragment : Fragment() {
private val density get() = resources.displayMetrics.density private val density get() = resources.displayMetrics.density
val contactAdapters: List<ContactsAdapter> = pages.map { page -> val contactAdapters: List<ContactsAdapter> = pages.map { page ->
ContactsAdapter( ContactsAdapter(
imageCache = sharedImageCache, imageCache = sharedImageCache,
onImageNeeded = { hash -> fetchImage(hash) }, onImageNeeded = { hash -> fetchImage(hash) },
onDeleteClick = { contact -> confirmDelete(contact) }, onDeleteClick = { contact -> confirmDelete(contact) },
onTransferClick = { contact -> openTransfer(contact) } onTransferClick = { contact -> openTransfer(contact) }
).also { a -> ).also { a ->
a.setFilter(page.categoryId, currentSearch) a.setFilter(page.categoryId, currentSearch)
@@ -63,7 +67,7 @@ class ContactsFragment : Fragment() {
} }
} }
fun updateContacts(contacts: List<MibBeneficiary>) = fun updateContacts(contacts: List<ContactDisplay>) =
contactAdapters.forEach { it.updateContacts(contacts) } contactAdapters.forEach { it.updateContacts(contacts) }
fun updateSearch(query: String) = fun updateSearch(query: String) =
@@ -86,7 +90,7 @@ class ContactsFragment : Fragment() {
) )
clipToPadding = false clipToPadding = false
val p4 = (4 * density).toInt() val p4 = (4 * density).toInt()
val p80 = (80 * density).toInt() val p80 = (65 * density).toInt()
setPadding(0, p4, 0, p80) setPadding(0, p4, 0, p80)
adapter = contactAdapters[viewType] adapter = contactAdapters[viewType]
} }
@@ -113,19 +117,35 @@ class ContactsFragment : Fragment() {
pagerAdapter.updateSearch(currentSearch) pagerAdapter.updateSearch(currentSearch)
} }
val fabMarginBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.fabAddContact) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
val lp = v.layoutParams as androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams
lp.bottomMargin = fabMarginBase + extraBottom
v.layoutParams = lp
insets
}
binding.fabAddContact.setOnClickListener { binding.fabAddContact.setOnClickListener {
AddContactSheetFragment().show(childFragmentManager, "add_contact") AddContactSheetFragment().show(childFragmentManager, "add_contact")
} }
(activity as? HomeActivity)?.loadAllContacts() (activity as? HomeActivity)?.loadAllContacts()
binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefresh()
binding.swipeRefresh.isRefreshing = false
}
viewModel.contactCategories.observe(viewLifecycleOwner) { cats -> viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
rebuildPager(cats) rebuildPager(cats)
} }
viewModel.contacts.observe(viewLifecycleOwner) { contacts -> viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
allContacts = contacts allContacts = ContactListParser.fromList(contacts)
pagerAdapter.updateContacts(contacts) pagerAdapter.updateContacts(allContacts)
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE binding.loadingView.visibility = View.GONE
} }
@@ -138,7 +158,7 @@ class ContactsFragment : Fragment() {
}.also { it.attach() } }.also { it.attach() }
} }
private fun rebuildPager(cats: List<MibBeneficiaryCategory>) { private fun rebuildPager(cats: List<BankContactCategory>) {
val pages = buildList { val pages = buildList {
add(TabPage(null, getString(R.string.contacts_tab_all))) add(TabPage(null, getString(R.string.contacts_tab_all)))
cats.forEach { add(TabPage(it.id, it.categoryName)) } cats.forEach { add(TabPage(it.id, it.categoryName)) }
@@ -150,31 +170,29 @@ class ContactsFragment : Fragment() {
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false) binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
} }
private fun openTransfer(contact: MibBeneficiary) { private fun openTransfer(contact: ContactDisplay) {
val fragment = TransferFragment.newInstance( val fragment = TransferFragment.newInstance(
accountNumber = contact.benefAccount, accountNumber = contact.accountNumber,
displayName = contact.benefNickName, displayName = contact.name,
subtitle = "${contact.benefBankName} · ${contact.benefAccount}", subtitle = contact.transferSubtitle,
colorHex = contact.bankColor, colorHex = contact.bankColor,
imageHash = contact.customerImgHash imageHash = contact.imageHash
) )
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment) (requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
} }
private fun confirmDelete(contact: MibBeneficiary) { private fun confirmDelete(contact: ContactDisplay) {
AlertDialog.Builder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.contact_delete_title) .setTitle(R.string.contact_delete_title)
.setMessage(getString(R.string.contact_delete_message, contact.benefNickName)) .setMessage(getString(R.string.contact_delete_message, contact.name))
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) } .setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
.setNegativeButton(android.R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.show() .show()
} }
private fun deleteContact(contact: MibBeneficiary) { private fun deleteContact(contact: ContactDisplay) {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val success = withContext(Dispatchers.IO) { val success = withContext(Dispatchers.IO) { ContactManager.delete(contact, app) }
if (contact.benefCategoryId == "BML") deleteBml(contact) else deleteMib(contact)
}
if (success) { if (success) {
Toast.makeText(requireContext(), R.string.contact_deleted, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.contact_deleted, Toast.LENGTH_SHORT).show()
removeFromViewModel(contact) removeFromViewModel(contact)
@@ -184,27 +202,10 @@ class ContactsFragment : Fragment() {
} }
} }
private fun deleteBml(contact: MibBeneficiary): Boolean { private fun removeFromViewModel(contact: ContactDisplay) {
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false val updated = viewModel.contacts.value?.filter { it.benefNo != contact.id } ?: return
val contactId = contact.benefNo.removePrefix("bml_")
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
}
private fun deleteMib(contact: MibBeneficiary): Boolean {
val sess = session ?: return false
return try {
if (contact.profileId.isNotBlank()) {
val profile = app.mibProfiles.firstOrNull { it.profileId == contact.profileId }
if (profile != null) app.mibLoginFlow.switchProfile(sess, profile)
}
MibContactsClient().deleteContact(sess, contact.benefNo)
} catch (_: Exception) { false }
}
private fun removeFromViewModel(contact: MibBeneficiary) {
val updated = viewModel.contacts.value?.filter { it.benefNo != contact.benefNo } ?: return
viewModel.contacts.value = updated viewModel.contacts.value = updated
if (contact.benefCategoryId == "BML") { if (contact.network == TransferNetwork.BML) {
updated.filter { it.benefCategoryId == "BML" } updated.filter { it.benefCategoryId == "BML" }
.groupBy { it.profileId } .groupBy { it.profileId }
.forEach { (loginId, contacts) -> .forEach { (loginId, contacts) ->
@@ -221,7 +222,6 @@ class ContactsFragment : Fragment() {
private fun fetchImage(hash: String) { private fun fetchImage(hash: String) {
if (!pendingHashes.add(hash)) return if (!pendingHashes.add(hash)) return
// Check disk cache first — if hash matches we already have the image
val cached = ContactImageCache.load(requireContext(), hash) val cached = ContactImageCache.load(requireContext(), hash)
if (cached != null) { if (cached != null) {
view?.post { pagerAdapter.updateImage(hash, cached) } view?.post { pagerAdapter.updateImage(hash, cached) }
@@ -1,16 +1,31 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView
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.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 androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.LinearSnapHelper
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlForeignLimit import sh.sar.basedbank.api.bml.BmlForeignLimit
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.util.bmlapi.BmlCardParser
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
@@ -20,6 +35,22 @@ class DashboardFragment : Fragment() {
private val binding get() = _binding!! private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels() private val viewModel: HomeViewModel by activityViewModels()
private var pendingQrAccountNumber: String? = null
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
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 = FragmentDashboardBinding.inflate(inflater, container, false) _binding = FragmentDashboardBinding.inflate(inflater, container, false)
return binding.root return binding.root
@@ -27,64 +58,265 @@ class DashboardFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) } viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) } viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances() }
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) { updatePendingFinances() }
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) } viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
viewModel.hideAmounts.observe(viewLifecycleOwner) {
updateBalances(viewModel.accounts.value ?: emptyList())
updatePendingFinances()
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
}
binding.btnTransfer.setOnClickListener { binding.swipeRefresh.setOnRefreshListener {
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer) (activity as? HomeActivity)?.triggerRefresh()
binding.swipeRefresh.isRefreshing = false
} }
binding.btnPayMvQr.setOnClickListener {
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show() binding.cardPendingFinances.setOnClickListener {
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
} }
val cardAdapter = DashboardCardAdapter()
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
binding.rvCards.adapter = cardAdapter
LinearSnapHelper().attachToRecyclerView(binding.rvCards)
val updateCardList = {
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
val bmlItems = (viewModel.accounts.value ?: emptyList())
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) }
.map { CardItem.Bml(it) }
val all = mibItems + bmlItems
cardAdapter.update(all)
binding.sectionCards.visibility = if (all.isNotEmpty()) View.VISIBLE else View.GONE
}
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
}
} }
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
requireActivity().title = getString(R.string.nav_dashboard) requireActivity().title = getString(R.string.nav_dashboard)
refreshQuickActions()
} }
private fun updateBalances(accounts: List<MibAccount>) { private fun refreshQuickActions() {
val mvrTotal = accounts val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val ids = NavCustomization.getQuickActions(prefs)
listOf(binding.btnQuickAction1, binding.btnQuickAction2).forEachIndexed { i, btn ->
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == ids[i] }
if (def != null) {
btn.setText(def.titleRes)
btn.icon = ContextCompat.getDrawable(requireContext(), def.iconRes)
}
btn.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(ids[i]) }
}
}
private fun updateBalances(accounts: List<BankAccount>) {
val hide = viewModel.hideAmounts.value ?: false
val nonCreditAccounts = accounts.filter { it.profileType != "BML_CREDIT" && it.profileType != "BML_LOAN" }
val creditAccounts = accounts.filter { it.profileType == "BML_CREDIT" }
if (hide) {
binding.tvMvrBalance.text = "MVR ••••••"
binding.tvUsdBalance.text = "USD ••••••"
if (creditAccounts.isNotEmpty()) {
binding.rowCreditCards.visibility = View.VISIBLE
val hasMvrCredit = creditAccounts.any { it.currencyName.equals("MVR", ignoreCase = true) }
val hasUsdCredit = creditAccounts.any { it.currencyName.equals("USD", ignoreCase = true) }
binding.cardMvrCredit.visibility = if (hasMvrCredit) View.VISIBLE else View.GONE
binding.cardUsdCredit.visibility = if (hasUsdCredit) View.VISIBLE else View.GONE
binding.tvMvrCredit.text = "MVR ••••••"
binding.tvUsdCredit.text = "USD ••••••"
} else {
binding.rowCreditCards.visibility = View.GONE
}
return
}
val mvrTotal = nonCreditAccounts
.filter { it.currencyName.equals("MVR", ignoreCase = true) } .filter { it.currencyName.equals("MVR", ignoreCase = true) }
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 } .sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
val usdTotal = accounts val usdTotal = nonCreditAccounts
.filter { it.currencyName.equals("USD", ignoreCase = true) } .filter { it.currencyName.equals("USD", ignoreCase = true) }
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 } .sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
binding.tvMvrBalance.text = "MVR %,.2f".format(mvrTotal) binding.tvMvrBalance.text = "MVR %,.2f".format(mvrTotal)
binding.tvUsdBalance.text = "USD %,.2f".format(usdTotal) binding.tvUsdBalance.text = "USD %,.2f".format(usdTotal)
val mvrCredit = creditAccounts
.filter { it.currencyName.equals("MVR", ignoreCase = true) }
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
val usdCredit = creditAccounts
.filter { it.currencyName.equals("USD", ignoreCase = true) }
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
if (creditAccounts.isNotEmpty()) {
binding.rowCreditCards.visibility = View.VISIBLE
binding.cardMvrCredit.visibility = if (mvrCredit > 0) View.VISIBLE else View.GONE
binding.cardUsdCredit.visibility = if (usdCredit > 0) View.VISIBLE else View.GONE
binding.tvMvrCredit.text = "MVR %,.2f".format(mvrCredit)
binding.tvUsdCredit.text = "USD %,.2f".format(usdCredit)
} else {
binding.rowCreditCards.visibility = View.GONE
}
} }
private val expandedLimits = mutableSetOf<Int>()
private fun updateForeignLimits(entries: List<HomeViewModel.BmlLimitsData>) { private fun updateForeignLimits(entries: List<HomeViewModel.BmlLimitsData>) {
val hide = viewModel.hideAmounts.value ?: false
binding.containerForeignLimits.removeAllViews() binding.containerForeignLimits.removeAllViews()
var cardIndex = 0
for (entry in entries) { for (entry in entries) {
for (limit in entry.limits) { for (limit in entry.limits) {
val idx = cardIndex++
val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false) val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false)
card.tvLimitUserName.text = entry.userName.ifBlank { "BML" } bindLimitCard(card, entry.userName, limit, hide, idx in expandedLimits)
card.tvLimitType.text = limit.type card.root.setOnClickListener {
card.tvLimitGeneral.text = "USD %,.0f / %,.0f".format(limit.generalRemaining, limit.generalCap) if (idx in expandedLimits) expandedLimits.remove(idx) else expandedLimits.add(idx)
card.tvLimitMedical.text = "USD %,.0f".format(limit.medicalRemaining) updateForeignLimits(entries)
card.tvLimitAtm.text = if (!limit.isAtmEnabled) }
"USD %,.0f / %,.0f · Disabled".format(limit.atmRemaining, limit.atmLimit)
else
"USD %,.0f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
card.tvLimitEcom.text = "USD %,.0f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
card.tvLimitPos.text = if (!limit.isPosEnabled)
"USD %,.0f / %,.0f · Disabled".format(limit.posRemaining, limit.posLimit)
else
"USD %,.0f / %,.0f".format(limit.posRemaining, limit.posLimit)
binding.containerForeignLimits.addView(card.root) binding.containerForeignLimits.addView(card.root)
} }
} }
} }
private fun updatePendingFinances(deals: List<MibFinanceDeal>) { private fun bindLimitCard(
val total = deals.sumOf { it.outstandingAmount } card: ItemForeignLimitBinding,
binding.tvPendingFinances.text = "MVR %,.2f".format(total) userName: String,
limit: BmlForeignLimit,
hide: Boolean,
expanded: Boolean
) {
card.tvLimitUserName.text = userName.ifBlank { "BML" }
card.tvLimitType.text = limit.type
// ECOM (always visible)
card.tvLimitEcom.text = if (hide) "USD ••••••"
else "USD %,.2f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
card.progressEcom.progress = if (hide || limit.ecomLimit <= 0) 0
else ((limit.ecomRemaining / limit.ecomLimit) * 100).toInt().coerceIn(0, 100)
// General (always visible)
card.tvLimitGeneral.text = if (hide) "USD ••••••"
else "USD %,.2f / %,.0f".format(limit.generalRemaining, limit.generalCap)
card.progressGeneral.progress = if (hide || limit.generalCap <= 0) 0
else ((limit.generalRemaining / limit.generalCap) * 100).toInt().coerceIn(0, 100)
// Expanded section
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
card.dividerLimitDetails.visibility = detailsVisible
card.detailsGroup.visibility = detailsVisible
if (expanded) {
// ATM
if (!limit.isAtmEnabled) card.tvAtmLabel.append(" (Disabled)")
card.tvLimitAtm.text = if (hide) "USD ••••••"
else "USD %,.2f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
card.progressAtm.progress = if (hide || limit.atmLimit <= 0) 0
else ((limit.atmRemaining / limit.atmLimit) * 100).toInt().coerceIn(0, 100)
// POS
if (!limit.isPosEnabled) card.tvPosLabel.append(" (Disabled)")
card.tvLimitPos.text = if (hide) "USD ••••••"
else "USD %,.2f / %,.0f".format(limit.posRemaining, limit.posLimit)
card.progressPos.progress = if (hide || limit.posLimit <= 0) 0
else ((limit.posRemaining / limit.posLimit) * 100).toInt().coerceIn(0, 100)
// Medical
card.tvLimitMedical.text = if (hide) "USD ••••••"
else "USD %,.2f / %,.0f".format(limit.medicalRemaining, limit.totalLimit)
card.progressMedical.progress = if (hide || limit.totalLimit <= 0) 0
else ((limit.medicalRemaining / limit.totalLimit) * 100).toInt().coerceIn(0, 100)
}
}
private fun updatePendingFinances() {
val hide = viewModel.hideAmounts.value ?: false
val mibTotal = (viewModel.financing.value ?: emptyList()).sumOf { it.outstandingAmount }
val bmlLoanDetails = viewModel.bmlLoanDetails.value ?: emptyMap()
val bmlTotal = bmlLoanDetails.values.sumOf { abs(it.outstandingAmt) }
val total = mibTotal + bmlTotal
binding.tvPendingFinances.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(total)
} }
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null
} }
private inner class DashboardCardAdapter : RecyclerView.Adapter<DashboardCardAdapter.VH>() {
private var cards: List<CardItem> = emptyList()
fun update(newCards: List<CardItem>) {
cards = newCards
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.item_card_dashboard, parent, false)
return VH(view)
}
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position])
override fun getItemCount() = cards.size
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
private val tvCardStatus: TextView = view.findViewById(R.id.tvCardStatus)
private val btnPayQr: View = view.findViewById(R.id.btnPayQr)
private val btnPayNfc: View = view.findViewById(R.id.btnPayNfc)
fun bind(item: CardItem) {
when (item) {
is CardItem.Mib -> {
tvCardOwner.text = item.card.cardHolderName
tvCardNumber.text = PayWithCardFragment.formatMasked(item.card.maskedCardNumber)
val assetPath = PayWithCardFragment.cardImageAsset(item.card)
if (assetPath != null) PayWithCardFragment.loadCardImage(ivCardImage, assetPath)
else ivCardImage.setImageDrawable(null)
PayWithCardFragment.bindCardStatus(tvCardStatus, PayWithCardFragment.mibCardStatusLabel(item.card.cardStatus))
}
is CardItem.Bml -> {
tvCardOwner.text = item.account.accountBriefName
tvCardNumber.text = PayWithCardFragment.formatMasked(item.account.accountNumber)
PayWithCardFragment.loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
PayWithCardFragment.bindCardStatus(tvCardStatus, null) // only Active BML cards reach dashboard
}
}
val isMib = item is CardItem.Mib
btnPayQr.setOnClickListener {
if (isMib) {
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
} else {
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
}
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(requireContext())
val nfcSupported = nfcAdapter != null
btnPayNfc.isEnabled = nfcSupported
btnPayNfc.setOnClickListener {
val msg = if (isMib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
}
}
}
}
} }
@@ -5,92 +5,139 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.api.mib.MibFinancingClient import sh.sar.basedbank.api.mib.MibFinancingClient
import sh.sar.basedbank.databinding.ItemBmlLoanBinding
import sh.sar.basedbank.databinding.ItemFinanceDealBinding import sh.sar.basedbank.databinding.ItemFinanceDealBinding
import java.text.NumberFormat import java.text.NumberFormat
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
import kotlin.math.abs
import kotlin.math.ceil
class FinancingAdapter(private var deals: List<MibFinanceDeal>) : class FinancingAdapter(mibDeals: List<MibFinanceDeal>) :
RecyclerView.Adapter<FinancingAdapter.ViewHolder>() { RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private sealed class Item {
data class Mib(val deal: MibFinanceDeal) : Item()
data class Bml(val account: BankAccount, val detail: BmlLoanDetail?) : Item()
}
private var items: List<Item> = mibDeals.map { Item.Mib(it) }
private var hideAmounts: Boolean = false
private val expandedPositions = mutableSetOf<Int>() private val expandedPositions = mutableSetOf<Int>()
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply { private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
minimumFractionDigits = 2 minimumFractionDigits = 2
maximumFractionDigits = 2 maximumFractionDigits = 2
} }
private val inputDateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US) private val mibDateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
private val isoDateFmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US)
private val outputDateFmt = SimpleDateFormat("d MMM yyyy", Locale.US) private val outputDateFmt = SimpleDateFormat("d MMM yyyy", Locale.US)
fun updateDeals(newDeals: List<MibFinanceDeal>) { fun setHideAmounts(hide: Boolean) {
deals = newDeals if (hideAmounts == hide) return
expandedPositions.clear() hideAmounts = hide
notifyDataSetChanged() notifyDataSetChanged()
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>) {
val binding = ItemFinanceDealBinding.inflate(LayoutInflater.from(parent.context), parent, false) expandedPositions.clear()
return ViewHolder(binding) items = mibDeals.map { Item.Mib(it) } + bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
notifyDataSetChanged()
} }
override fun onBindViewHolder(holder: ViewHolder, position: Int) { // Legacy compatibility — used on initial empty construction
holder.bind(deals[position], position in expandedPositions) fun updateDeals(newDeals: List<MibFinanceDeal>) {
holder.binding.root.setOnClickListener { expandedPositions.clear()
val bmlItems = items.filterIsInstance<Item.Bml>()
items = newDeals.map { Item.Mib(it) } + bmlItems
notifyDataSetChanged()
}
fun updateBmlLoans(loans: List<Pair<BankAccount, BmlLoanDetail?>>) {
expandedPositions.clear()
val mibItems = items.filterIsInstance<Item.Mib>()
items = mibItems + loans.map { (acc, detail) -> Item.Bml(acc, detail) }
notifyDataSetChanged()
}
override fun getItemViewType(position: Int) = when (items[position]) {
is Item.Mib -> TYPE_MIB
is Item.Bml -> TYPE_BML
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val inflater = LayoutInflater.from(parent.context)
return when (viewType) {
TYPE_BML -> BmlViewHolder(ItemBmlLoanBinding.inflate(inflater, parent, false))
else -> MibViewHolder(ItemFinanceDealBinding.inflate(inflater, parent, false))
}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val expanded = position in expandedPositions
when (val item = items[position]) {
is Item.Mib -> (holder as MibViewHolder).bind(item.deal, expanded)
is Item.Bml -> (holder as BmlViewHolder).bind(item.account, item.detail, expanded)
}
holder.itemView.setOnClickListener {
val pos = holder.bindingAdapterPosition val pos = holder.bindingAdapterPosition
if (pos in expandedPositions) expandedPositions.remove(pos) else expandedPositions.add(pos) if (pos in expandedPositions) expandedPositions.remove(pos) else expandedPositions.add(pos)
notifyItemChanged(pos) notifyItemChanged(pos)
} }
} }
override fun getItemCount() = deals.size override fun getItemCount() = items.size
inner class ViewHolder(val binding: ItemFinanceDealBinding) : // ── MIB ViewHolder ────────────────────────────────────────────────────────
inner class MibViewHolder(val binding: ItemFinanceDealBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
fun bind(deal: MibFinanceDeal, expanded: Boolean) { fun bind(deal: MibFinanceDeal, expanded: Boolean) {
val ctx = binding.root.context val ctx = binding.root.context
val currency = deal.currency val currency = deal.currency
val hide = hideAmounts
binding.tvProductName.text = deal.productDesc binding.tvProductName.text = deal.productDesc
binding.tvDealNo.text = ctx.getString(R.string.financing_deal_no_fmt, deal.dealNo) binding.tvDealNo.text = ctx.getString(R.string.financing_deal_no_fmt, deal.dealNo)
binding.tvStatus.text = deal.statusDesc binding.tvStatus.text = deal.statusDesc
binding.tvTotal.text = "$currency ${amountFmt.format(deal.dealAmount)}" binding.tvTotal.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.dealAmount)}"
binding.tvPaid.text = "$currency ${amountFmt.format(deal.paidAmount)}" binding.tvPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.paidAmount)}"
binding.tvUnpaid.text = "$currency ${amountFmt.format(deal.outstandingAmount)}" binding.tvUnpaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.outstandingAmount)}"
// Progress bar
val progress = if (deal.dealAmount > 0) val progress = if (deal.dealAmount > 0)
((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100) ((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100)
else 0 else 0
binding.progressBar.progress = progress binding.progressBar.progress = if (hide) 0 else progress
// Completion estimate binding.tvCompletion.text = mibCompletionText(deal, ctx)
binding.tvCompletion.text = completionText(deal, ctx)
// Expanded details
val detailsVisible = if (expanded) View.VISIBLE else View.GONE val detailsVisible = if (expanded) View.VISIBLE else View.GONE
binding.dividerDetails.visibility = detailsVisible binding.dividerDetails.visibility = detailsVisible
binding.detailsGroup.visibility = detailsVisible binding.detailsGroup.visibility = detailsVisible
if (expanded) { if (expanded) {
binding.tvDealDate.text = formatDate(deal.dealDate) binding.tvDealDate.text = formatMibDate(deal.dealDate)
binding.tvInstallment.text = "$currency ${amountFmt.format(deal.installmentAmount)}" binding.tvInstallment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.installmentAmount)}"
binding.tvNumInstallments.text = deal.noOfInstallments.toString() binding.tvNumInstallments.text = deal.noOfInstallments.toString()
binding.tvLastPaidDate.text = formatDate(deal.lastPaidDate) binding.tvLastPaidDate.text = formatMibDate(deal.lastPaidDate)
binding.tvLastPayAmount.text = "$currency ${amountFmt.format(deal.lastPayAmount)}" binding.tvLastPayAmount.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.lastPayAmount)}"
if (deal.overdueAmount > 0) { if (deal.overdueAmount > 0) {
binding.rowOverdue.visibility = View.VISIBLE binding.rowOverdue.visibility = View.VISIBLE
binding.tvOverdue.text = "$currency ${amountFmt.format(deal.overdueAmount)}" binding.tvOverdue.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.overdueAmount)}"
} else { } else {
binding.rowOverdue.visibility = View.GONE binding.rowOverdue.visibility = View.GONE
} }
} }
} }
private fun completionText(deal: MibFinanceDeal, ctx: android.content.Context): String { private fun mibCompletionText(deal: MibFinanceDeal, ctx: android.content.Context): String {
if (deal.outstandingAmount <= 0.0) return ctx.getString(R.string.financing_completion_done) if (deal.outstandingAmount <= 0.0) return ctx.getString(R.string.financing_completion_done)
val remaining = MibFinancingClient.remainingMonths(deal) val remaining = MibFinancingClient.remainingMonths(deal)
if (remaining <= 0) return ctx.getString(R.string.financing_completion_done) if (remaining <= 0) return ctx.getString(R.string.financing_completion_done)
@@ -100,12 +147,84 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
return ctx.getString(R.string.financing_completion_fmt, month) return ctx.getString(R.string.financing_completion_fmt, month)
} }
private fun formatDate(raw: String): String { private fun formatMibDate(raw: String): String {
return try { return try {
outputDateFmt.format(inputDateFmt.parse(raw)!!) outputDateFmt.format(mibDateFmt.parse(raw)!!)
} catch (_: Exception) { } catch (_: Exception) { raw.take(10) }
raw.take(10)
}
} }
} }
// ── BML ViewHolder ────────────────────────────────────────────────────────
inner class BmlViewHolder(val binding: ItemBmlLoanBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(account: BankAccount, detail: BmlLoanDetail?, expanded: Boolean) {
val ctx = binding.root.context
val currency = account.currencyName
val hide = hideAmounts
binding.tvLoanProduct.text = account.accountTypeName
.trim().lowercase().split(" ")
.joinToString(" ") { it.replaceFirstChar { c -> c.uppercaseChar() } }
binding.tvLoanAccount.text = account.accountNumber
binding.tvLoanStatus.text = detail?.loanStatus?.ifBlank { account.statusDesc } ?: account.statusDesc
val loanAmt = detail?.loanAmount ?: 0.0
val outstanding = if (detail != null) abs(detail.outstandingAmt) else account.availableBalance.toDoubleOrNull() ?: 0.0
val paid = (loanAmt - outstanding).coerceAtLeast(0.0)
binding.tvLoanTotal.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(loanAmt)}"
binding.tvLoanPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(paid)}"
binding.tvLoanOutstanding.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(outstanding)}"
val progress = if (loanAmt > 0) ((paid / loanAmt) * 100).toInt().coerceIn(0, 100) else 0
binding.loanProgressBar.progress = if (hide) 0 else progress
binding.tvLoanCompletion.text = bmlCompletionText(detail, ctx)
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
binding.loanDividerDetails.visibility = detailsVisible
binding.loanDetailsGroup.visibility = detailsVisible
if (expanded && detail != null) {
binding.tvLoanRepayment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(detail.repayAmount)}"
binding.tvLoanIntRate.text = ctx.getString(R.string.loan_rate_fmt, detail.intRate)
binding.tvLoanStartDate.text = formatIsoDate(detail.startDate)
binding.tvLoanEndDate.text = formatIsoDate(detail.endDate)
if (detail.overdueAmount > 0) {
binding.loanRowOverdue.visibility = View.VISIBLE
binding.tvLoanOverdue.text = if (hide) "$currency ••••••"
else "$currency ${amountFmt.format(detail.overdueAmount)} (${detail.noOfRepayOverdue})"
} else {
binding.loanRowOverdue.visibility = View.GONE
}
}
}
private fun bmlCompletionText(detail: BmlLoanDetail?, ctx: android.content.Context): String {
if (detail == null) return ""
val outstanding = abs(detail.outstandingAmt)
if (outstanding <= 0.0 || detail.repayAmount <= 0.0)
return ctx.getString(R.string.financing_completion_done)
val remaining = ceil(outstanding / detail.repayAmount).toInt()
if (remaining <= 0) return ctx.getString(R.string.financing_completion_done)
val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, remaining)
val month = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(cal.time)
return ctx.getString(R.string.financing_completion_fmt, month)
}
private fun formatIsoDate(raw: String): String {
return try {
outputDateFmt.format(isoDateFmt.parse(raw)!!)
} catch (_: Exception) { raw.take(10) }
}
}
companion object {
private const val TYPE_MIB = 0
private const val TYPE_BML = 1
}
} }
@@ -4,10 +4,15 @@ import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import 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 androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentFinancingBinding import sh.sar.basedbank.databinding.FragmentFinancingBinding
class FinancingFragment : Fragment() { class FinancingFragment : Fragment() {
@@ -17,6 +22,9 @@ class FinancingFragment : Fragment() {
private val viewModel: HomeViewModel by activityViewModels() private val viewModel: HomeViewModel by activityViewModels()
private lateinit var adapter: FinancingAdapter private lateinit var adapter: FinancingAdapter
private var latestMibDeals: List<MibFinanceDeal> = emptyList()
private var latestBmlLoanDetails: Map<String, BmlLoanDetail> = emptyMap()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentFinancingBinding.inflate(inflater, container, false) _binding = FragmentFinancingBinding.inflate(inflater, container, false)
return binding.root return binding.root
@@ -27,12 +35,44 @@ class FinancingFragment : Fragment() {
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
viewModel.financing.observe(viewLifecycleOwner) { deals -> val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
adapter.updateDeals(deals) ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
binding.recyclerView.visibility = if (deals.isEmpty()) View.GONE else View.VISIBLE val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
binding.emptyView.visibility = if (deals.isEmpty()) View.VISIBLE else View.GONE val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
binding.loadingView.visibility = View.GONE val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
} }
binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefresh()
binding.swipeRefresh.isRefreshing = false
}
viewModel.accounts.observe(viewLifecycleOwner) { rebuildAdapter() }
viewModel.financing.observe(viewLifecycleOwner) { deals ->
latestMibDeals = deals
rebuildAdapter()
}
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) { details ->
latestBmlLoanDetails = details
rebuildAdapter()
}
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
}
private fun rebuildAdapter() {
val accounts = viewModel.accounts.value ?: emptyList()
val loanAccounts = accounts.filter { it.profileType == "BML_LOAN" }
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
adapter.update(latestMibDeals, bmlLoans)
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
binding.emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE
} }
override fun onResume() { override fun onResume() {
File diff suppressed because it is too large Load Diff
@@ -3,17 +3,37 @@ package sh.sar.basedbank.ui.home
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import sh.sar.basedbank.api.bml.BmlForeignLimit import sh.sar.basedbank.api.bml.BmlForeignLimit
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.mib.MibBeneficiary import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.api.mib.MibFinanceDeal
sealed class CardItem {
data class Mib(val card: MibCard) : CardItem()
data class Bml(val account: BankAccount) : CardItem()
}
class HomeViewModel : ViewModel() { class HomeViewModel : ViewModel() {
val accounts = MutableLiveData<List<MibAccount>>(emptyList()) val accounts = MutableLiveData<List<BankAccount>>(emptyList())
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList()) val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
val contacts = MutableLiveData<List<MibBeneficiary>>(emptyList()) /** BML loan details keyed by account internalId. */
val contactCategories = MutableLiveData<List<MibBeneficiaryCategory>>(emptyList()) val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
val contacts = MutableLiveData<List<BankContact>>(emptyList())
val contactCategories = MutableLiveData<List<BankContactCategory>>(emptyList())
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>) data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList()) val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
val mibCards = MutableLiveData<List<MibCard>?>(null)
val hideAmounts = MutableLiveData<Boolean>(false)
/**
* Set of connectivity error keys from the last refresh.
* Contains "NO_INTERNET" for no network, or uppercase bank names ("MIB", "BML", "FAHIPAY")
* for HTTP 5xx server errors from specific banks.
*/
val connectivityErrors = MutableLiveData<Set<String>>(emptySet())
} }
@@ -1,5 +1,6 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@@ -7,35 +8,24 @@ import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import sh.sar.basedbank.R import sh.sar.basedbank.R
class MoreFragment : Fragment() { class MoreFragment : Fragment() {
private data class NavItem(val id: Int, @DrawableRes val icon: Int, @StringRes val title: Int)
private val items = listOf(
NavItem(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr),
NavItem(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities),
NavItem(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history),
NavItem(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances),
NavItem(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings),
NavItem(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp),
NavItem(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings),
)
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_more, container, false) inflater.inflate(R.layout.fragment_more, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val items = NavCustomization.getMoreItems(prefs)
val list = view.findViewById<LinearLayout>(R.id.moreList) val list = view.findViewById<LinearLayout>(R.id.moreList)
val inflater = LayoutInflater.from(requireContext()) val inflater = LayoutInflater.from(requireContext())
for (item in items) { for (item in items) {
val row = inflater.inflate(R.layout.item_more_nav, list, false) val row = inflater.inflate(R.layout.item_more_nav, list, false)
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.icon) row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
row.findViewById<TextView>(R.id.tvLabel).setText(item.title) row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
row.findViewById<TextView>(R.id.tvDescription).setText(item.descriptionRes)
row.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(item.id) } row.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(item.id) }
list.addView(row) list.addView(row)
} }
@@ -0,0 +1,70 @@
package sh.sar.basedbank.ui.home
import android.content.SharedPreferences
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import sh.sar.basedbank.R
object NavCustomization {
data class NavItemDef(
val id: Int,
val key: String,
@DrawableRes val iconRes: Int,
@StringRes val titleRes: Int,
@StringRes val descriptionRes: Int
)
/** All items that can occupy either a bottom nav slot or the "More" screen. */
val ALL_SWAPPABLE = listOf(
NavItemDef(R.id.nav_accounts, "nav_accounts", R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts),
NavItemDef(R.id.nav_contacts, "nav_contacts", R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts),
NavItemDef(R.id.nav_transfer, "nav_transfer", R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer),
NavItemDef(R.id.nav_pay_mv_qr, "nav_pay_mv_qr", R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr),
NavItemDef(R.id.nav_activities, "nav_activities", R.drawable.ic_nav_activities, R.string.nav_activities, R.string.nav_desc_activities),
NavItemDef(R.id.nav_transfer_history, "nav_transfer_history", R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history, R.string.nav_desc_transfer_history),
NavItemDef(R.id.nav_finances, "nav_finances", R.drawable.ic_nav_finances, R.string.nav_finances, R.string.nav_desc_finances),
NavItemDef(R.id.nav_pay_with_card, "nav_pay_with_card", R.drawable.ic_nav_card, R.string.nav_pay_with_card, R.string.nav_desc_pay_with_card),
NavItemDef(R.id.nav_card_settings, "nav_card_settings", R.drawable.ic_nav_card, R.string.nav_card_settings, R.string.nav_desc_card_settings),
NavItemDef(R.id.nav_otp, "nav_otp", R.drawable.ic_nav_otp, R.string.nav_otp, R.string.nav_desc_otp),
NavItemDef(R.id.nav_settings, "nav_settings", R.drawable.ic_nav_settings, R.string.nav_settings, R.string.nav_desc_settings),
)
private fun keyToId(key: String?, default: Int) =
ALL_SWAPPABLE.find { it.key == key }?.id ?: default
private fun idToKey(id: Int) =
ALL_SWAPPABLE.find { it.id == id }?.key
fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
keyToId(prefs.getString("bottom_nav_slot_1_key", null), R.id.nav_accounts),
keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_contacts),
keyToId(prefs.getString("bottom_nav_slot_3_key", null), R.id.nav_transfer),
)
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
prefs.edit()
.putString("bottom_nav_slot_1_key", idToKey(slots[0]) ?: "nav_accounts")
.putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_contacts")
.putString("bottom_nav_slot_3_key", idToKey(slots[2]) ?: "nav_transfer")
.apply()
}
fun getQuickActions(prefs: SharedPreferences): List<Int> = listOf(
keyToId(prefs.getString("quick_action_1_key", null), R.id.nav_transfer),
keyToId(prefs.getString("quick_action_2_key", null), R.id.nav_pay_mv_qr),
)
fun saveQuickActions(prefs: SharedPreferences, ids: List<Int>) {
prefs.edit()
.putString("quick_action_1_key", idToKey(ids[0]) ?: "nav_transfer")
.putString("quick_action_2_key", idToKey(ids[1]) ?: "nav_pay_mv_qr")
.apply()
}
/** Items that belong in the "More" screen — those not occupying a bottom nav slot. */
fun getMoreItems(prefs: SharedPreferences): List<NavItemDef> {
val slots = getSlots(prefs).toSet()
return ALL_SWAPPABLE.filter { it.id !in slots }
}
}
@@ -1,7 +1,9 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.content.Context
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.Menu
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment
@@ -16,7 +18,14 @@ class NavMoreSheetFragment : BottomSheetDialogFragment() {
inflater.inflate(R.layout.sheet_nav_more, container, false) inflater.inflate(R.layout.sheet_nav_more, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
view.findViewById<NavigationView>(R.id.navMoreView).setNavigationItemSelectedListener { item -> val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val items = NavCustomization.getMoreItems(prefs)
val navView = view.findViewById<NavigationView>(R.id.navMoreView)
navView.menu.clear()
items.forEachIndexed { i, item ->
navView.menu.add(Menu.NONE, item.id, i, item.titleRes).setIcon(item.iconRes)
}
navView.setNavigationItemSelectedListener { item ->
dismiss() dismiss()
onNavigate?.invoke(item.itemId) onNavigate?.invoke(item.itemId)
true true
@@ -14,7 +14,8 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.mib.MibProfileClient
import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.databinding.FragmentOtpBinding import sh.sar.basedbank.databinding.FragmentOtpBinding
import sh.sar.basedbank.databinding.ItemOtpCardBinding import sh.sar.basedbank.databinding.ItemOtpCardBinding
@@ -71,8 +72,9 @@ class OtpFragment : Fragment() {
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val entries = mutableListOf<OtpEntry>() val entries = mutableListOf<OtpEntry>()
store.loadMibCredentials()?.let { creds -> for (loginId in store.getMibLoginIds()) {
val name = store.loadMibFullName() val creds = store.loadMibCredentials(loginId) ?: continue
val name = store.loadMibFullName(loginId)
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed)) entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
} }
for (loginId in store.getBmlLoginIds()) { for (loginId in store.getBmlLoginIds()) {
@@ -88,20 +90,23 @@ class OtpFragment : Fragment() {
// Fetch real names in background if not yet cached, then refresh labels // Fetch real names in background if not yet cached, then refresh labels
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
var changed = false var changed = false
if (store.loadMibFullName() == null) { for (loginId in store.getMibLoginIds()) {
app.mibSession?.let { session -> if (store.loadMibFullName(loginId) == null) {
val session = app.mibSessions[loginId] ?: continue
val flow = app.mibFlowFor(loginId)
val profile = withContext(Dispatchers.IO) { val profile = withContext(Dispatchers.IO) {
try { app.mibLoginFlow.fetchPersonalProfile(session) } catch (_: Exception) { null } try { MibProfileClient().fetchPersonalProfile(session) } catch (_: Exception) { null }
} }
if (profile != null) { if (profile != null) {
store.saveMibUserProfile(CredentialStore.MibUserProfile( store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile(
fullName = profile.fullName, fullName = profile.fullName,
username = profile.username, username = profile.username,
email = profile.email, email = profile.email,
mobile = profile.mobile, mobile = profile.mobile,
enrolled = profile.enrolled enrolled = profile.enrolled
)) ))
val idx = entries.indexOfFirst { it.seed == store.loadMibCredentials()?.otpSeed } val seed = store.loadMibCredentials(loginId)?.otpSeed
val idx = entries.indexOfFirst { it.seed == seed }
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "MIB · ${profile.fullName}"); changed = true } if (idx >= 0) { entries[idx] = entries[idx].copy(label = "MIB · ${profile.fullName}"); changed = true }
} }
} }
@@ -110,7 +115,7 @@ class OtpFragment : Fragment() {
if (store.loadBmlUserProfile(loginId)?.fullName.isNullOrBlank()) { if (store.loadBmlUserProfile(loginId)?.fullName.isNullOrBlank()) {
val session = app.bmlSessions[loginId] ?: continue val session = app.bmlSessions[loginId] ?: continue
val info = withContext(Dispatchers.IO) { val info = withContext(Dispatchers.IO) {
try { BmlLoginFlow().fetchUserInfo(session) } catch (_: Exception) { null } try { BmlAccountClient().fetchUserInfo(session) } catch (_: Exception) { null }
} }
if (info != null) { if (info != null) {
store.saveBmlUserProfile(loginId, CredentialStore.BmlUserProfile( store.saveBmlUserProfile(loginId, CredentialStore.BmlUserProfile(
@@ -0,0 +1,531 @@
package sh.sar.basedbank.ui.home
import android.content.ContentValues
import android.content.Context
import android.graphics.*
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.MediaStore
import android.app.Activity
import android.content.Intent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.*
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.FileProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
import java.io.File
import java.io.FileOutputStream
class PayMvQrFragment : Fragment() {
private var _binding: FragmentPayMvQrBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private var selectedAccount: BankAccount? = null
private var generatedBitmap: Bitmap? = null
private var generateJob: Job? = null
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
// BML card/gateway QR — hand off to dedicated payment screen
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
raw.startsWith("https://pay.bml.com.mv/app/")) {
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw))
return@registerForActivityResult
}
val qr = PaymvQrParser.parse(raw)
if (qr == null || qr.accountNumber == null) {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
return@registerForActivityResult
}
val activity = requireActivity() as HomeActivity
activity.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromQr(
accountNumber = qr.accountNumber,
displayName = qr.merchantName ?: qr.accountNumber,
amount = qr.amount,
remarks = qr.purpose
))
}
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
): View {
_binding = FragmentPayMvQrBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val basePaddingBottom = view.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(view) { 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.updatePadding(bottom = basePaddingBottom + navBarBottom)
insets
}
setupDropdown()
binding.etAmount.addTextChangedListener { scheduleGenerate() }
binding.btnShare.isEnabled = false
binding.btnSave.isEnabled = false
binding.btnShare.setOnClickListener { shareQr() }
binding.btnSave.setOnClickListener { saveQr() }
binding.btnScanQr.setOnClickListener {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
}
private fun setupDropdown() {
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
val eligible = accounts.filter {
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN"
}
val adapter = QrAccountAdapter(requireContext(), eligible)
binding.actvAccount.setAdapter(adapter)
binding.actvAccount.setOnItemClickListener { _, _, position, _ ->
val picked = adapter.getAccount(position) ?: return@setOnItemClickListener
selectedAccount = picked
scheduleGenerate()
}
}
}
private fun scheduleGenerate() {
generateJob?.cancel()
generateJob = viewLifecycleOwner.lifecycleScope.launch {
delay(300)
generateQr()
}
}
private suspend fun generateQr() {
val account = selectedAccount ?: return
val acquirer = when (account.bank) {
"BML" -> "MALBMVMV"
"MIB" -> "MADVMVMV"
"FAHIPAY" -> "FAHIMVMV"
else -> "MADVMVMV"
}
val amountFormatted = binding.etAmount.text?.toString()?.trim()
?.replace(",", "")
?.toDoubleOrNull()
?.takeIf { it > 0 }
?.let { "%.2f".format(it) }
val ctx = requireContext()
val bmp = withContext(Dispatchers.Default) {
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted)
renderQrCard(ctx, account, payload, amountFormatted)
}
if (_binding == null) return
generatedBitmap = bmp
binding.tvQrPlaceholder.visibility = View.GONE
binding.ivQrCard.setImageBitmap(bmp)
binding.ivQrCard.visibility = View.VISIBLE
binding.btnShare.isEnabled = true
binding.btnSave.isEnabled = true
}
// ── EMV MPQR payload ──────────────────────────────────────────────────────
private fun buildQrPayload(
accountNumber: String,
accountName: String,
acquirer: String,
amountStr: String?
): String {
fun tlv(tag: String, value: String): String {
val len = value.length
return tag + (if (len < 10) "0$len" else "$len") + value
}
val format = tlv("00", "01")
val poi = tlv("01", "11")
val sub00 = tlv("00", "mv.favara.mpqr")
val sub01 = tlv("01", acquirer)
val sub03 = tlv("03", accountNumber)
val sub10 = tlv("10", "IPAY")
val merchantAcct = tlv("26", sub00 + sub01 + sub03 + sub10)
val currency = tlv("53", "462")
val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else ""
val country = tlv("58", "MV")
val name = tlv("59", accountName.take(25))
val prefix = format + poi + merchantAcct + currency + amountTLV + country + name + "6304"
return prefix + crc16(prefix)
}
private fun crc16(data: String): String {
var crc = 0xFFFF
for (c in data) {
crc = crc xor ((c.code and 0xFF) shl 8)
repeat(8) {
crc = if (crc and 0x8000 != 0) ((crc shl 1) and 0xFFFF) xor 0x1021
else (crc shl 1) and 0xFFFF
}
}
return crc.toString(16).uppercase().padStart(4, '0')
}
// ── QR card rendering ────────────────────────────────────────────────────
private fun renderQrCard(
ctx: Context,
account: BankAccount,
qrPayload: String,
amountStr: String?
): Bitmap {
val W = 900
val H = 1080
val outerCorner = 48f
val boxBlue = Color.parseColor("#2272B7")
val footerBlue = Color.parseColor("#1A5799")
val boxL = 24f; val boxT = 110f; val boxR = 876f; val boxB = 962f
val bm = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bm)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
// Clip to outer rounded card shape
val outerPath = Path()
outerPath.addRoundRect(RectF(0f, 0f, W.toFloat(), H.toFloat()), outerCorner, outerCorner, Path.Direction.CW)
canvas.clipPath(outerPath)
canvas.drawColor(Color.WHITE)
// --- Bank logo top-left ---
val logoRes = when (account.bank) {
"BML" -> R.drawable.bml_logo_vector
"MIB" -> R.drawable.mib_faisanet_logo
else -> R.drawable.fahipay_logo_long
}
AppCompatResources.getDrawable(ctx, logoRes)?.let { d ->
val nW = d.intrinsicWidth.coerceAtLeast(1)
val nH = d.intrinsicHeight.coerceAtLeast(1)
val maxW = 180f; val maxH = 76f
val scale = minOf(maxW / nW, maxH / nH)
val lW = (nW * scale).toInt()
val lH = (nH * scale).toInt()
val lTop = ((boxT - lH) / 2).toInt().coerceAtLeast(10)
d.setBounds(24, lTop, 24 + lW, lTop + lH)
d.draw(canvas)
}
// --- "PayMV QR" top-right ---
paint.color = Color.parseColor("#1A1A2E")
paint.textSize = 36f
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
paint.textAlign = Paint.Align.RIGHT
canvas.drawText("PayMV QR", W - 28f, 66f, paint)
// --- Blue rounded box ---
paint.color = boxBlue
paint.textAlign = Paint.Align.LEFT
canvas.drawRoundRect(RectF(boxL, boxT, boxR, boxB), 36f, 36f, paint)
// Account name (white, bold, uppercase, auto-scaled to fit)
paint.color = Color.WHITE
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
paint.textAlign = Paint.Align.CENTER
val nameText = account.accountBriefName.uppercase()
paint.textSize = 36f
val maxNameW = boxR - boxL - 48f
if (paint.measureText(nameText) > maxNameW) {
paint.textSize = 36f * maxNameW / paint.measureText(nameText)
}
val nameBaseline = boxT + 68f
canvas.drawText(nameText, W / 2f, nameBaseline, paint)
// Optional amount below name
val qrTopY: Float
if (!amountStr.isNullOrBlank()) {
paint.textSize = 28f
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
val amtBaseline = nameBaseline + 42f
canvas.drawText("MVR $amountStr", W / 2f, amtBaseline, paint)
qrTopY = amtBaseline + 20f
} else {
qrTopY = nameBaseline + 26f
}
// QR code — white modules on the same blue as the box background
val availH = boxB - qrTopY - 24f
val qrPx = minOf(availH, boxR - boxL - 48f).toInt().coerceAtMost(700).coerceAtLeast(200)
val qrLeft = ((W - qrPx) / 2).toFloat()
try {
val hints = mapOf(
EncodeHintType.MARGIN to 0,
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
)
val matrix = QRCodeWriter().encode(qrPayload, BarcodeFormat.QR_CODE, qrPx, qrPx, hints)
val pixels = IntArray(qrPx * qrPx)
for (y in 0 until qrPx) {
for (x in 0 until qrPx) {
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else boxBlue
}
}
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
canvas.drawBitmap(qrBm, qrLeft, qrTopY, null)
qrBm.recycle()
} catch (_: Exception) { /* skip if encoding fails */ }
// --- Dark blue footer ---
paint.color = footerBlue
paint.textAlign = Paint.Align.LEFT
canvas.drawRect(RectF(0f, 970f, W.toFloat(), H.toFloat()), paint)
paint.color = Color.WHITE
paint.textSize = 32f
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
paint.textAlign = Paint.Align.CENTER
canvas.drawText("MALDIVES NATIONAL QR", W / 2f, 1038f, paint)
return bm
}
// ── Share / Save ─────────────────────────────────────────────────────────
private fun shareQr() {
val bmp = generatedBitmap ?: return
val account = selectedAccount ?: return
lifecycleScope.launch {
val uri = withContext(Dispatchers.IO) {
try {
val dir = File(requireContext().cacheDir, "qr")
dir.mkdirs()
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
val file = File(dir, "${safeName}_paymv_qr.png")
FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
FileProvider.getUriForFile(
requireContext(),
"${requireContext().packageName}.fileprovider",
file
)
} catch (_: Exception) { null }
}
if (uri == null || _binding == null) return@launch
val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
type = "image/png"
putExtra(android.content.Intent.EXTRA_STREAM, uri)
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(android.content.Intent.createChooser(intent, getString(R.string.paymvqr_share)))
}
}
private fun saveQr() {
val bmp = generatedBitmap ?: return
val account = selectedAccount ?: return
lifecycleScope.launch {
val saved = withContext(Dispatchers.IO) {
try {
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
val filename = "${safeName}_PayMV_QR.png"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val values = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
}
val uri = requireContext().contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values
) ?: return@withContext false
requireContext().contentResolver.openOutputStream(uri)?.use {
bmp.compress(Bitmap.CompressFormat.PNG, 100, it)
}
} else {
@Suppress("DEPRECATION")
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
dir.mkdirs()
FileOutputStream(File(dir, filename)).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
}
true
} catch (_: Exception) { false }
}
if (_binding == null) return@launch
Toast.makeText(
requireContext(),
if (saved) R.string.paymvqr_saved else R.string.paymvqr_save_failed,
Toast.LENGTH_SHORT
).show()
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.pay_mv_qr)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
// ── Account dropdown adapter ──────────────────────────────────────────────
private inner class QrAccountAdapter(
private val context: Context,
private val accounts: List<BankAccount>
) : BaseAdapter(), Filterable {
fun getAccount(position: Int): BankAccount? = accounts.getOrNull(position)
override fun getCount() = accounts.size
override fun getItem(position: Int) = accounts.getOrNull(position)
override fun getItemId(position: Int) = position.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup) =
getDropDownView(position, convertView, parent)
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
val acc = accounts[position]
val b = if (convertView?.tag is ItemAccountDropdownBinding) {
convertView.tag as ItemAccountDropdownBinding
} else {
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
.also { it.root.tag = it }
}
val ownerPrefix = if (acc.bank == "BML" && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
val displayData = AccountListParser.from(acc)
val typeLabel = displayData?.typeLabel
?: if (acc.bank == "BML") BmlDashboardParser.productLabel(acc.accountTypeName)
else acc.accountTypeName.trim()
b.tvDropdownAccountNumber.text = acc.accountNumber
if (typeLabel.isNotBlank()) {
b.tvDropdownAccountType.text = typeLabel
b.tvDropdownAccountType.visibility = View.VISIBLE
} else {
b.tvDropdownAccountType.visibility = View.GONE
}
b.tvDropdownBalance.text = displayData?.balance ?: ""
b.root.alpha = 1f
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
when {
networkIcon != null -> {
b.ivDropdownCardLogo.setImageResource(networkIcon)
b.ivDropdownCardLogo.visibility = View.VISIBLE
}
acc.bank == "BML" -> {
val localKey = sh.sar.basedbank.util.ProfileImageStore.bmlKey(acc.profileId)
val cachedLocal = dropdownProfileImageCache[localKey]
val imageView = b.ivDropdownCardLogo
imageView.tag = localKey
if (cachedLocal != null) {
imageView.setImageBitmap(cachedLocal)
} else {
imageView.setImageResource(R.drawable.bml_logo_vector)
if (acc.profileId.isNotBlank()) {
viewLifecycleOwner.lifecycleScope.launch {
val bitmap = withContext(Dispatchers.IO) {
sh.sar.basedbank.util.ProfileImageStore.load(requireContext(), localKey)
}
if (bitmap != null) {
dropdownProfileImageCache[localKey] = bitmap
if (imageView.tag == localKey) imageView.setImageBitmap(bitmap)
}
}
}
}
imageView.visibility = View.VISIBLE
}
acc.bank == "FAHIPAY" -> {
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(acc.loginTag)
val localKey = sh.sar.basedbank.util.ProfileImageStore.fahipayKey(loginId)
val cachedLocal = dropdownProfileImageCache[localKey]
val imageView = b.ivDropdownCardLogo
imageView.tag = localKey
if (cachedLocal != null) {
imageView.setImageBitmap(cachedLocal)
} else {
imageView.setImageResource(R.drawable.fahipay_logo)
if (loginId.isNotBlank()) {
viewLifecycleOwner.lifecycleScope.launch {
val bitmap = withContext(Dispatchers.IO) {
sh.sar.basedbank.util.ProfileImageStore.load(requireContext(), localKey)
}
if (bitmap != null) {
dropdownProfileImageCache[localKey] = bitmap
if (imageView.tag == localKey) imageView.setImageBitmap(bitmap)
}
}
}
}
imageView.visibility = View.VISIBLE
}
acc.bank == "MIB" -> {
val hash = acc.profileImageHash
val cached = hash?.let { dropdownProfileImageCache[it] }
val imageView = b.ivDropdownCardLogo
imageView.tag = hash
if (cached != null) {
imageView.setImageBitmap(cached)
} else {
imageView.setImageResource(R.drawable.mib_logo)
if (hash != null) {
val app = requireActivity().application as BasedBankApp
viewLifecycleOwner.lifecycleScope.launch {
val bitmap = withContext(Dispatchers.IO) {
try {
val sess = app.anyMibSession() ?: return@withContext null
val b64 = app.anyMibFlow()?.fetchProfileImage(sess, hash) ?: return@withContext null
val bytes = android.util.Base64.decode(b64, android.util.Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} catch (_: Exception) { null }
}
if (bitmap != null) {
dropdownProfileImageCache[hash] = bitmap
if (imageView.tag == hash) imageView.setImageBitmap(bitmap)
}
}
}
}
imageView.visibility = View.VISIBLE
}
else -> b.ivDropdownCardLogo.visibility = View.GONE
}
return b.root
}
override fun getFilter() = object : Filter() {
override fun performFiltering(c: CharSequence?) =
FilterResults().apply { values = accounts; count = accounts.size }
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
override fun convertResultToString(r: Any?) =
(r as? BankAccount)?.let {
val prefix = if (it.bank == "BML" && it.profileName.isNotBlank()) "${it.profileName} · " else ""
"$prefix${it.accountBriefName}"
} ?: ""
}
}
}
@@ -0,0 +1,214 @@
package sh.sar.basedbank.ui.home
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.drawable.GradientDrawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.databinding.FragmentPayWithCardBinding
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.bmlapi.BmlCardParser
class PayWithCardFragment : Fragment() {
private var _binding: FragmentPayWithCardBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private var pendingQrAccountNumber: String? = null
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
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 {
_binding = FragmentPayWithCardBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val adapter = CardWalletAdapter(emptyList(), requireContext())
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
}
binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefresh()
}
val updateCardList = {
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
val bmlItems = (viewModel.accounts.value ?: emptyList())
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) }
.map { CardItem.Bml(it) }
val all = mibItems + bmlItems
adapter.update(all)
binding.loadingView.visibility = View.GONE
binding.swipeRefresh.isRefreshing = false
binding.emptyView.visibility = if (all.isEmpty()) View.VISIBLE else View.GONE
binding.recyclerView.visibility = if (all.isEmpty()) View.GONE else View.VISIBLE
}
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
val cached = CardsCache.load(requireContext())
if (cached.isNotEmpty()) {
viewModel.mibCards.value = cached
} else {
binding.loadingView.visibility = View.VISIBLE
}
(activity as? HomeActivity)?.triggerRefreshCards()
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_pay_with_card)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private inner class CardWalletAdapter(
private var cards: List<CardItem>,
private val context: Context
) : RecyclerView.Adapter<CardWalletAdapter.VH>() {
fun update(newCards: List<CardItem>) {
cards = newCards
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
VH(LayoutInflater.from(context).inflate(R.layout.item_card_wallet, parent, false))
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position])
override fun getItemCount() = cards.size
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
private val tvCardType: TextView = view.findViewById(R.id.tvCardType)
private val tvCardStatus: TextView = view.findViewById(R.id.tvCardStatus)
private val btnPayQr: View = view.findViewById(R.id.btnPayQr)
private val btnPayNfc: View = view.findViewById(R.id.btnPayNfc)
fun bind(item: CardItem) {
when (item) {
is CardItem.Mib -> {
tvCardOwner.text = item.card.cardHolderName
tvCardNumber.text = formatMasked(item.card.maskedCardNumber)
tvCardType.text = item.card.cardTypeDesc
val assetPath = cardImageAsset(item.card)
if (assetPath != null) loadCardImage(ivCardImage, assetPath)
else ivCardImage.setImageDrawable(null)
bindCardStatus(tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
}
is CardItem.Bml -> {
tvCardOwner.text = item.account.accountBriefName
tvCardNumber.text = formatMasked(item.account.accountNumber)
tvCardType.text = item.account.accountTypeName
loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
val bmlStatus = item.account.statusDesc.takeUnless { it.equals("Active", ignoreCase = true) }
bindCardStatus(tvCardStatus, bmlStatus)
}
}
val isMib = item is CardItem.Mib
btnPayQr.setOnClickListener {
if (isMib) {
Toast.makeText(context, R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
} else {
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
}
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(context)
val nfcSupported = nfcAdapter != null
btnPayNfc.isEnabled = nfcSupported
btnPayNfc.setOnClickListener {
val msg = if (isMib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
}
}
}
}
companion object {
fun cardImageAsset(card: MibCard): String? = when (card.cardType) {
"53" -> "cards/mib/visa_black_platinum.png"
"57" -> "cards/mib/visa_blue_everyday.png"
"70" -> "cards/mib/visa_business.png"
else -> null
}
fun loadCardImage(imageView: ImageView, assetPath: String) {
try {
val bitmap = imageView.context.assets.open(assetPath).use {
BitmapFactory.decodeStream(it)
}
imageView.setImageBitmap(bitmap)
} catch (_: Exception) {
imageView.setImageDrawable(null)
}
}
fun mibCardStatusLabel(cardStatus: String): String? = when (cardStatus) {
"CHST0" -> null // Active — no badge
else -> cardStatus
}
fun bindCardStatus(tv: TextView, statusLabel: String?) {
if (statusLabel == null) { tv.visibility = View.GONE; return }
tv.visibility = View.VISIBLE
tv.text = statusLabel
val dp = tv.context.resources.displayMetrics.density
tv.background = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = 12 * dp
setColor(0xCC212121.toInt())
}
}
fun formatMasked(masked: String): String {
if (masked.length < 4) return masked
return "\u2022\u2022\u2022\u2022 ${masked.takeLast(4)}"
}
}
}
@@ -13,6 +13,12 @@ import android.provider.Settings
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.Animatable
import android.view.ScaleGestureDetector
import androidx.appcompat.content.res.AppCompatResources
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@@ -52,6 +58,8 @@ class QrScannerActivity : AppCompatActivity() {
textMode = ZxingCpp.TextMode.PLAIN textMode = ZxingCpp.TextMode.PLAIN
) )
private var camera: Camera? = null
private var torchEnabled = false
private var cameraStarted = false private var cameraStarted = false
private val permissionLauncher = registerForActivityResult( private val permissionLauncher = registerForActivityResult(
@@ -104,8 +112,36 @@ class QrScannerActivity : AppCompatActivity() {
} }
insets insets
} }
binding.btnCancel.setOnClickListener { finish() }
binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") } binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") }
binding.zoomSlider.addOnChangeListener { _, value, fromUser ->
if (fromUser) camera?.cameraControl?.setLinearZoom(value)
}
val scaleDetector = ScaleGestureDetector(this,
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
val state = camera?.cameraInfo?.zoomState?.value ?: return true
camera?.cameraControl?.setZoomRatio(
(state.zoomRatio * detector.scaleFactor)
.coerceIn(state.minZoomRatio, state.maxZoomRatio)
)
return true
}
})
binding.previewView.setOnTouchListener { _, event ->
scaleDetector.onTouchEvent(event)
true
}
binding.btnFlashlight.setOnClickListener {
torchEnabled = !torchEnabled
camera?.cameraControl?.enableTorch(torchEnabled)
val drawableRes = if (torchEnabled) R.drawable.ic_flashlight_to_on else R.drawable.ic_flashlight_to_off
val drawable = AppCompatResources.getDrawable(this, drawableRes)
binding.btnFlashlight.icon = drawable
(drawable as? Animatable)?.start()
binding.btnFlashlight.iconTint = ColorStateList.valueOf(
if (torchEnabled) Color.parseColor("#FFEB3B") else Color.WHITE
)
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED == PackageManager.PERMISSION_GRANTED
@@ -179,9 +215,12 @@ class QrScannerActivity : AppCompatActivity() {
try { try {
provider.unbindAll() provider.unbindAll()
provider.bindToLifecycle( camera = provider.bindToLifecycle(
this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
) )
camera?.cameraInfo?.zoomState?.observe(this@QrScannerActivity) { state ->
binding.zoomSlider.value = state.linearZoom
}
} catch (_: Exception) { } catch (_: Exception) {
finish() finish()
} }
@@ -1,29 +1,45 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
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.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentSettingsAppearanceBinding import sh.sar.basedbank.databinding.FragmentSettingsAppearanceBinding
import java.util.Collections
class SettingsAppearanceFragment : Fragment() { class SettingsAppearanceFragment : Fragment() {
private var _binding: FragmentSettingsAppearanceBinding? = null private var _binding: FragmentSettingsAppearanceBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private lateinit var prefs: SharedPreferences
private val slots = mutableListOf<Int>()
private val quickActions = mutableListOf<Int>()
private lateinit var slotAdapter: NavItemAdapter
private lateinit var quickActionAdapter: 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)
return binding.root return binding.root
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
// Navigation mode // Navigation mode
val isBottom = prefs.getBoolean("bottom_nav", false) val isBottom = prefs.getBoolean("bottom_nav", false)
@@ -32,8 +48,35 @@ class SettingsAppearanceFragment : Fragment() {
if (!isChecked) return@addOnButtonCheckedListener if (!isChecked) return@addOnButtonCheckedListener
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply() prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
(activity as? HomeActivity)?.applyNavMode() (activity as? HomeActivity)?.applyNavMode()
updateShortcutsVisibility()
} }
// Quick actions
quickActions.clear()
quickActions.addAll(NavCustomization.getQuickActions(prefs))
quickActionAdapter = NavItemAdapter(quickActions) {
NavCustomization.saveQuickActions(prefs, quickActions)
}
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions)
// Bottom bar shortcuts
slots.clear()
slots.addAll(NavCustomization.getSlots(prefs))
slotAdapter = NavItemAdapter(slots) {
NavCustomization.saveSlots(prefs, slots)
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
}
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots)
// Show labels toggle
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
binding.switchShowLabels.isChecked = showLabels
binding.switchShowLabels.setOnCheckedChangeListener { _, checked ->
prefs.edit().putBoolean("bottom_nav_show_labels", checked).apply()
(activity as? HomeActivity)?.applyNavLabelVisibility()
}
updateShortcutsVisibility()
// Theme // Theme
val saved = prefs.getString("theme", "system") val saved = prefs.getString("theme", "system")
binding.themeToggle.check(when (saved) { binding.themeToggle.check(when (saved) {
@@ -63,6 +106,95 @@ class SettingsAppearanceFragment : Fragment() {
} }
} }
private fun setupNavItemRecyclerView(
rv: RecyclerView,
adapter: NavItemAdapter,
items: MutableList<Int>
) {
rv.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
rv.adapter = adapter
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.START or ItemTouchHelper.END, 0
) {
override fun onMove(
rv: RecyclerView,
from: RecyclerView.ViewHolder,
to: RecyclerView.ViewHolder
): Boolean {
val fromPos = from.adapterPosition
val toPos = to.adapterPosition
Collections.swap(items, fromPos, toPos)
adapter.notifyItemMoved(fromPos, toPos)
adapter.onSave()
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
}).attachToRecyclerView(rv)
}
private fun updateShortcutsVisibility() {
val isBottom = prefs.getBoolean("bottom_nav", false)
binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
}
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
if (items === slots && !prefs.getBoolean("bottom_nav", false)) return
val ctx = requireContext()
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
val listLayout = LinearLayout(ctx).apply { orientation = LinearLayout.VERTICAL }
val rows = available.map { item ->
LayoutInflater.from(ctx).inflate(R.layout.item_more_nav, listLayout, false).also { row ->
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
listLayout.addView(row)
}
}
val scroll = ScrollView(ctx).apply { addView(listLayout) }
var dialog: androidx.appcompat.app.AlertDialog? = null
dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.settings_bottom_bar_select)
.setView(scroll)
.setNegativeButton(R.string.cancel, null)
.show()
available.forEachIndexed { i, item ->
rows[i].setOnClickListener {
items[slotIndex] = item.id
adapter.onSave()
adapter.notifyItemChanged(slotIndex)
dialog?.dismiss()
}
}
}
private inner class NavItemAdapter(
val items: MutableList<Int>,
val onSave: () -> Unit
) : RecyclerView.Adapter<NavItemAdapter.VH>() {
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
val ivNavIcon: ImageView = view.findViewById(R.id.ivNavIcon)
val tvNavLabel: TextView = view.findViewById(R.id.tvNavLabel)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_nav_slot, parent, false)
val itemWidth = if (parent.measuredWidth > 0) parent.measuredWidth / items.size
else RecyclerView.LayoutParams.WRAP_CONTENT
view.layoutParams = RecyclerView.LayoutParams(itemWidth, RecyclerView.LayoutParams.WRAP_CONTENT)
return VH(view)
}
override fun getItemCount() = items.size
override fun onBindViewHolder(holder: VH, position: Int) {
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == items[position] } ?: return
holder.ivNavIcon.setImageResource(def.iconRes)
holder.tvNavLabel.setText(def.titleRes)
holder.itemView.setOnClickListener { showItemPicker(items, holder.adapterPosition, this) }
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
requireActivity().title = getString(R.string.settings_appearance) requireActivity().title = getString(R.string.settings_appearance)
@@ -17,14 +17,15 @@ class SettingsFragment : Fragment() {
private data class SettingsItem( private data class SettingsItem(
@DrawableRes val icon: Int, @DrawableRes val icon: Int,
@StringRes val title: Int, @StringRes val title: Int,
@StringRes val description: Int,
val dest: () -> Fragment val dest: () -> Fragment
) )
private val items = listOf( private val items = listOf(
SettingsItem(R.drawable.ic_contacts, R.string.settings_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) { 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) { SettingsSecurityFragment() }, SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security, R.string.settings_desc_privacy_security) { SettingsSecurityFragment() },
SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage) { SettingsStorageFragment() }, SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage, R.string.settings_desc_storage) { SettingsStorageFragment() },
) )
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View = override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
@@ -37,6 +38,7 @@ class SettingsFragment : Fragment() {
val row = inflater.inflate(R.layout.item_more_nav, list, false) val row = inflater.inflate(R.layout.item_more_nav, list, false)
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.icon) row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.icon)
row.findViewById<TextView>(R.id.tvLabel).setText(item.title) row.findViewById<TextView>(R.id.tvLabel).setText(item.title)
row.findViewById<TextView>(R.id.tvDescription).setText(item.description)
row.setOnClickListener { row.setOnClickListener {
(requireActivity() as HomeActivity).showWithBackStack(item.dest()) (requireActivity() as HomeActivity).showWithBackStack(item.dest())
} }
File diff suppressed because it is too large Load Diff
@@ -31,6 +31,15 @@ class SettingsSecurityFragment : Fragment() {
) )
} }
// Auto unlock on correct PIN (only for pin method)
if (prefs.getString("security_method", null) == "pin") {
binding.rowAutoUnlockPin.visibility = View.VISIBLE
binding.switchAutoUnlockPin.isChecked = prefs.getBoolean("auto_unlock_pin", false)
binding.switchAutoUnlockPin.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("auto_unlock_pin", isChecked).apply()
}
}
// Biometrics // Biometrics
val canUseBiometrics = BiometricManager.from(requireContext()) val canUseBiometrics = BiometricManager.from(requireContext())
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
@@ -60,7 +69,6 @@ class SettingsSecurityFragment : Fragment() {
// Auto-lock // Auto-lock
binding.autolockToggle.check(when (prefs.getLong("autolock_timeout", 60_000L)) { binding.autolockToggle.check(when (prefs.getLong("autolock_timeout", 60_000L)) {
0L -> R.id.btnAutolockOff
30_000L -> R.id.btnAutolock30s 30_000L -> R.id.btnAutolock30s
180_000L -> R.id.btnAutolock3m 180_000L -> R.id.btnAutolock3m
300_000L -> R.id.btnAutolock5m 300_000L -> R.id.btnAutolock5m
@@ -69,7 +77,6 @@ class SettingsSecurityFragment : Fragment() {
binding.autolockToggle.addOnButtonCheckedListener { _, checkedId, isChecked -> binding.autolockToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener if (!isChecked) return@addOnButtonCheckedListener
val timeout = when (checkedId) { val timeout = when (checkedId) {
R.id.btnAutolockOff -> 0L
R.id.btnAutolock30s -> 30_000L R.id.btnAutolock30s -> 30_000L
R.id.btnAutolock3m -> 180_000L R.id.btnAutolock3m -> 180_000L
R.id.btnAutolock5m -> 300_000L R.id.btnAutolock5m -> 300_000L
@@ -79,6 +86,17 @@ class SettingsSecurityFragment : Fragment() {
(activity as? HomeActivity)?.resetAutolockTimer() (activity as? HomeActivity)?.resetAutolockTimer()
} }
// Hide sensitive information (enables/disables the eye icon in toolbar)
val viewModel = (requireActivity() as HomeActivity).let {
androidx.lifecycle.ViewModelProvider(it)[HomeViewModel::class.java]
}
binding.switchHideAmounts.isChecked = prefs.getBoolean("hide_sensitive_info", false)
binding.switchHideAmounts.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("hide_sensitive_info", isChecked).apply()
if (!isChecked) viewModel.hideAmounts.value = false
requireActivity().invalidateOptionsMenu()
}
// Block screenshots // Block screenshots
val blockScreenshots = prefs.getBoolean("block_screenshots", true) val blockScreenshots = prefs.getBoolean("block_screenshots", true)
binding.switchBlockScreenshots.isChecked = blockScreenshots binding.switchBlockScreenshots.isChecked = blockScreenshots
@@ -7,10 +7,12 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.TransactionCache import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentSettingsStorageBinding import sh.sar.basedbank.databinding.FragmentSettingsStorageBinding
import sh.sar.basedbank.util.AccountCache import sh.sar.basedbank.util.AccountCache
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.ContactImageCache import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.ContactsCache import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.FinancingCache import sh.sar.basedbank.util.FinancingCache
@@ -21,6 +23,7 @@ class SettingsStorageFragment : Fragment() {
private var _binding: FragmentSettingsStorageBinding? = null private var _binding: FragmentSettingsStorageBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsStorageBinding.inflate(inflater, container, false) _binding = FragmentSettingsStorageBinding.inflate(inflater, container, false)
@@ -32,6 +35,7 @@ class SettingsStorageFragment : Fragment() {
val ctx = requireContext() val ctx = requireContext()
clearAllCaches(ctx) clearAllCaches(ctx)
Toast.makeText(ctx, R.string.settings_cache_cleared, Toast.LENGTH_SHORT).show() Toast.makeText(ctx, R.string.settings_cache_cleared, Toast.LENGTH_SHORT).show()
(activity as? HomeActivity)?.triggerRefresh()
} }
} }
@@ -48,6 +52,13 @@ class SettingsStorageFragment : Fragment() {
private fun clearAllCaches(ctx: Context) { private fun clearAllCaches(ctx: Context) {
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx) AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx) ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx) CardsCache.clear(ctx); TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
viewModel.accounts.value = emptyList()
viewModel.mibCards.value = null
viewModel.financing.value = emptyList()
viewModel.bmlLoanDetails.value = emptyMap()
viewModel.bmlLimits.value = emptyList()
viewModel.contacts.value = emptyList()
viewModel.contactCategories.value = emptyList()
} }
} }
@@ -9,17 +9,17 @@ import android.view.ViewGroup
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import sh.sar.basedbank.api.mib.Transaction import sh.sar.basedbank.api.models.BankTransaction
import sh.sar.basedbank.databinding.ItemDateHeaderBinding import sh.sar.basedbank.databinding.ItemDateHeaderBinding
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
import sh.sar.basedbank.databinding.ItemTransactionBinding import sh.sar.basedbank.databinding.ItemTransactionBinding
/** Adapter for Transfer History — date-grouped, shows account name in secondary line. */ /** Adapter for BankTransaction History — date-grouped, shows account name in secondary line. */
class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() { class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
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: Transaction) : Item() data class Trx(val transaction: BankTransaction) : Item()
} }
private val displayItems = mutableListOf<Item>() private val displayItems = mutableListOf<Item>()
@@ -27,6 +27,13 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private val iconUrlCache = mutableMapOf<String, Bitmap>() private val iconUrlCache = mutableMapOf<String, Bitmap>()
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
var onIconUrlNeeded: ((url: String) -> Unit)? = null var onIconUrlNeeded: ((url: String) -> Unit)? = null
private var hideAmounts: Boolean = false
fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return
hideAmounts = hide
notifyDataSetChanged()
}
fun updateImage(counterpartyName: String, bitmap: Bitmap) { fun updateImage(counterpartyName: String, bitmap: Bitmap) {
imageCache[counterpartyName] = bitmap imageCache[counterpartyName] = bitmap
@@ -60,7 +67,7 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
} }
/** Replace the full sorted transaction list and rebuild date groups. */ /** Replace the full sorted transaction list and rebuild date groups. */
fun setTransactions(transactions: List<Transaction>) { fun setTransactions(transactions: List<BankTransaction>) {
_showLoadingFooter = false _showLoadingFooter = false
displayItems.clear() displayItems.clear()
var lastDateKey = "" var lastDateKey = ""
@@ -107,7 +114,7 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
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: Transaction) { fun bind(trx: BankTransaction) {
val isCredit = trx.amount >= 0 val isCredit = trx.amount >= 0
val color = AccountHistoryAdapter.sourceColor(trx.source) val color = AccountHistoryAdapter.sourceColor(trx.source)
val name = trx.counterpartyName ?: trx.description val name = trx.counterpartyName ?: trx.description
@@ -134,23 +141,28 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
} }
b.tvDescription.text = trx.description b.tvDescription.text = trx.description
// Show account name in secondary line for Transfer History // Show account name in secondary line for BankTransaction History
b.tvCounterparty.text = trx.accountDisplayName b.tvCounterparty.text = trx.accountDisplayName
b.tvCounterparty.visibility = View.VISIBLE b.tvCounterparty.visibility = View.VISIBLE
b.tvDate.text = AccountHistoryAdapter.formatTime(trx.date) b.tvDate.text = AccountHistoryAdapter.formatTime(trx.date)
val sign = if (isCredit) "+" else "-" if (hideAmounts) {
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount)) b.tvAmount.text = "${trx.currency} ••••••"
b.tvAmount.text = "$sign ${trx.currency} $absAmt" b.tvAmount.setTextColor(Color.parseColor("#888888"))
b.tvAmount.setTextColor( } else {
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043") val sign = if (isCredit) "+" else "-"
) val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
b.tvAmount.setTextColor(
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
)
}
b.root.setOnClickListener { showDetail(trx) } b.root.setOnClickListener { showDetail(trx) }
} }
private fun showDetail(trx: Transaction) { private fun showDetail(trx: BankTransaction) {
val ctx = b.root.context val ctx = b.root.context
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
val details = buildString { val details = buildString {
File diff suppressed because it is too large Load Diff
@@ -10,6 +10,8 @@ import android.util.Base64
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 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 androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@@ -23,12 +25,13 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.bml.BmlHistoryClient
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
import sh.sar.basedbank.api.mib.MibAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibHistoryClient import sh.sar.basedbank.api.mib.MibHistoryClient
import sh.sar.basedbank.api.mib.Transaction import sh.sar.basedbank.api.models.BankServerException
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.FragmentTransferHistoryBinding import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding
import sh.sar.basedbank.util.ContactImageCache import sh.sar.basedbank.util.ContactImageCache
@@ -46,12 +49,12 @@ class TransferHistoryFragment : Fragment() {
private lateinit var adapter: TransactionAdapter private lateinit var adapter: TransactionAdapter
// All merged transactions sorted by date desc // All merged transactions sorted by date desc
private val allTransactions = mutableListOf<Transaction>() private val allTransactions = mutableListOf<BankTransaction>()
private var searchQuery = "" private var searchQuery = ""
// Per-account pagination state // Per-account pagination state
private data class AccountState( private data class AccountState(
val account: MibAccount, val account: BankAccount,
var mibNextStart: Int = 1, var mibNextStart: Int = 1,
var mibTotalCount: Int = -1, var mibTotalCount: Int = -1,
var bmlNextPage: Int = 1, var bmlNextPage: Int = 1,
@@ -61,10 +64,10 @@ class TransferHistoryFragment : Fragment() {
var fahipayTotal: Int = -1 var fahipayTotal: Int = -1
) { ) {
fun hasMore(): Boolean = when { fun hasMore(): Boolean = when {
account.profileType == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2 account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT" -> cardMonthOffset < 2
account.profileType.startsWith("BML") -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
} }
} }
@@ -84,9 +87,20 @@ class TransferHistoryFragment : Fragment() {
adapter = TransactionAdapter() adapter = TransactionAdapter()
adapter.onImageNeeded = { name -> loadContactImage(name) } adapter.onImageNeeded = { name -> loadContactImage(name) }
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) } adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter binding.recyclerView.adapter = adapter
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
}
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) { override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
if (dy <= 0 || isLoading) return if (dy <= 0 || isLoading) return
@@ -125,6 +139,14 @@ class TransferHistoryFragment : Fragment() {
} }
(activity as? HomeActivity)?.setRefreshing(true) (activity as? HomeActivity)?.setRefreshing(true)
loadNextPages() loadNextPages()
binding.swipeRefresh.setOnRefreshListener {
if (isLoading) {
binding.swipeRefresh.isRefreshing = false
} else {
resetAndReload()
}
}
} }
override fun onResume() { override fun onResume() {
@@ -132,6 +154,26 @@ class TransferHistoryFragment : Fragment() {
requireActivity().title = getString(R.string.nav_transfer_history) requireActivity().title = getString(R.string.nav_transfer_history)
} }
private fun resetAndReload() {
allTransactions.clear()
pendingImageNames.clear()
pendingIconUrls.clear()
firstBatchDone = false
val accounts = accountStates.map { it.account }
accountStates.clear()
accounts.forEach { accountStates.add(AccountState(it)) }
// Restore cache immediately so data stays visible while refreshing
val cached = TransactionCache.load(requireContext(), "transfer")
if (cached.isNotEmpty()) {
allTransactions.addAll(cached)
filterAndDisplay()
} else {
adapter.setTransactions(emptyList())
binding.emptyView.visibility = View.GONE
}
loadNextPages()
}
private fun loadNextPages() { private fun loadNextPages() {
val activeStates = accountStates.filter { it.hasMore() } val activeStates = accountStates.filter { it.hasMore() }
if (isLoading || activeStates.isEmpty()) return if (isLoading || activeStates.isEmpty()) return
@@ -142,25 +184,32 @@ class TransferHistoryFragment : Fragment() {
} }
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val mibSession = app.mibSession
lifecycleScope.launch { lifecycleScope.launch {
val bannerMsg = java.util.concurrent.atomic.AtomicReference<String?>(null)
fun trackError(e: Exception) {
when {
e is java.io.IOException -> bannerMsg.compareAndSet(null, "IO")
e is BankServerException -> bannerMsg.compareAndSet(null, "SERVER:${e.bankName}")
}
}
val newTransactions = withContext(Dispatchers.IO) { val newTransactions = withContext(Dispatchers.IO) {
val results = mutableListOf<Transaction>() val results = mutableListOf<BankTransaction>()
// BML accounts: fetch in parallel // BML accounts: fetch in parallel
val bmlStates = activeStates.filter { it.account.profileType.startsWith("BML") } val bmlStates = activeStates.filter { it.account.bank == "BML" }
results.addAll(bmlStates.map { state -> results.addAll(bmlStates.map { state ->
async { async {
try { try {
when { when {
state.account.profileType == "BML_PREPAID" || state.account.profileType == "BML_CREDIT" -> { state.account.profileType == "BML_PREPAID" || state.account.profileType == "BML_CREDIT" || state.account.profileType == "BML_DEBIT" -> {
val session = app.bmlSessionFor(state.account) ?: return@async emptyList() val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
val cal = Calendar.getInstance() val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -state.cardMonthOffset) cal.add(Calendar.MONTH, -state.cardMonthOffset)
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time) val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
state.cardMonthOffset++ state.cardMonthOffset++
BmlLoginFlow().fetchCardHistory( BmlHistoryClient().fetchCardHistory(
session = session, session = session,
cardId = state.account.internalId, cardId = state.account.internalId,
accountDisplayName = state.account.accountBriefName, accountDisplayName = state.account.accountBriefName,
@@ -170,7 +219,7 @@ class TransferHistoryFragment : Fragment() {
} }
else -> { else -> {
val session = app.bmlSessionFor(state.account) ?: return@async emptyList() val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory( val (list, totalPages) = BmlHistoryClient().fetchAccountHistory(
session = session, session = session,
accountId = state.account.internalId, accountId = state.account.internalId,
accountDisplayName = state.account.accountBriefName, accountDisplayName = state.account.accountBriefName,
@@ -182,18 +231,16 @@ class TransferHistoryFragment : Fragment() {
list list
} }
} }
} catch (_: Exception) { emptyList<Transaction>() } } catch (e: Exception) { trackError(e); emptyList<BankTransaction>() }
} }
}.awaitAll().flatten()) }.awaitAll().flatten())
// Fahipay accounts // Fahipay accounts
val fahipayStates = activeStates.filter { it.account.profileType == "FAHIPAY" } val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" }
for (state in fahipayStates) { for (state in fahipayStates) {
val session = app.fahipaySession ?: continue val session = app.fahipaySessionFor(state.account) ?: continue
try { try {
val flow = FahipayLoginFlow() val (list, total) = FahipayHistoryClient().fetchHistory(
flow.setSessionCookie(session.sessionCookie)
val (list, total) = flow.fetchHistory(
session = session, session = session,
accountDisplayName = state.account.accountBriefName, accountDisplayName = state.account.accountBriefName,
accountNumber = state.account.accountNumber, accountNumber = state.account.accountNumber,
@@ -202,31 +249,34 @@ class TransferHistoryFragment : Fragment() {
if (total > 0) state.fahipayTotal = total if (total > 0) state.fahipayTotal = total
state.fahipayNextStart += list.size state.fahipayNextStart += list.size
results.addAll(list) results.addAll(list)
} catch (_: Exception) {} } catch (e: Exception) { trackError(e) }
} }
// MIB accounts: serialized per profile, protected by mutex to prevent session race // MIB accounts: serialized per profile, protected by mutex to prevent session race
val mibStates = activeStates.filter { val mibStates = activeStates.filter { it.account.bank == "MIB" }
!it.account.profileType.startsWith("BML") && it.account.profileType != "FAHIPAY" for ((loginId, loginStates) in mibStates.groupBy { it.account.loginTag.removePrefix("mib_") }) {
} val session = app.mibSessions[loginId] ?: continue
for ((profileId, states) in mibStates.groupBy { it.account.profileId }) { for ((profileId, states) in loginStates.groupBy { it.account.profileId }) {
val session = mibSession ?: break app.mibMutex.withLock {
app.mibMutex.withLock {
val profile = app.mibProfiles.firstOrNull { it.profileId == profileId }
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
for (state in states) {
try { try {
val (list, total) = MibHistoryClient().fetchHistory( val profiles = app.mibProfilesMap[loginId] ?: emptyList()
session = session, val profile = profiles.firstOrNull { it.profileId == profileId }
accountNo = state.account.accountNumber, if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
accountDisplayName = state.account.accountBriefName, for (state in states) {
start = state.mibNextStart, try {
pageSize = pageSize val (list, total) = MibHistoryClient().fetchHistory(
) session = session,
if (total > 0) state.mibTotalCount = total accountNo = state.account.accountNumber,
state.mibNextStart += list.size.coerceAtLeast(pageSize) accountDisplayName = state.account.accountBriefName,
results.addAll(list) start = state.mibNextStart,
} catch (_: Exception) {} pageSize = pageSize
)
if (total > 0) state.mibTotalCount = total
state.mibNextStart += list.size.coerceAtLeast(pageSize)
results.addAll(list)
} catch (e: Exception) { trackError(e) }
}
} catch (e: Exception) { trackError(e) }
} }
} }
} }
@@ -235,10 +285,21 @@ class TransferHistoryFragment : Fragment() {
} }
isLoading = false isLoading = false
if (_binding == null) return@launch
val raw = bannerMsg.get()
when {
raw == null -> (activity as? HomeActivity)?.hideConnectivityBanner()
raw == "IO" -> (activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
raw.startsWith("SERVER:") -> (activity as? HomeActivity)?.showConnectivityBanner(
getString(R.string.connectivity_server_error, raw.removePrefix("SERVER:"))
)
}
if (!firstBatchDone) { if (!firstBatchDone) {
firstBatchDone = true firstBatchDone = true
(activity as? HomeActivity)?.setRefreshing(false) (activity as? HomeActivity)?.setRefreshing(false)
binding.swipeRefresh.isRefreshing = false
} }
if (newTransactions.isNotEmpty()) { if (newTransactions.isNotEmpty()) {
@@ -280,7 +341,7 @@ class TransferHistoryFragment : Fragment() {
return return
} }
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val sess = app.mibSession ?: return val sess = app.anyMibSession() ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
@@ -1,7 +1,7 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
data class TransferReceiptData( data class TransferReceiptData(
val isMib: Boolean, val bank: String, // "MIB", "BML", etc.
val amount: String, val amount: String,
val currency: String, val currency: String,
val fromLabel: String, val fromLabel: String,
@@ -1,6 +1,10 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.app.Dialog
import android.content.ClipData
import android.content.ClipboardManager
import android.content.ContentValues import android.content.ContentValues
import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
@@ -46,7 +50,7 @@ class TransferReceiptFragment : Fragment() {
private val receiptCard get() = _receiptCard!! private val receiptCard get() = _receiptCard!!
companion object { companion object {
private const val ARG_IS_MIB = "is_mib" private const val ARG_BANK = "bank"
private const val ARG_AMOUNT = "amount" private const val ARG_AMOUNT = "amount"
private const val ARG_CURRENCY = "currency" private const val ARG_CURRENCY = "currency"
private const val ARG_FROM_LABEL = "from_label" private const val ARG_FROM_LABEL = "from_label"
@@ -69,7 +73,7 @@ class TransferReceiptFragment : Fragment() {
fun newInstance(data: TransferReceiptData, toAvatarBitmap: Bitmap?) = TransferReceiptFragment().apply { fun newInstance(data: TransferReceiptData, toAvatarBitmap: Bitmap?) = TransferReceiptFragment().apply {
pendingToAvatarBitmap = toAvatarBitmap pendingToAvatarBitmap = toAvatarBitmap
arguments = Bundle().apply { arguments = Bundle().apply {
putBoolean(ARG_IS_MIB, data.isMib) putString(ARG_BANK, data.bank)
putString(ARG_AMOUNT, data.amount) putString(ARG_AMOUNT, data.amount)
putString(ARG_CURRENCY, data.currency) putString(ARG_CURRENCY, data.currency)
putString(ARG_FROM_LABEL, data.fromLabel) putString(ARG_FROM_LABEL, data.fromLabel)
@@ -90,8 +94,8 @@ class TransferReceiptFragment : Fragment() {
} }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val isMib = arguments?.getBoolean(ARG_IS_MIB, true) ?: true val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
return if (isMib) { return if (bank == "MIB") {
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false) val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
bindMib(binding) bindMib(binding)
_receiptCard = binding.receiptCard _receiptCard = binding.receiptCard
@@ -105,6 +109,8 @@ class TransferReceiptFragment : Fragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
receiptCard.setOnClickListener { showFullScreenReceipt() }
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener { view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
parentFragmentManager.popBackStack() parentFragmentManager.popBackStack()
} }
@@ -150,15 +156,21 @@ class TransferReceiptFragment : Fragment() {
binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "") binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "")
binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "") binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "")
binding.tvPurpose.text = args.getString(ARG_REMARKS, "") binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
copyOnLongClick(
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount,
binding.tvReferenceNo, binding.tvToAccount, binding.tvToBank,
binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
)
} }
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) { private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val sess = app.mibSession ?: return val sess = app.anyMibSession() ?: return
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) { viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
try { try {
val base64 = if (isProfile) { val base64 = if (isProfile) {
app.mibLoginFlow.fetchProfileImage(sess, hash) app.anyMibFlow()?.fetchProfileImage(sess, hash)
} else { } else {
MibContactsClient().fetchProfileImageBase64(sess, hash) MibContactsClient().fetchProfileImageBase64(sess, hash)
} ?: return@launch } ?: return@launch
@@ -201,6 +213,13 @@ class TransferReceiptFragment : Fragment() {
binding.remarksDivider.visibility = View.VISIBLE binding.remarksDivider.visibility = View.VISIBLE
binding.remarksRow.visibility = View.VISIBLE binding.remarksRow.visibility = View.VISIBLE
} }
copyOnLongClick(
binding.tvMessage, binding.tvMessageRow, binding.tvReference,
binding.tvTransactionDate, binding.tvFrom, binding.tvToName,
binding.tvToAccount, binding.tvAmountRow, binding.tvAmountValue,
binding.tvAmountCurrency, binding.tvRemarks
)
} }
// ── Share / Save ────────────────────────────────────────────────────────── // ── Share / Save ──────────────────────────────────────────────────────────
@@ -310,6 +329,66 @@ class TransferReceiptFragment : Fragment() {
return bm return bm
} }
private fun showFullScreenReceipt() {
val ctx = requireContext()
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
val scrollView = android.widget.ScrollView(ctx).apply {
setBackgroundColor(Color.BLACK)
}
val cardView = if (bank == "MIB") {
val binding = FragmentReceiptMibBinding.inflate(layoutInflater)
bindMib(binding)
binding.receiptCard
} else {
val binding = FragmentReceiptBmlBinding.inflate(layoutInflater)
bindBml(binding)
binding.receiptCard
}
(cardView.parent as? ViewGroup)?.removeView(cardView)
cardView.setOnClickListener { dialog.dismiss() }
scrollView.addView(cardView)
scrollView.setOnTouchListener { _, _ -> dialog.dismiss(); true }
dialog.setContentView(scrollView)
val actWin = requireActivity().window
val prevColor = actWin.statusBarColor
val insetsCtrl = androidx.core.view.WindowInsetsControllerCompat(actWin, actWin.decorView)
actWin.statusBarColor = Color.BLACK
insetsCtrl.isAppearanceLightStatusBars = false
dialog.setOnDismissListener {
actWin.statusBarColor = prevColor
val isLight = (resources.configuration.uiMode and
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
android.content.res.Configuration.UI_MODE_NIGHT_NO
insetsCtrl.isAppearanceLightStatusBars = isLight
}
dialog.show()
dialog.window?.let { win ->
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
androidx.core.view.WindowInsetsControllerCompat(win, scrollView).apply {
hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
systemBarsBehavior = androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
}
}
private fun copyOnLongClick(vararg views: android.widget.TextView) {
for (tv in views) {
tv.setOnLongClickListener {
val text = tv.text?.toString()?.trim() ?: return@setOnLongClickListener false
if (text.isBlank()) return@setOnLongClickListener false
val cm = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
cm.setPrimaryClip(ClipData.newPlainText("receipt", text))
Toast.makeText(requireContext(), "Copied", Toast.LENGTH_SHORT).show()
true
}
}
}
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
requireActivity().title = "Receipt" requireActivity().title = "Receipt"
@@ -18,14 +18,20 @@ import kotlinx.coroutines.withContext
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
import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.fahipay.FahipayAccountClient
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
import sh.sar.basedbank.api.fahipay.FahipaySession import sh.sar.basedbank.api.fahipay.FahipaySession
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibLoginFlow import sh.sar.basedbank.api.mib.MibLoginFlow
import sh.sar.basedbank.api.mib.MibProfileClient
import sh.sar.basedbank.util.AccountCache 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 com.google.android.material.dialog.MaterialAlertDialogBuilder
class CredentialsFragment : Fragment() { class CredentialsFragment : Fragment() {
@@ -46,6 +52,11 @@ class CredentialsFragment : Fragment() {
private var fahipayFlow: FahipayLoginFlow? = null private var fahipayFlow: FahipayLoginFlow? = null
private var fahipayAwaitingTotp = false private var fahipayAwaitingTotp = false
// BML multi-profile state
private var bmlFlow: BmlLoginFlow? = null
private var bmlLoginId: String = ""
private var bmlAccumulatedAccounts = mutableListOf<BankAccount>()
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
@@ -54,7 +65,7 @@ class CredentialsFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
when (bankType) { when (bankType) {
"BML" -> { "BML" -> {
binding.ivBankLogo.setImageResource(R.drawable.bml_logo_vector) binding.ivBankLogo.setImageResource(R.drawable.bml_logo_long)
binding.tvSignInDesc.setText(R.string.bml_sign_in_desc) binding.tvSignInDesc.setText(R.string.bml_sign_in_desc)
} }
"FAHIPAY" -> { "FAHIPAY" -> {
@@ -170,6 +181,7 @@ class CredentialsFragment : Fragment() {
binding.btnLogin.isEnabled = false binding.btnLogin.isEnabled = false
val passwordHash = MibLoginFlow.hashPassword(password) val passwordHash = MibLoginFlow.hashPassword(password)
val loginId = username
val flow = MibLoginFlow(CredentialStore(requireContext())) val flow = MibLoginFlow(CredentialStore(requireContext()))
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
@@ -178,11 +190,12 @@ class CredentialsFragment : Fragment() {
flow.login(username, passwordHash, otpSeed) flow.login(username, passwordHash, otpSeed)
} }
val store = CredentialStore(requireContext()) val store = CredentialStore(requireContext())
store.saveMibCredentials(username, passwordHash, otpSeed) store.saveMibCredentials(loginId, username, passwordHash, otpSeed)
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
flow.lastSession?.let { s -> flow.lastSession?.let { s ->
val profile = flow.fetchPersonalProfile(s) val profile = MibProfileClient().fetchPersonalProfile(s)
if (profile != null) store.saveMibUserProfile( if (profile != null) store.saveMibUserProfile(
loginId,
CredentialStore.MibUserProfile( CredentialStore.MibUserProfile(
fullName = profile.fullName, fullName = profile.fullName,
username = profile.username, username = profile.username,
@@ -193,11 +206,15 @@ class CredentialsFragment : Fragment() {
) )
} }
} }
AccountCache.save(requireContext(), accounts) store.saveMibProfiles(loginId, flow.lastProfiles)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
app.accounts = accounts // Merge with any existing MIB accounts from other logins
app.mibSession = flow.lastSession app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" } + accounts
app.mibProfiles = flow.lastProfiles app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" } + accounts
AccountCache.save(requireContext(), app.mibAccounts)
app.mibSessions[loginId] = flow.lastSession!!
app.mibProfilesMap[loginId] = flow.lastProfiles
app.mibLoginFlows[loginId] = flow
val intent = Intent(requireContext(), HomeActivity::class.java) val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent) startActivity(intent)
@@ -226,20 +243,72 @@ class CredentialsFragment : Fragment() {
binding.progressBar.visibility = View.VISIBLE binding.progressBar.visibility = View.VISIBLE
binding.btnLogin.isEnabled = false binding.btnLogin.isEnabled = false
val loginId = username bmlLoginId = username
val flow = BmlLoginFlow() bmlAccumulatedAccounts.clear()
val flow = BmlLoginFlow().also { bmlFlow = it }
val loginTag = "bml_$username"
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
try { try {
val (session, accounts) = withContext(Dispatchers.IO) { val profiles = withContext(Dispatchers.IO) {
flow.login(username, password, otpSeed) flow.login(username, password, otpSeed)
} }
if (profiles.isEmpty()) throw Exception("No profiles found for this account")
var hasBusinessProfiles = false
for (profile in profiles) {
if (profile.profileType == "business") {
hasBusinessProfiles = true
continue // skip — user can enable in Settings → Logins
}
val result = withContext(Dispatchers.IO) { flow.activateProfile(profile, loginTag) }
if (result is BmlActivationResult.Success) {
bmlAccumulatedAccounts += result.accounts
val store = CredentialStore(requireContext())
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
if (result.session.refreshToken.isNotBlank())
store.saveBmlProfileRefreshToken(profile.profileId, result.session.refreshToken)
if (result.session.expiresAt > 0)
store.saveBmlProfileExpiresAt(profile.profileId, result.session.expiresAt)
val app = requireActivity().application as BasedBankApp
app.bmlSessions[profile.profileId] = result.session
}
}
val store = CredentialStore(requireContext()) val store = CredentialStore(requireContext())
store.saveBmlCredentials(loginId, username, password, otpSeed) store.saveBmlCredentials(bmlLoginId, username, password, otpSeed)
store.saveBmlSession(loginId, session.accessToken, session.deviceId) store.saveBmlProfiles(bmlLoginId, profiles)
withContext(Dispatchers.IO) { val app = requireActivity().application as BasedBankApp
val info = flow.fetchUserInfo(session) app.bmlProfilesMap[bmlLoginId] = profiles
if (info != null) store.saveBmlUserProfile( app.bmlLoginFlows[bmlLoginId] = flow
loginId,
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
finishBmlLogin(hasBusinessProfiles)
} catch (e: Exception) {
binding.tvError.text = e.message ?: "Login failed"
binding.tvError.visibility = View.VISIBLE
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
}
}
}
private suspend fun finishBmlLogin(hasBusinessProfiles: Boolean = false) {
val store = CredentialStore(requireContext())
val accounts = bmlAccumulatedAccounts.toList()
// Fetch user profile info from any active session
val app = requireActivity().application as BasedBankApp
val anySession = app.anyBmlSessionFor(bmlLoginId)
if (anySession != null) {
withContext(Dispatchers.IO) {
val info = BmlAccountClient().fetchUserInfo(anySession)
if (info != null) {
store.saveBmlUserProfile(
bmlLoginId,
CredentialStore.BmlUserProfile( CredentialStore.BmlUserProfile(
fullName = info.fullName, fullName = info.fullName,
email = info.email, email = info.email,
@@ -249,24 +318,55 @@ class CredentialsFragment : Fragment() {
birthdate = info.birthdate birthdate = info.birthdate
) )
) )
// Single-profile accounts used username as a temporary profileId.
// Replace it with the real BML customer ID so multi-login doesn't collide.
val customerId = info.customerId
if (customerId.isNotBlank()) {
val profiles = store.loadBmlProfiles(bmlLoginId)
val autoProfile = profiles.firstOrNull { it.autoActivated }
if (autoProfile != null && autoProfile.profileId != customerId) {
val oldId = autoProfile.profileId
// Re-key session in memory and storage
val session = app.bmlSessions.remove(oldId)
if (session != null) {
app.bmlSessions[customerId] = session
val savedRefresh = store.loadBmlProfileRefreshToken(oldId)
val savedExpiry = store.loadBmlProfileExpiresAt(oldId)
store.clearBmlProfileSession(oldId)
store.saveBmlProfileSession(customerId, session.accessToken, session.deviceId)
if (session.refreshToken.isNotBlank())
store.saveBmlProfileRefreshToken(customerId, session.refreshToken)
else if (savedRefresh != null)
store.saveBmlProfileRefreshToken(customerId, savedRefresh)
val expiryToSave = if (session.expiresAt > 0) session.expiresAt else savedExpiry
if (expiryToSave > 0) store.saveBmlProfileExpiresAt(customerId, expiryToSave)
}
// Update stored profile list with the real ID
val updatedProfiles = profiles.map {
if (it.autoActivated) it.copy(profileId = customerId) else it
}
store.saveBmlProfiles(bmlLoginId, updatedProfiles)
app.bmlProfilesMap[bmlLoginId] = updatedProfiles
// Update accounts to use real profileId
bmlAccumulatedAccounts.replaceAll { acc ->
if (acc.profileId == oldId) acc.copy(profileId = customerId) else acc
}
}
}
} }
AccountCache.saveBml(requireContext(), loginId, accounts)
val app = requireActivity().application as BasedBankApp
app.bmlSessions[loginId] = session
// Merge with any existing BML accounts from other logins
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" } + accounts
app.accounts = app.accounts + accounts
val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
} catch (e: Exception) {
binding.tvError.text = e.message ?: "Login failed"
binding.tvError.visibility = View.VISIBLE
} finally {
binding.progressBar.visibility = View.GONE
binding.btnLogin.isEnabled = true
} }
} }
AccountCache.saveBml(requireContext(), bmlLoginId, accounts)
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
app.accounts = app.accounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
if (hasBusinessProfiles) {
Toast.makeText(requireContext(), "Business profiles can be enabled in Settings → Logins", Toast.LENGTH_LONG).show()
}
val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
} }
private fun attemptFahipayLogin() { private fun attemptFahipayLogin() {
@@ -370,15 +470,17 @@ class CredentialsFragment : Fragment() {
store: CredentialStore store: CredentialStore
) { ) {
val (profile, balance) = withContext(Dispatchers.IO) { val (profile, balance) = withContext(Dispatchers.IO) {
val p = flow.fetchProfile(session) val p = FahipayAccountClient().fetchProfile(session)
val b = flow.fetchBalance(session) val b = FahipayAccountClient().fetchBalance(session)
Pair(p, b) Pair(p, b)
} }
val loginTag = "fahipay_${profile.profileId}" val loginId = profile.profileId
val account = flow.buildAccount(profile, balance, loginTag) val loginTag = "fahipay_$loginId"
store.saveFahipayCredentials(idCard, password) val account = FahipayAccountClient().buildAccount(profile, balance, loginTag)
store.saveFahipaySession(session.authId, session.sessionCookie) store.saveFahipayCredentials(loginId, idCard, password)
store.saveFahipaySession(loginId, session.authId, session.sessionCookie)
store.saveFahipayUserProfile( store.saveFahipayUserProfile(
loginId,
CredentialStore.FahipayUserProfile( CredentialStore.FahipayUserProfile(
fullName = profile.fullName, fullName = profile.fullName,
email = profile.email, email = profile.email,
@@ -389,11 +491,11 @@ class CredentialsFragment : Fragment() {
linkedAccounts = profile.linkedAccounts linkedAccounts = profile.linkedAccounts
) )
) )
AccountCache.saveFahipay(requireContext(), listOf(account)) AccountCache.saveFahipay(requireContext(), loginId, listOf(account))
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
app.fahipaySession = session app.fahipaySessions[loginId] = session
app.fahipayAccounts = listOf(account) app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != loginTag } + listOf(account)
app.accounts = app.accounts + listOf(account) app.accounts = app.accounts.filter { it.loginTag != loginTag } + listOf(account)
val intent = Intent(requireContext(), HomeActivity::class.java) val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent) startActivity(intent)
@@ -40,6 +40,11 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, originalBottomPadding + navBar.bottom) view.setPadding(view.paddingLeft, view.paddingTop, view.paddingRight, originalBottomPadding + navBar.bottom)
insets insets
} }
ViewCompat.setOnApplyWindowInsetsListener(binding.viewPager) { view, insets ->
val statusBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(view.paddingLeft, statusBar.top, view.paddingRight, view.paddingBottom)
insets
}
val adapter = OnboardingPagerAdapter(this) val adapter = OnboardingPagerAdapter(this)
binding.viewPager.adapter = adapter binding.viewPager.adapter = adapter
@@ -56,7 +61,7 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
} }
// Pre-select language button without triggering the listener // Pre-select language button without triggering the listener
val savedLang = prefs.getString("language", null) val savedLang = prefs.getString("language", null) ?: "en".also { selectLanguage(it) }
binding.languageToggle.clearOnButtonCheckedListeners() binding.languageToggle.clearOnButtonCheckedListeners()
when (savedLang) { when (savedLang) {
"en" -> binding.btnLangEnglish.isChecked = true "en" -> binding.btnLangEnglish.isChecked = true

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