94 Commits

Author SHA1 Message Date
shihaam c0b58061c2 release version 1.0.10
Auto Tag on Version Change / check-version (push) Successful in 8s
Build and Release APK / build (push) Successful in 3m21s
2026-05-28 23:08:04 +05:00
shihaam 978da26ff1 update ci to push to tg
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-28 23:07:24 +05:00
shihaam 7fe2ba5788 Able to set Default card for payments now
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-28 22:55:49 +05:00
shihaam 26a0c7b81d manage card mode improve part 2 - animations
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-28 22:27:51 +05:00
shihaam 83fc340e2b manage card mode improve part 1
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-28 22:09:26 +05:00
shihaam bfbb649b33 manage card mode added
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-28 22:02:09 +05:00
shihaam b780091bb8 improve BML QR payment flow - dismiss if account selected, keep if card selected, refuse to select account
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-28 21:47:36 +05:00
shihaam e4468c4a8f handle camera permission for profile pic upload
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-28 21:18:59 +05:00
shihaam b4e1f57347 fix paymv qr generation part 1
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-28 20:29:24 +05:00
shihaam 907757c893 remove unsupported accounts from paymv qr generation
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-28 19:06:59 +05:00
shihaam 1ea0355ce6 remove money value from paymv qr generation
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-28 19:02:52 +05:00
shihaam c9b8973b65 dashboard cards are not 14% bigger
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-28 19:00:46 +05:00
shihaam 7a0e32f4d6 seperate mvr and usd blocked funds in dashboard
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-28 18:45:57 +05:00
shihaam d68b8aaf0a tap to copy OTP
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-28 18:41:51 +05:00
shihaam 396f778ad4 remove white border on bml logo and make logo part smaller and red part bigger (looks nicer now)
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-28 18:38:25 +05:00
shihaam dc0f1b96c1 remove cards and quick action lables from dashboard
Auto Tag on Version Change / check-version (push) Successful in 6s
2026-05-28 18:26:09 +05:00
shihaam 640dd5de22 theme customizations support
Auto Tag on Version Change / check-version (push) Successful in 8s
2026-05-28 18:21:38 +05:00
shihaam f0a0e7857c optimize button customizations
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-28 17:25:37 +05:00
shihaam 836f4c493a eye always enabled and removed setting to hide eye
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-28 17:19:04 +05:00
shihaam 6325f4fd7a debug builds get name suffix and different launcher icon color
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-28 17:15:14 +05:00
shihaam 69aa172eff quick actions are drawer-only, bottom bar shortcuts disabled when using drawer 2026-05-28 17:15:04 +05:00
shihaam ed2054fb81 fixbug that took user to empty dashboard without any accounts
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-28 16:32:06 +05:00
shihaam e9583f0580 Merge pull request 'fix/accounts-list-balance-consistency' (#5) from ahusan/fksar:fix/accounts-list-balance-consistency into main
Auto Tag on Version Change / check-version (push) Successful in 5s
Reviewed-on: #5
2026-05-28 16:23:33 +05:00
shihaam a32841a319 compress images
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-28 15:58:26 +05:00
shihaam 7a66dd836c add faisawear images (intergration later) 2026-05-28 15:57:23 +05:00
shihaam 68dd49b90c rename fisa to fisa card 2026-05-28 15:55:49 +05:00
shihaam 76090525e1 add binga mvr usd and fiasa cards
Auto Tag on Version Change / check-version (push) Successful in 4s
2026-05-28 15:33:49 +05:00
shihaam f7fd06cdf3 Unified card settings and pay with card into 1 page and redsigned it 2026-05-28 15:28:45 +05:00
ahusan 8d09e760a8 Enhance dashboard: add attention row for blocked and overdue funds
Introduces a new attention row in the dashboard to display blocked funds and overdue financing. The row is conditionally visible based on the presence of blocked amounts or overdue totals. Updates the account display logic to show blocked amounts where applicable, ensuring users have a clear view of their financial status. Additionally, new string resources for "Blocked Funds" and "Overdue Financing" are added for localization.
2026-05-28 15:24:49 +05:00
ahusan 62ccae602d accounts list: use available balance, show blocked amount as secondary line
BML CASA rows on the accounts list were showing currentBalance (the
working/ledger balance, which includes blocked funds). Every other
balance display in the app — transfer screen, contact picker, QR pay,
dashboard totals — uses availableBalance, so the same account was
showing a different figure depending on where you looked at it.

This switches the accounts list to availableBalance for consistency,
and adds a small muted "MVR X.XX blocked" line beneath the balance
when blocked > 0 so the blocked funds are still visible at a glance.
Only BML reports a non-zero blocked amount; MIB and Fahipay rows are
unaffected.

The per-account history page header is untouched — its three-column
Available / Balance / Blocked breakdown still works as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 14:54:33 +05:00
ahusan 9011ef2f5a debug builds: separate applicationId so they coexist with release
Adds applicationIdSuffix=.debug and versionNameSuffix=-debug so a
side-loaded debug build can be installed alongside the Play/release
build without conflicting on package id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 14:53:45 +05:00
shihaam dd620763ec new feature: add launcher shortcuts
Auto Tag on Version Change / check-version (push) Successful in 5s
2026-05-28 14:06:49 +05:00
shihaam 86063d600f Fix Bug that allowed lockscreen bypass on rooted androids
Auto Tag on Version Change / check-version (push) Successful in 7s
2026-05-28 13:41:39 +05:00
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
214 changed files with 11846 additions and 2789 deletions
+1 -3
View File
@@ -1,11 +1,9 @@
services:
release:
# image: git.shihaam.dev/dockerfiles/android-builder
image: git.shihaam.dev/dockerfiles/runners/gradle
hostname: isodroid
network_mode: host
env_file: .env
volumes:
- ./release:/release
- ../../:/source
# - /root/.cache/cache-runners/gradle:/root/.gradle
- /root/.cache/cache-runners/gradle:/root/.gradle
+21
View File
@@ -87,3 +87,24 @@ jobs:
--data-binary "@${ASSET_PATH}"
echo "Uploaded asset: $ASSET_NAME"
- name: Send APK to Telegram
env:
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
TG_CHAT_ID: ${{ vars.TG_CHAT_ID }}
run: |
if [ -z "$TG_BOT_TOKEN" ] || [ -z "$TG_CHAT_ID" ]; then
echo "TG_BOT_TOKEN or TG_CHAT_ID not set, skipping Telegram upload."
exit 0
fi
APP_NAME="${{ gitea.repository }}"
APP_NAME="${APP_NAME##*/}"
TAG="${{ gitea.ref_name }}"
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
CAPTION="${APP_NAME} ${TAG}"
curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument" \
-F "chat_id=${TG_CHAT_ID}" \
-F "document=@${ASSET_PATH}" \
-F "caption=${CAPTION}"
+3 -3
View File
@@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-18T20:24:18.550107339Z">
<DropdownSelection timestamp="2026-05-22T00:11:32.873305232Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=ce61d84c" />
<DeviceId pluginId="PhysicalDevice" identifier="serial=683a9830" />
</handle>
</Target>
</DropdownSelection>
@@ -15,7 +15,7 @@
<targets>
<Target type="DEFAULT_BOOT">
<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>
</Target>
<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)
[![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)
![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
- Android 8.0+ (API 26)
- Existing accounts with MIB, BML, or Fahipay
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
## Building
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
## Download
[Download latest APK](https://git.shihaam.dev/shihaam/ISODroid/releases/latest)
## Privacy
@@ -70,3 +24,8 @@ No data ever leaves your device except the API calls to the banking services the
## 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.
## License
GNU General Public License v3.0 - See [LICENSE](LICENSE) file for details
+6 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "sh.sar.basedbank"
minSdk = 26
targetSdk = 36
versionCode = 4
versionName = "1.0.5"
versionCode = 9
versionName = "1.0.10"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -27,6 +27,10 @@ android {
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#CC0000"
android:pathData="M0,0h108v108h-108z" />
</vector>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Thijooree Debug</string>
</resources>
+4 -1
View File
@@ -10,7 +10,7 @@
<application
android:name=".BasedBankApp"
android:allowBackup="true"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
@@ -30,6 +30,9 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity>
<activity
Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

+1
View File
@@ -0,0 +1 @@
visa_bingaa.png
+1
View File
@@ -0,0 +1 @@
visa_bingaa.png
Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -16,6 +16,13 @@ import sh.sar.basedbank.util.CredentialStore
class BasedBankApp : Application() {
/**
* Set to true only after the user passes LockActivity or completes fresh login.
* Resets to false on every process restart so direct ADB/root activity launches
* cannot reach HomeActivity without re-authenticating.
*/
var isUnlocked = false
// Held in memory after successful login; cleared on logout
var accounts: List<BankAccount> = emptyList()
var fullName: String = ""
@@ -108,7 +115,11 @@ class BasedBankApp : Application() {
override fun onCreate() {
super.onCreate()
DynamicColors.applyToActivitiesIfAvailable(this)
// Only apply wallpaper-based dynamic colors in system theme mode.
// Light/dark modes use content-based accent colors applied per-activity via ThemeHelper.
DynamicColors.applyToActivitiesIfAvailable(this) { _, _ ->
getSharedPreferences("prefs", MODE_PRIVATE).getString("theme", "system") == "system"
}
val theme = getSharedPreferences("prefs", MODE_PRIVATE).getString("theme", "system")
AppCompatDelegate.setDefaultNightMode(when (theme) {
@@ -21,6 +21,8 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.databinding.ActivityLockBinding
import sh.sar.basedbank.ui.home.HomeActivity
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.ThemeHelper
import sh.sar.basedbank.BasedBankApp
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec
@@ -32,6 +34,8 @@ class LockActivity : AppCompatActivity() {
private lateinit var salt: String
private lateinit var storedHash: String
private var biometricsEnabled = false
private var autoUnlockPin = false
private var pinLength = 4
private var isVerifying = false
private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE)
@@ -43,6 +47,7 @@ class LockActivity : AppCompatActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.applyAccent(this)
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityLockBinding.inflate(layoutInflater)
@@ -52,6 +57,9 @@ class LockActivity : AppCompatActivity() {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
ta.recycle()
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets ->
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
@@ -61,6 +69,8 @@ class LockActivity : AppCompatActivity() {
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
method = prefs.getString("security_method", "pin") ?: "pin"
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 }
salt = stored.first
@@ -134,13 +144,18 @@ class LockActivity : AppCompatActivity() {
when (key) {
"" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() }
"" -> 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() {
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() {
@@ -194,15 +209,15 @@ class LockActivity : AppCompatActivity() {
if (remaining <= 0) return false
val secs = ((remaining + 999L) / 1000L).toInt()
val msg = getString(R.string.unlock_locked_out, secs)
binding.tvLockPinDots.text = msg
binding.root.postDelayed({ updateDots() }, remaining)
binding.tvPinHint.text = msg
binding.root.postDelayed({ binding.tvPinHint.text = ""; updateDots() }, remaining)
return true
}
private fun showFailure() {
val msg = failureMessage()
binding.tvLockPinDots.text = msg
binding.root.postDelayed({ updateDots() }, 1200)
binding.tvPinHint.text = msg
binding.root.postDelayed({ binding.tvPinHint.text = ""; updateDots() }, 1200)
}
private fun failureMessage(): String {
@@ -250,10 +265,23 @@ class LockActivity : AppCompatActivity() {
}
private fun proceed() {
(application as BasedBankApp).isUnlocked = true
if (intent.getBooleanExtra(EXTRA_RESUME, false)) {
finish()
} else {
startActivity(Intent(this, HomeActivity::class.java))
val store = CredentialStore(this)
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
if (!hasCredentials) {
startActivity(Intent(this, sh.sar.basedbank.ui.login.LoginActivity::class.java))
finish()
return
}
val navDest = intent.getIntExtra("nav_destination", -1)
val autoScan = intent.getBooleanExtra("auto_scan", false)
startActivity(Intent(this, HomeActivity::class.java).apply {
if (navDest != -1) putExtra("nav_destination", navDest)
if (autoScan) putExtra("auto_scan", true)
})
finish()
}
}
@@ -7,24 +7,43 @@ import sh.sar.basedbank.ui.home.HomeActivity
import sh.sar.basedbank.ui.login.LoginActivity
import sh.sar.basedbank.ui.onboarding.OnboardingActivity
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val onboardingDone = prefs.getBoolean("onboarding_done", false)
val securitySet = prefs.getString("security_method", null) != null
val store = CredentialStore(this)
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
val navDestination = when (intent?.action) {
"sh.sar.basedbank.OPEN_TRANSFER" -> R.id.nav_transfer
"sh.sar.basedbank.OPEN_SCAN_QR" -> R.id.nav_transfer
"sh.sar.basedbank.OPEN_PAY_WITH_CARD" -> R.id.nav_pay_with_card
else -> -1
}
val autoScan = intent?.action == "sh.sar.basedbank.OPEN_SCAN_QR"
val target = when {
!onboardingDone -> OnboardingActivity::class.java
!hasCredentials -> LoginActivity::class.java
securitySet -> LockActivity::class.java // proceed() → HomeActivity
else -> HomeActivity::class.java
}
startActivity(Intent(this, target))
// No lock screen configured — mark as unlocked so HomeActivity's guard passes
if (target == HomeActivity::class.java) {
(application as BasedBankApp).isUnlocked = true
}
startActivity(Intent(this, target).apply {
if (navDestination != -1) putExtra("nav_destination", navDestination)
if (autoScan) putExtra("auto_scan", true)
})
finish()
}
}
@@ -2,6 +2,7 @@ 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,
@@ -27,9 +28,19 @@ class BmlAccountClient {
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
@@ -49,6 +60,51 @@ class BmlAccountClient {
} 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,
@@ -61,6 +117,7 @@ class BmlAccountClient {
val casaAccounts = mutableListOf<BankAccount>()
val prepaidCards = mutableListOf<BankAccount>()
val loanAccounts = mutableListOf<BankAccount>()
for (i in 0 until dashboard.length()) {
val item = dashboard.getJSONObject(i)
@@ -91,17 +148,43 @@ class BmlAccountClient {
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 isVisible = item.optBoolean("account_visible", false)
if (!isVisible) continue
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 = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
profileType = cardProfileType,
productCode = productCode,
accountNumber = accountNumber,
accountBriefName = item.optString("alias").ifBlank { product },
currencyName = currency,
@@ -119,6 +202,6 @@ class BmlAccountClient {
}
}
return casaAccounts + prepaidCards
return casaAccounts + prepaidCards + loanAccounts
}
}
@@ -1,11 +1,12 @@
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 const val BML_USER_AGENT = "bml-mobile-banking/348 (POCO; Android 14; 22101320I)"
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()
@@ -4,6 +4,7 @@ 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
@@ -30,6 +31,7 @@ class BmlHistoryClient {
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)
@@ -82,6 +84,7 @@ class BmlHistoryClient {
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()
@@ -25,9 +25,9 @@ class BmlLoginFlow {
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
private val APP_USER_AGENT = "bml-mobile-banking/348 (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.44.348"
private val WEB_USER_AGENT = "Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 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 cookieJar = object : CookieJar {
@@ -310,13 +310,47 @@ class BmlLoginFlow {
val tokenJson = tokenResp.body?.string() ?: throw Exception("Empty token response")
tokenResp.close()
val accessToken = JSONObject(tokenJson).optString("access_token")
val tokenObj = JSONObject(tokenJson)
val accessToken = tokenObj.optString("access_token")
.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 = BmlAccountClient().fetchAccounts(session, loginTag, profileName, profileId)
return Pair(session, accounts)
}
// ─── Token refresh ───────────────────────────────────────────────────────
/**
* Uses the saved refresh token to obtain a new access token without re-login.
* Returns a new [BmlSession] with updated tokens.
*/
fun refreshSession(session: BmlSession): BmlSession {
val body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("refresh_token", session.refreshToken)
.add("client_id", CLIENT_ID)
.add("Device-ID", session.deviceId)
.add("User-Agent", APP_USER_AGENT)
.add("x-app-version", APP_VERSION)
.build()
val resp = newBmlApiClient().newCall(
Request.Builder().url("$BASE_URL/oauth/token").post(body)
.header("User-Agent", WEB_USER_AGENT).build()
).execute()
val json = resp.body?.string() ?: throw Exception("Empty refresh response")
resp.close()
val obj = JSONObject(json)
val newAccess = obj.optString("access_token").takeIf { it.isNotBlank() }
?: throw Exception("Token refresh failed")
val newRefresh = obj.optString("refresh_token", "").ifBlank { session.refreshToken }
val expiresIn = obj.optLong("expires_in", 0L)
val expiresAt = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000L else 0L
return BmlSession(accessToken = newAccess, deviceId = session.deviceId, refreshToken = newRefresh, expiresAt = expiresAt)
}
// ─── Parsing ──────────────────────────────────────────────────────────────
/**
@@ -4,8 +4,12 @@ import sh.sar.basedbank.api.models.BankAccount
data class BmlSession(
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,
@@ -50,6 +54,34 @@ data class BmlTransferResult(
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(
val type: String,
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
)
}
}
}
}
@@ -17,7 +17,8 @@ class BmlTransferClient {
amount: Double,
transferType: String,
currency: String,
bank: String? = null
bank: String? = null,
channel: String = "token"
): Boolean {
val jo = JSONObject().apply {
put("debitAccount", debitAccount)
@@ -25,7 +26,7 @@ class BmlTransferClient {
put("debitAmount", amount)
put("transfertype", transferType)
put("currency", currency)
put("channel", "token")
put("channel", channel)
if (bank != null) put("bank", bank)
}
val request = Request.Builder()
@@ -55,7 +56,8 @@ class BmlTransferClient {
currency: String,
otp: String,
remarks: String = "",
bank: String? = null
bank: String? = null,
channel: String = "token"
): BmlTransferResult {
val jo = JSONObject().apply {
put("debitAccount", debitAccount)
@@ -63,7 +65,7 @@ class BmlTransferClient {
put("debitAmount", amount)
put("transfertype", transferType)
put("currency", currency)
put("channel", "token")
put("channel", channel)
put("otp", otp)
if (remarks.isNotBlank()) put("remarks", remarks)
if (bank != null) put("bank", bank)
@@ -4,6 +4,7 @@ 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 {
@@ -27,8 +28,10 @@ class FahipayAccountClient {
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(
@@ -47,8 +50,10 @@ class FahipayAccountClient {
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)
@@ -3,6 +3,7 @@ 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
@@ -32,8 +33,10 @@ class FahipayHistoryClient {
.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)
@@ -16,7 +16,7 @@ import java.util.concurrent.TimeUnit
class FahipayLoginFlow {
private val BASE_URL = "https://fahipay.mv"
private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android 14; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
private val UA_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 cookieStore = mutableMapOf<String, MutableList<Cookie>>()
private val cookieJar = object : CookieJar {
@@ -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
import android.os.Build
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -24,7 +25,7 @@ class MibContactsClient {
.header("Cookie", cookieHeader(session))
.header(
"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("Accept", "*/*")
@@ -1,5 +1,6 @@
package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.OkHttpClient
import okhttp3.Request
import java.util.concurrent.TimeUnit
@@ -27,7 +28,7 @@ class MibFinancingClient {
.header("Cookie", cookieHeader)
.header(
"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")
.get()
@@ -1,9 +1,11 @@
package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import java.util.concurrent.TimeUnit
class MibHistoryClient {
@@ -50,7 +52,7 @@ class MibHistoryClient {
.header("Cookie", cookieHeader(session))
.header(
"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("Accept", "*/*")
@@ -59,6 +61,7 @@ class MibHistoryClient {
.build()
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 json = try { JSONObject(bodyStr) } catch (_: Exception) { return Pair(emptyList(), 0) }
if (!json.optBoolean("success")) return Pair(emptyList(), 0)
@@ -9,6 +9,7 @@ import org.json.JSONObject
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.abs
import kotlin.random.Random
class SessionExpiredException : Exception("MIB session expired")
@@ -39,7 +40,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.addInterceptor { chain ->
.addNetworkInterceptor { chain ->
val req = chain.request().newBuilder()
.header("User-Agent", "android/1.0")
.header("Accept", "application/json")
@@ -161,14 +162,14 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
bank = "MIB",
profileName = profile.name,
profileType = profile.profileType,
cifType = profile.cifType,
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"),
blockedAmount = absBlockedAmount(a.optString("blockedAmount")),
mvrBalance = a.optString("mvrBalance"),
statusDesc = a.optString("statusDesc"),
profileImageHash = profile.customerImage,
@@ -188,6 +189,13 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
// ─── Helpers ─────────────────────────────────────────────────────────────
/** MIB returns blockedAmount as a signed decimal where negative = funds held.
* Normalize to a positive magnitude so downstream code can treat it uniformly. */
private fun absBlockedAmount(raw: String): String {
val v = raw.toDoubleOrNull() ?: return raw
return "%.2f".format(abs(v))
}
private fun initialKeyExchange(
appId: String, encKey: String, sfunc: String, key2: String? = null
): Pair<MibSession, String> {
@@ -318,14 +326,14 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
bank = "MIB",
profileName = profile.name,
profileType = profile.profileType,
cifType = profile.cifType,
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"),
blockedAmount = absBlockedAmount(a.optString("blockedAmount")),
mvrBalance = a.optString("mvrBalance"),
statusDesc = a.optString("statusDesc"),
profileImageHash = profile.customerImage,
@@ -366,6 +374,34 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
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 {
val request = Request.Builder()
.url(BASE_URL)
@@ -373,6 +409,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
.build()
val response = client.newCall(request).execute()
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")
}
@@ -46,6 +46,18 @@ data class MibIpsAccountInfo(
)
data class MibCard(
val cardId: String,
val maskedCardNumber: String,
val cardStatus: String,
val cardType: String,
val cardTypeDesc: String,
val customerId: String,
val phoneNumber: String,
val cardHolderName: String,
val loginTag: String
)
data class MibFinanceDeal(
val dealNo: String,
val productDesc: String,
@@ -1,5 +1,6 @@
package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
@@ -26,7 +27,7 @@ class MibTransferClient {
.header("Cookie", cookieHeader(session))
.header(
"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("Accept", "*/*")
@@ -68,6 +69,7 @@ class MibTransferClient {
.withWvHeaders(session)
.build()
return client.newCall(request).execute().use { response ->
if (response.code == 419) throw SessionExpiredException()
val bodyStr = response.body?.string() ?: ""
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
if (json == null || !json.optBoolean("success")) {
@@ -1,5 +1,8 @@
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.
@@ -8,7 +11,7 @@ data class BankAccount(
val bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow
val profileName: String,
val profileType: String,
val cifType: String = "", // MIB: human-readable profile category (e.g. "Individual", "Sole Propr"); empty for other banks
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,
@@ -23,6 +23,7 @@ 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.api.models.BankServerException
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.models.BankTransaction
import sh.sar.basedbank.api.mib.TransactionCache
@@ -118,6 +119,14 @@ class AccountHistoryFragment : Fragment() {
}
(activity as? HomeActivity)?.setRefreshing(true)
loadNextPage()
binding.swipeRefresh.setOnRefreshListener {
if (isLoading) {
binding.swipeRefresh.isRefreshing = false
} else {
resetAndReload()
}
}
}
override fun onResume() {
@@ -135,6 +144,24 @@ class AccountHistoryFragment : Fragment() {
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
}
private fun resetAndReload() {
allTransactions.clear()
pendingImageNames.clear()
pendingIconUrls.clear()
firstPageDone = false
fetcher = HistoryFetcher(account)
// Restore cache immediately so data stays visible while refreshing
val cached = TransactionCache.load(requireContext(), account.accountNumber)
if (cached.isNotEmpty()) {
allTransactions.addAll(cached)
filterAndDisplay()
} else {
adapter.setTransactions(emptyList())
binding.emptyView.visibility = View.GONE
}
loadNextPage()
}
private fun loadNextPage() {
if (isLoading || !fetcher.hasMore()) return
isLoading = true
@@ -146,15 +173,34 @@ class AccountHistoryFragment : Fragment() {
val app = requireActivity().application as BasedBankApp
lifecycleScope.launch {
val transactions = fetcher.fetchNextPage(app, pageSize)
val transactions = try {
fetcher.fetchNextPage(app, pageSize)
} catch (e: java.io.IOException) {
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
null
} catch (e: BankServerException) {
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_server_error, e.bankName))
null
} catch (_: Exception) {
null
}
isLoading = false
if (_binding == null) return@launch
if (!firstPageDone) {
firstPageDone = true
(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()) {
val existingIds = allTransactions.map { it.id }.toHashSet()
val newOnes = transactions.filter { it.id !in existingIds }
@@ -3,11 +3,13 @@ package sh.sar.basedbank.ui.home
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.Bitmap
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.ItemAccountBinding
import sh.sar.basedbank.databinding.ItemCardBinding
@@ -17,7 +19,11 @@ import sh.sar.basedbank.util.AccountListParser
class AccountsAdapter(
accounts: List<BankAccount>,
private val onAccountClick: (BankAccount) -> 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>() {
var onTransferClick: ((BankAccount) -> Unit)? = null
@@ -72,7 +78,7 @@ class AccountsAdapter(
else -> account.bank
}
val profileLabel = when (account.bank) {
"MIB" -> account.cifType.ifBlank { account.profileName }
"MIB" -> account.productCode.ifBlank { account.profileName }
else -> account.profileName
}
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
@@ -112,17 +118,51 @@ class AccountsAdapter(
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
RecyclerView.ViewHolder(binding.root) {
private var boundHash: String? = null
fun bind(account: BankAccount, display: AccountListDisplay) {
binding.tvAccountName.text = display.name
binding.tvAccountNumber.text = display.number
binding.tvAccountType.text = display.typeLabel
binding.tvBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
val blocked = display.blockedBalance
if (blocked != null) {
val shown = if (hideAmounts) maskAmount(blocked) else blocked
binding.tvBlocked.text = binding.root.context.getString(R.string.account_blocked_label, shown)
binding.tvBlocked.visibility = View.VISIBLE
} else {
binding.tvBlocked.visibility = View.GONE
}
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
binding.root.setOnClickListener { onAccountClick(account) }
binding.root.setOnLongClickListener {
copyToClipboard(it.context, display.number)
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)
}
}
}
}
}
@@ -1,6 +1,9 @@
package sh.sar.basedbank.ui.home
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Bundle
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -8,9 +11,15 @@ import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
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.databinding.FragmentAccountsBinding
import sh.sar.basedbank.util.ProfileImageStore
class AccountsFragment : Fragment() {
@@ -18,6 +27,7 @@ class AccountsFragment : Fragment() {
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private lateinit var adapter: AccountsAdapter
private val profileImageCache = mutableMapOf<String, Bitmap>()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentAccountsBinding.inflate(inflater, container, false)
@@ -25,9 +35,49 @@ class AccountsFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = AccountsAdapter(emptyList()) { account ->
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
}
val app = requireActivity().application as BasedBankApp
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))
}
@@ -43,8 +93,16 @@ class AccountsFragment : Fragment() {
insets
}
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
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() {
@@ -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,3 @@
package sh.sar.basedbank.ui.home
// Merged into CardsFragment
@@ -31,7 +31,9 @@ class ContactPickerAdapter(
val isSameAsFrom: Boolean = false,
val isManualEntry: Boolean = false,
val imageHash: String? = null,
val inactiveReason: String? = null
val inactiveReason: String? = null,
val balance: String? = null,
val bankLogoRes: Int? = null
) : PickerItem()
}
@@ -89,14 +91,31 @@ class ContactPickerAdapter(
binding.tvPrimary.text = item.displayName
binding.tvSecondary.text = item.subtitle
val cached = item.imageHash?.let { imageCache[it] }
if (cached != null) {
binding.ivIcon.setImageBitmap(cached)
if (item.balance != null) {
binding.tvBalance.text = item.balance
binding.tvBalance.visibility = android.view.View.VISIBLE
} else {
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.tvBalance.visibility = android.view.View.GONE
}
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
@@ -24,7 +24,11 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibContactsClient
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.bmlapi.BmlCardParser
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
class ContactPickerSheetFragment : BottomSheetDialogFragment() {
@@ -145,8 +149,9 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
viewModel.contacts.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>) {
@@ -183,6 +188,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
private fun buildPageItems(tabTag: String?): List<ContactPickerAdapter.PickerItem> {
val search = binding.etSheetSearch.text?.toString()?.trim() ?: ""
val hide = viewModel.hideAmounts.value ?: false
val items = mutableListOf<ContactPickerAdapter.PickerItem>()
if (tabTag == RECENTS_TAG) {
@@ -209,11 +215,11 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
val fromCurrency = fromAccount?.currencyName ?: ""
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) {
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
val cards = 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" || it.profileType == "BML_DEBIT" }
val filteredRegular = if (search.isBlank()) regularAccounts else regularAccounts.filter {
it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
@@ -223,16 +229,29 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
for (acc in filteredRegular) {
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
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(
accountNumber = acc.accountNumber,
displayName = acc.accountBriefName,
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}",
subtitle = acc.accountNumber,
colorHex = "#FE860E",
isSameAsFrom = isSame,
imageHash = acc.profileImageHash,
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
inactiveReason = if (isSame) null
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)
val isSame = acc.accountNumber == fromAccountNumber
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(
accountNumber = acc.accountNumber,
displayName = acc.accountBriefName,
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}",
subtitle = acc.accountNumber,
colorHex = "#FE860E",
isSameAsFrom = isSame,
imageHash = acc.profileImageHash,
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
inactiveReason = if (isSame) null
else if (!isActive) acc.statusDesc
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,6 +315,17 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
private fun fetchImage(hash: String) {
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
lifecycleScope.launch(Dispatchers.IO) {
try {
@@ -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? =
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() {
super.onDestroyView()
_binding = null
@@ -134,6 +134,11 @@ class ContactsFragment : Fragment() {
(activity as? HomeActivity)?.loadAllContacts()
binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefresh()
binding.swipeRefresh.isRefreshing = false
}
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
rebuildPager(cats)
}
@@ -1,19 +1,32 @@
package sh.sar.basedbank.ui.home
import android.app.Activity
import android.content.Context
import android.content.Intent
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.content.ContextCompat
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.LinearSnapHelper
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlForeignLimit
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.CredentialStore
import kotlin.math.abs
import sh.sar.basedbank.databinding.FragmentDashboardBinding
import sh.sar.basedbank.databinding.ItemForeignLimitBinding
@@ -23,21 +36,83 @@ class DashboardFragment : Fragment() {
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 = FragmentDashboardBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) }
viewModel.accounts.observe(viewLifecycleOwner) {
updateBalances(it)
updateAttentionRow()
}
viewModel.financing.observe(viewLifecycleOwner) {
updatePendingFinances()
updateAttentionRow()
}
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) {
updatePendingFinances()
updateAttentionRow()
}
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
viewModel.hideAmounts.observe(viewLifecycleOwner) {
updateBalances(viewModel.accounts.value ?: emptyList())
updatePendingFinances(viewModel.financing.value ?: emptyList())
updatePendingFinances()
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
updateAttentionRow()
}
binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefresh()
binding.swipeRefresh.isRefreshing = false
}
binding.cardPendingFinances.setOnClickListener {
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
}
binding.cardOverdue.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
val defaultNum = CredentialStore(requireContext()).getDefaultCardAccountNumber()
val ordered = if (defaultNum != null) {
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
if (def != null) listOf(def) + all.filter { it !== def } else all
} else all
cardAdapter.update(ordered)
binding.sectionCards.visibility = if (ordered.isNotEmpty()) View.VISIBLE else View.GONE
}
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)
@@ -57,6 +132,12 @@ class DashboardFragment : Fragment() {
private fun refreshQuickActions() {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val isBottom = prefs.getBoolean("bottom_nav", false)
if (isBottom) {
binding.buttonBar.visibility = View.GONE
return
}
binding.buttonBar.visibility = View.VISIBLE
val ids = NavCustomization.getQuickActions(prefs)
listOf(binding.btnQuickAction1, binding.btnQuickAction2).forEachIndexed { i, btn ->
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == ids[i] }
@@ -70,61 +151,244 @@ class DashboardFragment : Fragment() {
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 = accounts
val mvrTotal = nonCreditAccounts
.filter { it.currencyName.equals("MVR", ignoreCase = true) }
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
val usdTotal = accounts
val usdTotal = nonCreditAccounts
.filter { it.currencyName.equals("USD", ignoreCase = true) }
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
binding.tvMvrBalance.text = "MVR %,.2f".format(mvrTotal)
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>) {
val hide = viewModel.hideAmounts.value ?: false
binding.containerForeignLimits.removeAllViews()
var cardIndex = 0
for (entry in entries) {
for (limit in entry.limits) {
val idx = cardIndex++
val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false)
card.tvLimitUserName.text = entry.userName.ifBlank { "BML" }
card.tvLimitType.text = limit.type
if (hide) {
card.tvLimitGeneral.text = "USD ••••••"
card.tvLimitMedical.text = "USD ••••••"
card.tvLimitAtm.text = if (!limit.isAtmEnabled) "USD •••••• · Disabled" else "USD ••••••"
card.tvLimitEcom.text = "USD ••••••"
card.tvLimitPos.text = if (!limit.isPosEnabled) "USD •••••• · Disabled" else "USD ••••••"
} else {
card.tvLimitGeneral.text = "USD %,.0f / %,.0f".format(limit.generalRemaining, limit.generalCap)
card.tvLimitMedical.text = "USD %,.0f".format(limit.medicalRemaining)
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)
bindLimitCard(card, entry.userName, limit, hide, idx in expandedLimits)
card.root.setOnClickListener {
if (idx in expandedLimits) expandedLimits.remove(idx) else expandedLimits.add(idx)
updateForeignLimits(entries)
}
binding.containerForeignLimits.addView(card.root)
}
}
}
private fun updatePendingFinances(deals: List<MibFinanceDeal>) {
private fun bindLimitCard(
card: ItemForeignLimitBinding,
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 updateAttentionRow() {
val hide = viewModel.hideAmounts.value ?: false
binding.tvPendingFinances.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(deals.sumOf { it.outstandingAmount })
val accounts = viewModel.accounts.value ?: emptyList()
// Blocked: sum across CASA-style accounts (exclude cards and loans) per currency.
val blockedByCurrency = accounts
.filter { it.profileType != "BML_CREDIT" && it.profileType != "BML_PREPAID" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" }
.mapNotNull { acc ->
val v = acc.blockedAmount.replace(",", "").toDoubleOrNull() ?: 0.0
if (v > 0.0) acc.currencyName.uppercase() to v else null
}
.groupBy({ it.first }, { it.second })
.mapValues { (_, vs) -> vs.sum() }
val blockedMvr = blockedByCurrency["MVR"] ?: 0.0
val blockedUsd = blockedByCurrency["USD"] ?: 0.0
val blockedTotal = blockedByCurrency.values.sum()
if (blockedMvr > 0.0) {
binding.tvBlockedMvr.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(blockedMvr)
binding.cardBlockedMvr.visibility = View.VISIBLE
} else {
binding.cardBlockedMvr.visibility = View.GONE
}
if (blockedUsd > 0.0) {
binding.tvBlockedUsd.text = if (hide) "USD ••••••" else "USD %,.2f".format(blockedUsd)
binding.cardBlockedUsd.visibility = View.VISIBLE
} else {
binding.cardBlockedUsd.visibility = View.GONE
}
binding.rowBlocked.visibility = if (blockedTotal > 0.0) View.VISIBLE else View.GONE
// Overdue: MIB finance deals + BML loan details (assumed MVR — matches existing Pending Finances).
val mibOverdue = (viewModel.financing.value ?: emptyList()).sumOf { it.overdueAmount }
val bmlOverdue = (viewModel.bmlLoanDetails.value ?: emptyMap()).values.sumOf { it.overdueAmount }
val overdueTotal = mibOverdue + bmlOverdue
if (overdueTotal > 0.0) {
binding.tvOverdueTotal.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(overdueTotal)
binding.cardOverdue.visibility = View.VISIBLE
} else {
binding.cardOverdue.visibility = View.GONE
}
binding.rowAttention.visibility = if (overdueTotal > 0.0) View.VISIBLE else View.GONE
}
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() {
super.onDestroyView()
_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 = CardsFragment.formatMasked(item.card.maskedCardNumber)
val assetPath = CardsFragment.cardImageAsset(item.card)
if (assetPath != null) CardsFragment.loadCardImage(ivCardImage, assetPath)
else ivCardImage.setImageDrawable(null)
CardsFragment.bindCardStatus(tvCardStatus, CardsFragment.mibCardStatusLabel(item.card.cardStatus))
}
is CardItem.Bml -> {
tvCardOwner.text = item.account.accountBriefName
tvCardNumber.text = CardsFragment.formatMasked(item.account.accountNumber)
CardsFragment.loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
CardsFragment.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,56 +5,97 @@ import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
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.MibFinancingClient
import sh.sar.basedbank.databinding.ItemBmlLoanBinding
import sh.sar.basedbank.databinding.ItemFinanceDealBinding
import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Locale
import kotlin.math.abs
import kotlin.math.ceil
class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
RecyclerView.Adapter<FinancingAdapter.ViewHolder>() {
class FinancingAdapter(mibDeals: List<MibFinanceDeal>) :
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 amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
minimumFractionDigits = 2
maximumFractionDigits = 2
}
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)
fun setHideAmounts(hide: Boolean) {
if (hideAmounts == hide) return
hideAmounts = hide
notifyDataSetChanged()
}
private val expandedPositions = mutableSetOf<Int>()
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
minimumFractionDigits = 2
maximumFractionDigits = 2
}
private val inputDateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
private val outputDateFmt = SimpleDateFormat("d MMM yyyy", Locale.US)
fun updateDeals(newDeals: List<MibFinanceDeal>) {
deals = newDeals
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>) {
expandedPositions.clear()
items = mibDeals.map { Item.Mib(it) } + bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val binding = ItemFinanceDealBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return ViewHolder(binding)
// Legacy compatibility — used on initial empty construction
fun updateDeals(newDeals: List<MibFinanceDeal>) {
expandedPositions.clear()
val bmlItems = items.filterIsInstance<Item.Bml>()
items = newDeals.map { Item.Mib(it) } + bmlItems
notifyDataSetChanged()
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(deals[position], position in expandedPositions)
holder.binding.root.setOnClickListener {
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
if (pos in expandedPositions) expandedPositions.remove(pos) else expandedPositions.add(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) {
fun bind(deal: MibFinanceDeal, expanded: Boolean) {
@@ -69,25 +110,22 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
binding.tvPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.paidAmount)}"
binding.tvUnpaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.outstandingAmount)}"
// Progress bar
val progress = if (deal.dealAmount > 0)
((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100)
else 0
binding.progressBar.progress = if (hide) 0 else progress
// Completion estimate
binding.tvCompletion.text = completionText(deal, ctx)
binding.tvCompletion.text = mibCompletionText(deal, ctx)
// Expanded details
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
binding.dividerDetails.visibility = detailsVisible
binding.detailsGroup.visibility = detailsVisible
if (expanded) {
binding.tvDealDate.text = formatDate(deal.dealDate)
binding.tvDealDate.text = formatMibDate(deal.dealDate)
binding.tvInstallment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.installmentAmount)}"
binding.tvNumInstallments.text = deal.noOfInstallments.toString()
binding.tvLastPaidDate.text = formatDate(deal.lastPaidDate)
binding.tvLastPaidDate.text = formatMibDate(deal.lastPaidDate)
binding.tvLastPayAmount.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.lastPayAmount)}"
if (deal.overdueAmount > 0) {
@@ -99,7 +137,7 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
}
}
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)
val remaining = MibFinancingClient.remainingMonths(deal)
if (remaining <= 0) return ctx.getString(R.string.financing_completion_done)
@@ -109,12 +147,84 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
return ctx.getString(R.string.financing_completion_fmt, month)
}
private fun formatDate(raw: String): String {
private fun formatMibDate(raw: String): String {
return try {
outputDateFmt.format(inputDateFmt.parse(raw)!!)
} catch (_: Exception) {
raw.take(10)
}
outputDateFmt.format(mibDateFmt.parse(raw)!!)
} catch (_: Exception) { 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
}
}
@@ -10,6 +10,9 @@ import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
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
class FinancingFragment : Fragment() {
@@ -19,6 +22,9 @@ class FinancingFragment : Fragment() {
private val viewModel: HomeViewModel by activityViewModels()
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 {
_binding = FragmentFinancingBinding.inflate(inflater, container, false)
return binding.root
@@ -38,15 +44,37 @@ class FinancingFragment : Fragment() {
insets
}
binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefresh()
binding.swipeRefresh.isRefreshing = false
}
viewModel.accounts.observe(viewLifecycleOwner) { rebuildAdapter() }
viewModel.financing.observe(viewLifecycleOwner) { deals ->
adapter.updateDeals(deals)
binding.recyclerView.visibility = if (deals.isEmpty()) View.GONE else View.VISIBLE
binding.emptyView.visibility = if (deals.isEmpty()) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE
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() {
super.onResume()
requireActivity().title = getString(R.string.nav_finances)
@@ -14,8 +14,10 @@ import android.widget.Toast
import sh.sar.basedbank.ui.home.NavCustomization
import androidx.appcompat.app.AlertDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.activity.OnBackPressedCallback
import androidx.activity.viewModels
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.core.view.GravityCompat
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
@@ -36,10 +38,12 @@ import okhttp3.RequestBody.Companion.toRequestBody
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.AuthExpiredException
import sh.sar.basedbank.api.models.BankServerException
import java.util.concurrent.ConcurrentLinkedQueue
import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlContactsClient
import sh.sar.basedbank.api.bml.BmlForeignLimitsClient
import sh.sar.basedbank.api.bml.BmlLoanDetail
import sh.sar.basedbank.api.bml.BmlProfile
import sh.sar.basedbank.api.bml.BmlSession
import sh.sar.basedbank.api.fahipay.FahipayAccountClient
@@ -53,6 +57,7 @@ import sh.sar.basedbank.ui.login.LoginActivity
import sh.sar.basedbank.api.models.BankContact
import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibCardsClient
import sh.sar.basedbank.api.mib.MibFinancingClient
import sh.sar.basedbank.api.mib.MibProfile
import sh.sar.basedbank.api.mib.MibSession
@@ -60,8 +65,10 @@ import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.util.AccountCache
import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.FinancingCache
import sh.sar.basedbank.util.ForeignLimitsCache
import sh.sar.basedbank.util.ThemeHelper
class HomeActivity : AppCompatActivity() {
@@ -70,6 +77,10 @@ class HomeActivity : AppCompatActivity() {
private lateinit var toggle: ActionBarDrawerToggle
private var suppressBottomNavCallback = false
private var backPressedOnce = false
private val backPressHandler = Handler(Looper.getMainLooper())
private val resetBackPress = Runnable { backPressedOnce = false }
private val autolockHandler = Handler(Looper.getMainLooper())
private var warningDialog: AlertDialog? = null
private var countdownTimer: CountDownTimer? = null
@@ -96,6 +107,7 @@ class HomeActivity : AppCompatActivity() {
}
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.applyAccent(this)
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityHomeBinding.inflate(layoutInflater)
@@ -108,6 +120,21 @@ class HomeActivity : AppCompatActivity() {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
ta.recycle()
// Auth guard: HomeActivity must only be reachable after LockActivity or fresh login.
// Using loadSecurityHash() (EncryptedSharedPreferences) instead of plain prefs so
// a rooted device cannot bypass this by editing security_method in plain prefs.
val app = application as BasedBankApp
if (CredentialStore(this).loadSecurityHash() != null && !app.isUnlocked) {
startActivity(
android.content.Intent(this, sh.sar.basedbank.LockActivity::class.java)
)
finish()
return
}
setSupportActionBar(binding.toolbar)
toggle = ActionBarDrawerToggle(
@@ -137,6 +164,7 @@ class HomeActivity : AppCompatActivity() {
R.id.nav_finances -> FinancingFragment()
R.id.nav_otp -> OtpFragment()
R.id.nav_settings -> SettingsFragment()
R.id.nav_pay_with_card -> CardsFragment()
else -> null
}
if (frag != null) show(frag)
@@ -153,7 +181,6 @@ class HomeActivity : AppCompatActivity() {
}
// Load data
val app = application as BasedBankApp
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
// Came from fresh manual login — accounts ready, rest fetched in background
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts
@@ -168,8 +195,12 @@ class HomeActivity : AppCompatActivity() {
byLoginId.forEach { (loginId, accs) -> AccountCache.saveFahipay(this, loginId, accs) }
}
val cachedCards = CardsCache.load(this)
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
val cachedFinancing = FinancingCache.load(this)
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
if (cachedBmlLoans.isNotEmpty()) viewModel.bmlLoanDetails.value = cachedBmlLoans
val cachedLimits = ForeignLimitsCache.load(this)
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
@@ -178,6 +209,8 @@ class HomeActivity : AppCompatActivity() {
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
}
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
refreshBmlLoanDetails()
triggerRefreshCards()
} else {
// Came from lock screen — show caches immediately, refresh everything in background
val store = CredentialStore(this)
@@ -186,8 +219,12 @@ class HomeActivity : AppCompatActivity() {
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
val merged = cachedMib + cachedBml + cachedFahipay
if (merged.isNotEmpty()) viewModel.accounts.value = merged
val cachedCards = CardsCache.load(this)
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
val cachedFinancing = FinancingCache.load(this)
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
if (cachedBmlLoans.isNotEmpty()) viewModel.bmlLoanDetails.value = cachedBmlLoans
val cachedLimits = ForeignLimitsCache.load(this)
if (cachedLimits.isNotEmpty()) viewModel.bmlLimits.value = cachedLimits
@@ -196,12 +233,59 @@ class HomeActivity : AppCompatActivity() {
viewModel.hideAmounts.value = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_amounts", false)
// Show dashboard on first create
// Show dashboard on first create, or navigate to shortcut destination
if (savedInstanceState == null) {
show(DashboardFragment())
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
val navDest = intent.getIntExtra("nav_destination", -1)
val autoScan = intent.getBooleanExtra("auto_scan", false)
if (navDest != -1) {
val fragment = if (autoScan && navDest == R.id.nav_transfer)
TransferFragment.newInstanceWithAutoScan()
else null
navigateTo(navDest, fragment)
} else {
show(DashboardFragment())
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
}
}
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
// Close drawer if open (drawer-nav mode)
if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
binding.drawerLayout.closeDrawers()
return
}
// Let CardsFragment handle back if in manage mode
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
if (currentFrag is CardsFragment && currentFrag.onBackPressed()) return
// Pop fragment back stack if there's anything on it (e.g. showWithBackStack)
if (supportFragmentManager.backStackEntryCount > 0) {
supportFragmentManager.popBackStack()
return
}
// In bottom nav mode, pressing back navigates up the hierarchy
val isBottomNav = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
if (isBottomNav && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
// Sub-page reached via More (e.g. Settings, Activities) — go back to More
if (binding.bottomNavigation.selectedItemId == R.id.nav_more && currentFrag !is MoreFragment) {
show(MoreFragment())
return
}
binding.bottomNavigation.selectedItemId = R.id.nav_dashboard
return
}
// At top level — require double-tap to exit
if (backPressedOnce) {
backPressHandler.removeCallbacks(resetBackPress)
finish()
} else {
backPressedOnce = true
Toast.makeText(this@HomeActivity, R.string.press_back_to_exit, Toast.LENGTH_SHORT).show()
backPressHandler.postDelayed(resetBackPress, 2000)
}
}
})
// Keep all MIB sessions alive every 25 seconds while the app is in the foreground
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
@@ -301,6 +385,7 @@ fun applyNavLabelVisibility() {
R.id.nav_finances -> FinancingFragment()
R.id.nav_otp -> OtpFragment()
R.id.nav_settings -> SettingsFragment()
R.id.nav_pay_with_card -> CardsFragment()
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
}
show(dest)
@@ -324,6 +409,19 @@ fun applyNavLabelVisibility() {
}
}
fun triggerRefresh() {
autoRefresh(CredentialStore(this))
}
fun triggerRefreshFinancing() {
val app = application as BasedBankApp
for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
}
refreshBmlLoanDetails()
}
fun setRefreshing(visible: Boolean) {
binding.refreshIndicator.visibility = if (visible) View.VISIBLE else View.GONE
}
@@ -337,11 +435,12 @@ fun applyNavLabelVisibility() {
override fun onResume() {
super.onResume()
// Returning from LockActivity — skip the elapsed check and reset state.
// Returning from LockActivity — refresh sessions since they may have expired.
if (isLocked) {
isLocked = false
pauseTime = 0L
resetAutolockTimer()
autoRefresh(CredentialStore(this))
return
}
// If we were away long enough to have hit the autolock timeout (e.g. while
@@ -354,6 +453,9 @@ fun applyNavLabelVisibility() {
lock()
return
}
if (elapsed > 45_000L) {
autoRefresh(CredentialStore(this))
}
}
resetAutolockTimer()
}
@@ -417,9 +519,8 @@ fun applyNavLabelVisibility() {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.toolbar_menu, menu)
val eyeEnabled = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_sensitive_info", false)
val eyeItem = menu.findItem(R.id.action_hide_amounts)
eyeItem?.isVisible = eyeEnabled
eyeItem?.isVisible = true
val hidden = viewModel.hideAmounts.value ?: false
eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility)
return true
@@ -427,14 +528,24 @@ fun applyNavLabelVisibility() {
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == R.id.action_lock) {
lock()
val avd = getDrawable(R.drawable.avd_lock) as? android.graphics.drawable.AnimatedVectorDrawable
if (avd != null) {
item.icon = avd
avd.start()
Handler(Looper.getMainLooper()).postDelayed({ lock() }, 200)
} else {
lock()
}
return true
}
if (item.itemId == R.id.action_hide_amounts) {
val newHidden = !(viewModel.hideAmounts.value ?: false)
viewModel.hideAmounts.value = newHidden
getSharedPreferences("prefs", MODE_PRIVATE).edit().putBoolean("hide_amounts", newHidden).apply()
invalidateOptionsMenu()
val avd = getDrawable(if (newHidden) R.drawable.avd_hide_amounts else R.drawable.avd_show_amounts)
as? android.graphics.drawable.AnimatedVectorDrawable
item.icon = avd
avd?.start()
return true
}
return super.onOptionsItemSelected(item)
@@ -462,14 +573,25 @@ fun applyNavLabelVisibility() {
autoRefresh(store)
}
fun showConnectivityBanner(message: String) {
binding.connectivityBanner.text = message
binding.connectivityBanner.visibility = View.VISIBLE
}
fun hideConnectivityBanner() {
binding.connectivityBanner.visibility = View.GONE
}
private fun autoRefresh(store: CredentialStore) {
val mibLoginIds = store.getMibLoginIds()
val bmlLoginIds = store.getBmlLoginIds()
val fahipayLoginIds = store.getFahipayLoginIds()
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) return
binding.refreshIndicator.visibility = View.VISIBLE
hideConnectivityBanner()
lifecycleScope.launch {
val refreshErrors = ConcurrentLinkedQueue<String>()
// One async job per MIB login, all run in parallel
val mibJobs = mibLoginIds.mapNotNull { loginId ->
val creds = store.loadMibCredentials(loginId) ?: return@mapNotNull null
@@ -483,39 +605,84 @@ fun applyNavLabelVisibility() {
app.mibLoginFlows[loginId] = flow
store.saveMibProfiles(loginId, flow.lastProfiles)
accounts
} catch (_: Exception) { AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" } }
} catch (e: java.io.IOException) {
refreshErrors.add("NO_INTERNET")
AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" }
} catch (e: BankServerException) {
refreshErrors.add("SERVER:${e.bankName}")
AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" }
} catch (_: Exception) {
AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" }
}
}
}
// One async job per BML login, all run in parallel
val bmlJobs = bmlLoginIds.mapNotNull { loginId ->
val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null
val bmlJobs = bmlLoginIds.map { loginId ->
loginId to async(Dispatchers.IO) {
val loginTag = "bml_$loginId"
val app = application as BasedBankApp
val savedProfiles = store.loadBmlProfiles(loginId)
val allAccounts = mutableListOf<BankAccount>()
var anyExpired = savedProfiles.isEmpty()
// Try each saved profile's cached session
for (profile in savedProfiles) {
val saved = store.loadBmlProfileSession(profile.profileId)
if (saved != null) {
try {
val session = BmlSession(saved.first, saved.second)
val accounts = BmlAccountClient().fetchAccounts(session, loginTag, profile.name, profile.profileId)
app.bmlSessions[profile.profileId] = session
allAccounts += accounts
} catch (_: AuthExpiredException) { anyExpired = true
} catch (_: Exception) { anyExpired = true }
} else {
anyExpired = true
}
}
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
// Also try legacy single-profile session token (pre-multi-profile installs)
val bmlClient = BmlAccountClient()
for (profile in savedProfiles) {
val saved = store.loadBmlProfileSession(profile.profileId)
val refreshToken = store.loadBmlProfileRefreshToken(profile.profileId)
if (saved == null) {
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId }
continue
}
val expiresAt = store.loadBmlProfileExpiresAt(profile.profileId)
val tokenKnownExpired = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
suspend fun fetchWithSession(session: BmlSession) {
bmlClient.checkProfile(session)
val accounts = bmlClient.fetchAccounts(session, loginTag, profile.name, profile.profileId)
app.bmlSessions[profile.profileId] = session
allAccounts += accounts
}
suspend fun tryRefresh() {
if (refreshToken == null) throw Exception("No refresh token")
val oldSession = BmlSession(saved.first, saved.second, refreshToken)
val newSession = app.bmlFlowFor(loginId).refreshSession(oldSession)
store.saveBmlProfileSession(profile.profileId, newSession.accessToken, newSession.deviceId)
if (newSession.refreshToken.isNotBlank())
store.saveBmlProfileRefreshToken(profile.profileId, newSession.refreshToken)
if (newSession.expiresAt > 0)
store.saveBmlProfileExpiresAt(profile.profileId, newSession.expiresAt)
fetchWithSession(newSession)
}
try {
if (tokenKnownExpired) {
tryRefresh()
} else {
try {
fetchWithSession(BmlSession(saved.first, saved.second))
} catch (_: AuthExpiredException) {
tryRefresh()
}
}
} catch (e: java.io.IOException) {
refreshErrors.add("NO_INTERNET")
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId }
} catch (e: BankServerException) {
refreshErrors.add("SERVER:${e.bankName}")
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId }
} catch (_: Exception) {
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId }
}
}
// Legacy single-profile session (pre-multi-profile installs)
if (savedProfiles.isEmpty()) {
val legacyToken = store.loadBmlSession(loginId)
if (legacyToken != null) {
@@ -524,47 +691,17 @@ fun applyNavLabelVisibility() {
val accounts = BmlAccountClient().fetchAccounts(session, loginTag)
app.bmlSessions[loginId] = session
allAccounts += accounts
anyExpired = false
} catch (_: AuthExpiredException) { anyExpired = true
} catch (_: Exception) { anyExpired = true }
}
}
if (anyExpired || allAccounts.isEmpty()) {
// Re-authenticate to refresh personal profile sessions
try {
val flow = app.bmlFlowFor(loginId)
val profiles = flow.login(creds.username, creds.password, creds.otpSeed)
store.saveBmlProfiles(loginId, profiles)
app.bmlProfilesMap[loginId] = profiles
for (profile in profiles) {
if (profile.profileType == "business") {
// Can't activate business profiles without user OTP — use cached
val cached = AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId }
if (allAccounts.none { it.profileId == profile.profileId })
allAccounts += cached
continue
}
try {
val result = flow.activateProfile(profile, loginTag)
if (result is BmlActivationResult.Success) {
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
app.bmlSessions[profile.profileId] = result.session
allAccounts.removeAll { it.profileId == profile.profileId }
allAccounts += result.accounts
}
} catch (_: Exception) {
if (allAccounts.none { it.profileId == profile.profileId }) {
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId }
}
}
}
} catch (_: Exception) {
if (allAccounts.isEmpty())
} catch (e: java.io.IOException) {
refreshErrors.add("NO_INTERNET")
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
} catch (e: BankServerException) {
refreshErrors.add("SERVER:${e.bankName}")
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
} catch (_: Exception) {
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
}
} else {
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
}
}
@@ -611,6 +748,12 @@ fun applyNavLabelVisibility() {
app.fahipaySessions[loginId] = session
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
accounts
} catch (e: java.io.IOException) {
refreshErrors.add("NO_INTERNET")
AccountCache.loadFahipay(this@HomeActivity, loginId)
} catch (e: BankServerException) {
refreshErrors.add("SERVER:${e.bankName}")
AccountCache.loadFahipay(this@HomeActivity, loginId)
} catch (_: Exception) {
AccountCache.loadFahipay(this@HomeActivity, loginId)
}
@@ -630,11 +773,32 @@ fun applyNavLabelVisibility() {
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts())
binding.refreshIndicator.visibility = View.GONE
val noInternet = refreshErrors.any { it == "NO_INTERNET" }
val serverErrors = refreshErrors.filter { it.startsWith("SERVER:") }
.map { it.removePrefix("SERVER:") }.distinct()
when {
noInternet -> showConnectivityBanner(getString(R.string.connectivity_no_internet))
serverErrors.isNotEmpty() -> showConnectivityBanner(
getString(R.string.connectivity_server_error, serverErrors.joinToString(", "))
)
else -> hideConnectivityBanner()
}
val errors = mutableSetOf<String>()
if (noInternet) errors.add("NO_INTERNET")
serverErrors.forEach { errors.add(it.uppercase()) }
viewModel.connectivityErrors.postValue(errors)
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
}
refreshBmlLoanDetails()
for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshMibCards(loginId, session, profiles)
}
}
}
@@ -872,6 +1036,70 @@ fun applyNavLabelVisibility() {
}
}
private fun refreshBmlLoanDetails() {
val app = application as BasedBankApp
val loanAccounts = app.bmlAccounts.filter { it.profileType == "BML_LOAN" }
if (loanAccounts.isEmpty()) return
lifecycleScope.launch {
try {
val details = withContext(Dispatchers.IO) {
val map = mutableMapOf<String, BmlLoanDetail>()
for (acc in loanAccounts) {
val session = app.bmlSessionFor(acc) ?: continue
try {
val detail = BmlAccountClient().fetchLoanDetail(session, acc.internalId)
if (detail != null) map[acc.internalId] = detail
} catch (_: Exception) { /* keep existing */ }
}
map
}
if (details.isNotEmpty()) {
val merged = (viewModel.bmlLoanDetails.value ?: emptyMap()) + details
FinancingCache.saveBmlLoans(this@HomeActivity, merged)
viewModel.bmlLoanDetails.postValue(merged)
}
} catch (_: Exception) { /* keep cached data */ }
}
}
fun triggerRefreshCards() {
val app = application as BasedBankApp
for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
refreshMibCards(loginId, session, profiles)
}
}
private fun refreshMibCards(loginId: String, session: MibSession, profiles: List<MibProfile>) {
if (profiles.isEmpty()) return
val flow = (application as BasedBankApp).mibFlowFor(loginId)
val client = MibCardsClient()
lifecycleScope.launch {
try {
val cards = withContext(Dispatchers.IO) {
val result = mutableListOf<sh.sar.basedbank.api.mib.MibCard>()
val seen = mutableSetOf<String>()
for (profile in profiles) {
try {
flow.switchProfile(session, profile)
for (card in client.fetchCards(session, "mib_$loginId")) {
if (seen.add(card.cardId)) result += card
}
} catch (_: Exception) { }
}
result
}
if (cards.isNotEmpty()) {
val existing = viewModel.mibCards.value?.toMutableList() ?: mutableListOf()
existing.removeAll { it.loginTag == "mib_$loginId" }
existing += cards
viewModel.mibCards.postValue(existing)
CardsCache.save(this@HomeActivity, existing)
}
} catch (_: Exception) { }
}
}
private fun refreshFinancing(loginId: String, session: MibSession, profiles: List<MibProfile>) {
if (profiles.isEmpty()) return
val flow = (application as BasedBankApp).mibFlowFor(loginId)
@@ -3,19 +3,37 @@ package sh.sar.basedbank.ui.home
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import sh.sar.basedbank.api.bml.BmlForeignLimit
import sh.sar.basedbank.api.bml.BmlLoanDetail
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.mib.MibCard
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() {
val accounts = MutableLiveData<List<BankAccount>>(emptyList())
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
/** BML loan details keyed by account internalId. */
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>)
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())
}
@@ -25,6 +25,7 @@ class MoreFragment : Fragment() {
val row = inflater.inflate(R.layout.item_more_nav, list, false)
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
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) }
list.addView(row)
}
@@ -9,47 +9,55 @@ object NavCustomization {
data class NavItemDef(
val id: Int,
val key: String,
@DrawableRes val iconRes: Int,
@StringRes val titleRes: 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, R.drawable.ic_nav_accounts, R.string.nav_accounts),
NavItemDef(R.id.nav_contacts, R.drawable.ic_contacts, R.string.nav_contacts),
NavItemDef(R.id.nav_transfer, R.drawable.ic_send, R.string.transfer),
NavItemDef(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr),
NavItemDef(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities),
NavItemDef(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history),
NavItemDef(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances),
NavItemDef(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings),
NavItemDef(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp),
NavItemDef(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings),
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_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(
prefs.getInt("bottom_nav_slot_1", R.id.nav_accounts),
prefs.getInt("bottom_nav_slot_2", R.id.nav_contacts),
prefs.getInt("bottom_nav_slot_3", R.id.nav_transfer),
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()
.putInt("bottom_nav_slot_1", slots[0])
.putInt("bottom_nav_slot_2", slots[1])
.putInt("bottom_nav_slot_3", slots[2])
.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(
prefs.getInt("quick_action_1", R.id.nav_transfer),
prefs.getInt("quick_action_2", R.id.nav_pay_mv_qr),
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()
.putInt("quick_action_1", ids[0])
.putInt("quick_action_2", ids[1])
.putString("quick_action_1_key", idToKey(ids[0]) ?: "nav_transfer")
.putString("quick_action_2_key", idToKey(ids[1]) ?: "nav_pay_mv_qr")
.apply()
}
@@ -1,9 +1,14 @@
package sh.sar.basedbank.ui.home
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
@@ -42,6 +47,16 @@ class OtpFragment : Fragment() {
override fun onBindViewHolder(holder: VH, position: Int) {
holder.b.tvOtpLabel.text = entries[position].label
update(holder.b, entries[position].seed)
holder.b.root.setOnClickListener {
val code = holder.b.tvOtpCode.text.toString().replace(" ", "")
if (code.isNotEmpty()) {
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
Toast.makeText(it.context, "OTP copied", Toast.LENGTH_SHORT).show()
}
}
}
}
fun tick() {
@@ -32,11 +32,16 @@ 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.util.CredentialStore
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
import java.io.File
import java.io.FileOutputStream
@@ -49,10 +54,19 @@ class PayMvQrFragment : Fragment() {
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()
@@ -77,12 +91,16 @@ class PayMvQrFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val basePaddingBottom = view.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.updatePadding(bottom = basePaddingBottom + navBar.bottom)
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.etReference.addTextChangedListener { scheduleGenerate() }
binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() }
binding.btnShare.isEnabled = false
binding.btnSave.isEnabled = false
binding.btnShare.setOnClickListener { shareQr() }
@@ -95,7 +113,9 @@ class PayMvQrFragment : Fragment() {
private fun setupDropdown() {
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
val eligible = accounts.filter {
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT"
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" &&
it.bank != "MIB" && // TODO: MIB does not support PayMV QR
!(it.bank == "BML" && it.currencyName.contains("USD", ignoreCase = true)) // TODO: BML USD not supported by MMA
}
val adapter = QrAccountAdapter(requireContext(), eligible)
binding.actvAccount.setAdapter(adapter)
@@ -130,8 +150,28 @@ class PayMvQrFragment : Fragment() {
?.let { "%.2f".format(it) }
val ctx = requireContext()
val includePhone = binding.switchIncludePhone.isChecked
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
val store = CredentialStore(ctx)
val mobile = if (includePhone) {
when (account.bank) {
"BML" -> store.loadBmlUserProfile(loginId)?.mobile
"FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile
else -> null
}?.let { m ->
when {
m.startsWith("+") -> m
m.length == 7 -> "+960$m"
else -> m
}
}
} else null
val purpose = binding.etReference.text?.toString()?.trim()
?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default)
val bmp = withContext(Dispatchers.Default) {
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted)
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted, mobile, purpose)
renderQrCard(ctx, account, payload, amountFormatted)
}
if (_binding == null) return
@@ -149,7 +189,9 @@ class PayMvQrFragment : Fragment() {
accountNumber: String,
accountName: String,
acquirer: String,
amountStr: String?
amountStr: String?,
mobile: String?,
purpose: String
): String {
fun tlv(tag: String, value: String): String {
val len = value.length
@@ -159,17 +201,30 @@ class PayMvQrFragment : Fragment() {
val poi = tlv("01", "11")
val sub00 = tlv("00", "mv.favara.mpqr")
val sub01 = tlv("01", acquirer)
val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app
val sub03 = tlv("03", accountNumber)
val sub05 = if (!mobile.isNullOrBlank()) tlv("05", mobile) else ""
val sub10 = tlv("10", "IPAY")
val merchantAcct = tlv("26", sub00 + sub01 + sub03 + sub10)
val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10)
val mcc = tlv("52", "0000")
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"
val ref = generateReference()
val addlData = tlv("62", tlv("05", ref) + tlv("08", purpose))
val timestamp = java.time.LocalDateTime.now()
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.00000"))
val tag80 = tlv("80", tlv("00", "mv.favara.mpqr") + tlv("01", timestamp))
val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + addlData + tag80 + "6304"
return prefix + crc16(prefix)
}
private fun generateReference(): String {
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return (1..9).map { chars.random() }.joinToString("")
}
private fun crc16(data: String): String {
var crc = 0xFFFF
for (c in data) {
@@ -400,9 +455,105 @@ class PayMvQrFragment : Fragment() {
}
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
b.tvDropdownBalance.text = ""
if (typeLabel.isNotBlank()) {
b.tvDropdownAccountType.text = typeLabel
b.tvDropdownAccountType.visibility = View.VISIBLE
} else {
b.tvDropdownAccountType.visibility = View.GONE
}
b.tvDropdownBalance.visibility = View.GONE
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
}
@@ -0,0 +1,589 @@
package sh.sar.basedbank.ui.home
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Color
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.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import android.view.animation.AccelerateInterpolator
import android.view.animation.DecelerateInterpolator
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.doOnNextLayout
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.databinding.FragmentCardsBinding
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import kotlin.math.abs
class CardsFragment : Fragment() {
private var _binding: FragmentCardsBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
private var cards: List<CardItem> = emptyList()
private var currentCardPosition: Int = 0
private var cardWidth: Int = 0
private var pendingQrAccountNumber: String? = null
private var isManageMode: Boolean = false
// Carousel snapshot captured on enter, used to reverse the exit animation
private var carouselCardLayoutTop = 0f // card layout top relative to contentLayout
private var carouselCardCenterX = 0f // card center X relative to contentLayout
private var carouselTextLayoutTop = 0f // tvSelectedCardType layout top relative to contentLayout
// Swipe-to-dismiss tracking
private var swipeDragStartRawY = 0f
private var swipeIsDragging = false
private lateinit var stackAdapter: CardStackAdapter
private val store by lazy { CredentialStore(requireContext()) }
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 = FragmentCardsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val screenW = resources.displayMetrics.widthPixels
val peekPx = screenW / 8
cardWidth = screenW - 2 * peekPx
stackAdapter = CardStackAdapter(cardWidth)
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
binding.rvCards.adapter = stackAdapter
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
binding.rvCards.clipToPadding = false
val snapHelper = PagerSnapHelper()
snapHelper.attachToRecyclerView(binding.rvCards)
binding.rvCards.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
applyCardScales()
}
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
if (newState == RecyclerView.SCROLL_STATE_IDLE) {
val lm = recyclerView.layoutManager ?: return
val snapView = snapHelper.findSnapView(lm) ?: return
val position = lm.getPosition(snapView)
if (position >= 0) {
currentCardPosition = position
buildDots(cards.size, position)
updateCardInfo(position)
}
applyCardScales()
}
}
})
ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { 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(0, 0, 0, (16 * resources.displayMetrics.density).toInt() + extraBottom)
insets
}
viewModel.mibCards.observe(viewLifecycleOwner) { rebuildCards() }
viewModel.accounts.observe(viewLifecycleOwner) { rebuildCards() }
val cached = CardsCache.load(requireContext())
if (cached.isNotEmpty()) {
viewModel.mibCards.value = cached
} else {
binding.loadingView.visibility = View.VISIBLE
}
(activity as? HomeActivity)?.triggerRefreshCards()
binding.btnManageCard.setOnClickListener {
setManageMode(!isManageMode)
}
// Swipe-down on the manage card to dismiss manage mode
binding.manageCardView.root.setOnTouchListener { _, event ->
if (!isManageMode) return@setOnTouchListener false
val mgr = binding.manageCardView.root
when (event.action) {
android.view.MotionEvent.ACTION_DOWN -> {
mgr.animate().cancel()
binding.tvSelectedCardType.animate().cancel()
swipeDragStartRawY = event.rawY
swipeIsDragging = false
true
}
android.view.MotionEvent.ACTION_MOVE -> {
val dy = (event.rawY - swipeDragStartRawY).coerceAtLeast(0f)
if (dy > 12f || swipeIsDragging) {
swipeIsDragging = true
mgr.translationY = dy
binding.tvSelectedCardType.translationY = dy * 0.6f
val scale = 1f - (dy / (binding.contentLayout.height * 2.5f)).coerceIn(0f, 0.12f)
mgr.scaleX = scale
mgr.scaleY = scale
true
} else false
}
android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> {
if (swipeIsDragging) {
val dy = (event.rawY - swipeDragStartRawY).coerceAtLeast(0f)
swipeIsDragging = false
if (dy > 130f) {
setManageMode(false)
} else {
// Snap back
mgr.animate().translationY(0f).scaleX(1f).scaleY(1f)
.setDuration(280).setInterpolator(DecelerateInterpolator()).start()
binding.tvSelectedCardType.animate().translationY(0f)
.setDuration(280).setInterpolator(DecelerateInterpolator()).start()
}
true
} else false
}
else -> false
}
}
binding.btnScanToPay.setOnClickListener {
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
if (item is CardItem.Mib) {
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
} else {
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
}
val nfcAvailable = android.nfc.NfcAdapter.getDefaultAdapter(requireContext()) != null
binding.btnTapToPay.isEnabled = nfcAvailable
binding.btnTapToPay.setOnClickListener {
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
val msg = if (item is CardItem.Mib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
}
val wip = View.OnClickListener {
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
binding.btnChangePin.setOnClickListener(wip)
binding.btnFreeze.setOnClickListener(wip)
binding.btnBlock.setOnClickListener(wip)
}
private fun setManageMode(enabled: Boolean) {
isManageMode = enabled
requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card)
if (enabled) enterManageMode() else exitManageMode()
}
private fun enterManageMode() {
val item = cards.getOrNull(currentCardPosition) ?: return
// Bind card data
val cv = binding.manageCardView
when (item) {
is CardItem.Mib -> {
cv.tvCardOwner.text = item.card.cardHolderName
cv.tvCardNumber.text = formatMasked(item.card.maskedCardNumber)
val assetPath = cardImageAsset(item.card)
if (assetPath != null) loadCardImage(cv.ivCardImage, assetPath)
else cv.ivCardImage.setImageDrawable(null)
bindCardStatus(cv.tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
cv.root.alpha = 1f
}
is CardItem.Bml -> {
cv.tvCardOwner.text = item.account.accountBriefName
cv.tvCardNumber.text = formatMasked(item.account.accountNumber)
loadCardImage(cv.ivCardImage, BmlCardParser.cardImageAsset(item.account))
val isActive = item.account.statusDesc.equals("Active", ignoreCase = true)
bindCardStatus(cv.tvCardStatus, item.account.statusDesc.takeUnless { isActive })
cv.root.alpha = if (isActive) 1f else 0.45f
}
}
// Capture positions BEFORE layout changes (for enter animation + exit animation later)
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
val lm = binding.rvCards.layoutManager as? LinearLayoutManager
val srcView = lm?.findViewByPosition(currentCardPosition)
val srcLoc = IntArray(2).also { srcView?.getLocationOnScreen(it) ?: run { it[0] = contentLoc[0]; it[1] = contentLoc[1] } }
val srcScreenTop = (srcLoc[1] - contentLoc[1]).toFloat()
val srcCenterX = (srcLoc[0] - contentLoc[0]).toFloat() + cardWidth / 2f
val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
val textSrcScreenTop = (textLoc[1] - contentLoc[1]).toFloat()
// Apply layout changes
binding.btnManageCard.visibility = View.GONE
binding.topSpacer.visibility = View.GONE
binding.rvCards.visibility = View.GONE
binding.pageIndicator.visibility = View.GONE
binding.llPayButtons.visibility = View.GONE
binding.llManageButtons.visibility = View.VISIBLE
binding.llDefaultCardRow.visibility = View.VISIBLE
binding.manageCardView.root.visibility = View.VISIBLE
// Set switch state (clear listener first to avoid triggering on programmatic set)
val isBml = item is CardItem.Bml
binding.switchDefaultCard.setOnCheckedChangeListener(null)
binding.switchDefaultCard.isChecked = isBml && store.getDefaultCardAccountNumber() == (item as? CardItem.Bml)?.account?.accountNumber
binding.switchDefaultCard.setOnCheckedChangeListener { _, isChecked ->
if (item is CardItem.Mib) {
// MIB doesn't support NFC/QR pay — same toast as scan/tap to pay
binding.switchDefaultCard.setOnCheckedChangeListener(null)
binding.switchDefaultCard.isChecked = false
binding.switchDefaultCard.setOnCheckedChangeListener { _, c ->
handleDefaultCardToggle(c)
}
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
} else {
handleDefaultCardToggle(isChecked)
}
}
// After layout pass, compute offsets, save carousel snapshot, and animate
binding.contentLayout.doOnNextLayout {
val mgr = binding.manageCardView.root
val dstLoc = IntArray(2).also { mgr.getLocationOnScreen(it) }
val dstTop = (dstLoc[1] - contentLoc[1]).toFloat()
val dstCenterX = (dstLoc[0] - contentLoc[0]).toFloat() + mgr.width / 2f
val scaleStart = if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f
val transXStart = srcCenterX - dstCenterX
val transYStart = srcScreenTop - dstTop
// Save the carousel card's position (relative to contentLayout) for the exit animation
carouselCardLayoutTop = srcScreenTop
carouselCardCenterX = srcCenterX
carouselTextLayoutTop = textSrcScreenTop
val textDstLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
val textDstTop = (textDstLoc[1] - contentLoc[1]).toFloat()
mgr.pivotX = mgr.width / 2f
mgr.pivotY = 0f
mgr.scaleX = scaleStart
mgr.scaleY = scaleStart
mgr.translationX = transXStart
mgr.translationY = transYStart
mgr.animate()
.scaleX(1f).scaleY(1f)
.translationX(0f).translationY(0f)
.setDuration(380)
.setInterpolator(DecelerateInterpolator())
.start()
binding.tvSelectedCardType.translationY = textSrcScreenTop - textDstTop
binding.tvSelectedCardType.animate()
.translationY(0f)
.setDuration(380)
.setInterpolator(DecelerateInterpolator())
.start()
}
}
private fun handleDefaultCardToggle(isChecked: Boolean) {
val item = cards.getOrNull(currentCardPosition) as? CardItem.Bml ?: return
store.setDefaultCardAccountNumber(if (isChecked) item.account.accountNumber else null)
rebuildCards()
}
private fun exitManageMode() {
binding.manageCardView.root.animate().cancel()
binding.tvSelectedCardType.animate().cancel()
val mgr = binding.manageCardView.root
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
// Compute layout top of manage card (strip current translationY which may be from a swipe drag)
val mgrLoc = IntArray(2).also { mgr.getLocationOnScreen(it) }
val mgrLayoutTop = (mgrLoc[1] - contentLoc[1]).toFloat() - mgr.translationY
val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
val textLayoutTop = (textLoc[1] - contentLoc[1]).toFloat() - binding.tvSelectedCardType.translationY
// Target: animate card back to carousel position
val scaleEnd = if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f
val mgrLayoutCenterX = (mgrLoc[0] - contentLoc[0]).toFloat() - mgr.translationX + mgr.width / 2f
val targetTransX = carouselCardCenterX - mgrLayoutCenterX
val targetTransY = carouselCardLayoutTop - mgrLayoutTop
val targetTextTransY = carouselTextLayoutTop - textLayoutTop
mgr.pivotX = mgr.width / 2f
mgr.pivotY = 0f
mgr.animate()
.scaleX(scaleEnd).scaleY(scaleEnd)
.translationX(targetTransX)
.translationY(targetTransY)
.setDuration(320)
.setInterpolator(AccelerateInterpolator())
.withEndAction {
mgr.scaleX = 1f; mgr.scaleY = 1f
mgr.translationX = 0f; mgr.translationY = 0f
mgr.visibility = View.GONE
binding.tvSelectedCardType.translationY = 0f
binding.btnManageCard.visibility = View.VISIBLE
binding.topSpacer.visibility = View.VISIBLE
binding.rvCards.visibility = View.VISIBLE
binding.llPayButtons.visibility = View.VISIBLE
binding.llManageButtons.visibility = View.GONE
binding.llDefaultCardRow.visibility = View.GONE
binding.switchDefaultCard.setOnCheckedChangeListener(null)
buildDots(cards.size, currentCardPosition)
}
.start()
binding.tvSelectedCardType.animate()
.translationY(targetTextTransY)
.setDuration(320)
.setInterpolator(AccelerateInterpolator())
.withEndAction { binding.tvSelectedCardType.translationY = 0f }
.start()
}
private fun rebuildCards() {
// Remember which card is currently selected by identity so we can restore position after reorder
val currentCard = cards.getOrNull(currentCardPosition)
val defaultNum = store.getDefaultCardAccountNumber()
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
val bmlItems = (viewModel.accounts.value ?: emptyList())
.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
.map { CardItem.Bml(it) }
val all: List<CardItem> = mibItems + bmlItems
// Move default BML card to front
cards = if (defaultNum != null) {
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
if (def != null) listOf(def) + all.filter { it !== def } else all
} else all
// Restore position to follow the same card after reorder
if (currentCard != null) {
val newPos = cards.indexOf(currentCard)
if (newPos >= 0 && newPos != currentCardPosition) {
currentCardPosition = newPos
binding.rvCards.scrollToPosition(newPos)
}
}
stackAdapter.update(cards)
binding.loadingView.visibility = View.GONE
val empty = cards.isEmpty()
binding.emptyView.visibility = if (empty) View.VISIBLE else View.GONE
binding.contentLayout.visibility = if (empty) View.GONE else View.VISIBLE
if (!empty) {
buildDots(cards.size, currentCardPosition)
updateCardInfo(currentCardPosition)
}
}
private fun applyCardScales() {
val rv = binding.rvCards
val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f
val lm = rv.layoutManager as? LinearLayoutManager ?: return
val first = lm.findFirstVisibleItemPosition()
val last = lm.findLastVisibleItemPosition()
if (first < 0) return
for (i in first..last) {
val child = lm.findViewByPosition(i) ?: continue
val childCenter = (child.left + child.right) / 2f
val fraction = (abs(childCenter - rvCenter) / cardWidth.toFloat()).coerceIn(0f, 1f)
val scale = 1f - 0.18f * fraction
child.scaleX = scale
child.scaleY = scale
child.alpha = 1f - 0.4f * fraction
}
}
private fun buildDots(count: Int, selected: Int) {
if (isManageMode) return
binding.pageIndicator.removeAllViews()
if (count <= 1) {
binding.pageIndicator.visibility = View.GONE
return
}
binding.pageIndicator.visibility = View.VISIBLE
val dp = resources.displayMetrics.density
val activeColor = MaterialColors.getColor(
requireContext(), com.google.android.material.R.attr.colorPrimary, Color.GRAY)
val inactiveColor = MaterialColors.getColor(
requireContext(), com.google.android.material.R.attr.colorOutlineVariant, Color.LTGRAY)
val size = (8 * dp).toInt()
val margin = (4 * dp).toInt()
repeat(count) { i ->
val dot = View(requireContext())
dot.layoutParams = LinearLayout.LayoutParams(size, size).apply {
setMargins(margin, 0, margin, 0)
}
dot.background = GradientDrawable().apply {
shape = GradientDrawable.OVAL
setColor(if (i == selected) activeColor else inactiveColor)
}
binding.pageIndicator.addView(dot)
}
}
private fun updateCardInfo(position: Int) {
val item = cards.getOrNull(position) ?: return
binding.tvSelectedCardType.text = when (item) {
is CardItem.Mib -> item.card.cardTypeDesc
is CardItem.Bml -> item.account.accountTypeName
}
}
fun onBackPressed(): Boolean {
if (isManageMode) {
setManageMode(false)
return true
}
return false
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_pay_with_card)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private inner class CardStackAdapter(private val cardWidth: Int) : RecyclerView.Adapter<CardStackAdapter.VH>() {
private var items: List<CardItem> = emptyList()
fun update(newItems: List<CardItem>) {
items = newItems
notifyDataSetChanged()
}
override fun getItemCount() = items.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
VH(LayoutInflater.from(parent.context).inflate(R.layout.item_card_stack, parent, false))
override fun onBindViewHolder(holder: VH, position: Int) {
holder.bind(items[position])
// Pre-scale based on data position so initial render and off-screen cards are correct
val fraction = abs(position - currentCardPosition).toFloat().coerceIn(0f, 1f)
val scale = 1f - 0.18f * fraction
holder.itemView.scaleX = scale
holder.itemView.scaleY = scale
holder.itemView.alpha = 1f - 0.4f * fraction
}
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)
init {
itemView.layoutParams = RecyclerView.LayoutParams(cardWidth, RecyclerView.LayoutParams.WRAP_CONTENT)
}
fun bind(item: CardItem) {
when (item) {
is CardItem.Mib -> {
tvCardOwner.text = item.card.cardHolderName
tvCardNumber.text = formatMasked(item.card.maskedCardNumber)
val assetPath = cardImageAsset(item.card)
if (assetPath != null) loadCardImage(ivCardImage, assetPath)
else ivCardImage.setImageDrawable(null)
bindCardStatus(tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
itemView.alpha = 1f
}
is CardItem.Bml -> {
tvCardOwner.text = item.account.accountBriefName
tvCardNumber.text = formatMasked(item.account.accountNumber)
loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
val isActive = item.account.statusDesc.equals("Active", ignoreCase = true)
bindCardStatus(tvCardStatus, item.account.statusDesc.takeUnless { isActive })
itemView.alpha = if (isActive) 1f else 0.45f
}
}
}
}
}
companion object {
fun cardImageAsset(card: MibCard): String? = when (card.cardType) {
"51" -> "cards/mib/faisa_card.png"
"53" -> "cards/mib/visa_black_platinum.png"
"57" -> "cards/mib/visa_blue_everyday.png"
"70" -> "cards/mib/visa_business.png"
"701" -> "cards/mib/visa_bingaa_mvr.png"
"702" -> "cards/mib/visa_bingaa_usd.png"
else -> null
}
fun loadCardImage(imageView: ImageView, assetPath: String) {
try {
val bitmap = imageView.context.assets.open(assetPath).use {
android.graphics.BitmapFactory.decodeStream(it)
}
imageView.setImageBitmap(bitmap)
} catch (_: Exception) {
imageView.setImageDrawable(null)
}
}
fun mibCardStatusLabel(cardStatus: String): String? = when (cardStatus) {
"CHST0" -> null
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 androidx.activity.result.contract.ActivityResultContracts
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.core.view.ViewCompat
import androidx.core.view.WindowCompat
@@ -31,8 +37,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ActivityQrScannerBinding
import sh.sar.basedbank.util.CredentialStore
import java.util.concurrent.Executors
class QrScannerActivity : AppCompatActivity() {
@@ -52,6 +60,8 @@ class QrScannerActivity : AppCompatActivity() {
textMode = ZxingCpp.TextMode.PLAIN
)
private var camera: Camera? = null
private var torchEnabled = false
private var cameraStarted = false
private val permissionLauncher = registerForActivityResult(
@@ -87,6 +97,14 @@ class QrScannerActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (CredentialStore(this).loadSecurityHash() != null &&
!(application as BasedBankApp).isUnlocked) {
startActivity(Intent(this, sh.sar.basedbank.LockActivity::class.java))
finish()
return
}
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityQrScannerBinding.inflate(layoutInflater)
setContentView(binding.root)
@@ -104,8 +122,36 @@ class QrScannerActivity : AppCompatActivity() {
}
insets
}
binding.btnCancel.setOnClickListener { finish() }
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)
== PackageManager.PERMISSION_GRANTED
@@ -179,9 +225,12 @@ class QrScannerActivity : AppCompatActivity() {
try {
provider.unbindAll()
provider.bindToLifecycle(
camera = provider.bindToLifecycle(
this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
)
camera?.cameraInfo?.zoomState?.observe(this@QrScannerActivity) { state ->
binding.zoomSlider.value = state.linearZoom
}
} catch (_: Exception) {
finish()
}
@@ -2,7 +2,9 @@ package sh.sar.basedbank.ui.home
import android.content.Context
import android.content.SharedPreferences
import android.graphics.Color
import android.os.Bundle
import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -10,6 +12,7 @@ import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AppCompatDelegate
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
import androidx.core.os.LocaleListCompat
@@ -18,8 +21,11 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentSettingsAppearanceBinding
import sh.sar.basedbank.util.ThemeHelper
import java.util.Collections
class SettingsAppearanceFragment : Fragment() {
@@ -54,19 +60,29 @@ class SettingsAppearanceFragment : Fragment() {
// Quick actions
quickActions.clear()
quickActions.addAll(NavCustomization.getQuickActions(prefs))
quickActionAdapter = NavItemAdapter(quickActions) {
NavCustomization.saveQuickActions(prefs, quickActions)
quickActionAdapter = NavItemAdapter(
items = quickActions,
onSave = { NavCustomization.saveQuickActions(prefs, quickActions) },
isEnabled = { !prefs.getBoolean("bottom_nav", false) }
)
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions) {
!prefs.getBoolean("bottom_nav", false)
}
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)
slotAdapter = NavItemAdapter(
items = slots,
onSave = {
NavCustomization.saveSlots(prefs, slots)
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
},
isEnabled = { prefs.getBoolean("bottom_nav", false) }
)
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) {
prefs.getBoolean("bottom_nav", false)
}
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots)
// Show labels toggle
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
binding.switchShowLabels.isChecked = showLabels
@@ -93,8 +109,45 @@ class SettingsAppearanceFragment : Fragment() {
}
prefs.edit().putString("theme", key).apply()
AppCompatDelegate.setDefaultNightMode(mode)
updateAccentState(key == "system")
updatePitchBlackState(key == "dark")
}
// Pitch black
binding.switchPitchBlack.isChecked = prefs.getBoolean("pitch_black", false)
binding.switchPitchBlack.setOnCheckedChangeListener { _, checked ->
prefs.edit().putBoolean("pitch_black", checked).apply()
requireActivity().recreate()
}
val isDark = prefs.getString("theme", "system") == "dark"
updatePitchBlackState(isDark)
// Accent color
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
binding.accentToggle.check(when (savedPreset) {
ThemeHelper.PRESET_RED -> R.id.btnAccentOrange
ThemeHelper.PRESET_GREEN -> R.id.btnAccentGreen
ThemeHelper.PRESET_CUSTOM -> R.id.btnAccentCustom
else -> R.id.btnAccentBlue
})
binding.accentToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener
val preset = when (checkedId) {
R.id.btnAccentOrange -> ThemeHelper.PRESET_RED
R.id.btnAccentGreen -> ThemeHelper.PRESET_GREEN
R.id.btnAccentCustom -> ThemeHelper.PRESET_CUSTOM
else -> ThemeHelper.PRESET_BLUE
}
if (preset == ThemeHelper.PRESET_CUSTOM) {
showCustomColorPicker()
} else {
prefs.edit().putString("accent_preset", preset).apply()
requireActivity().recreate()
}
}
val isSystem = prefs.getString("theme", "system") == "system"
updateAccentState(isSystem)
// Language
val currentLocales = AppCompatDelegate.getApplicationLocales()
val currentLang = if (currentLocales.isEmpty) "en" else currentLocales[0]?.language ?: "en"
@@ -109,13 +162,18 @@ class SettingsAppearanceFragment : Fragment() {
private fun setupNavItemRecyclerView(
rv: RecyclerView,
adapter: NavItemAdapter,
items: MutableList<Int>
items: MutableList<Int>,
isEnabled: () -> Boolean
) {
rv.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
rv.adapter = adapter
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.START or ItemTouchHelper.END, 0
) {
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
if (!isEnabled()) return 0
return super.getMovementFlags(recyclerView, viewHolder)
}
override fun onMove(
rv: RecyclerView,
from: RecyclerView.ViewHolder,
@@ -134,11 +192,79 @@ class SettingsAppearanceFragment : Fragment() {
private fun updateShortcutsVisibility() {
val isBottom = prefs.getBoolean("bottom_nav", false)
binding.sectionQuickActions.alpha = if (isBottom) 0.38f else 1f
binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
binding.switchShowLabels.isClickable = isBottom
quickActionAdapter.notifyDataSetChanged()
slotAdapter.notifyDataSetChanged()
}
private fun updatePitchBlackState(isDark: Boolean) {
binding.rowPitchBlack.alpha = if (isDark) 1f else 0.38f
binding.switchPitchBlack.isEnabled = isDark
}
private fun updateAccentState(isSystem: Boolean) {
binding.sectionAccentColor.alpha = if (isSystem) 0.38f else 1f
for (i in 0 until binding.accentToggle.childCount) {
binding.accentToggle.getChildAt(i)?.isEnabled = !isSystem
}
}
private fun showCustomColorPicker() {
val ctx = requireContext()
val currentHex = prefs.getString("accent_custom_color", "") ?: ""
val inputLayout = TextInputLayout(ctx).apply {
hint = getString(R.string.accent_custom_hint)
val pad = (16 * resources.displayMetrics.density).toInt()
setPadding(pad, pad / 2, pad, 0)
}
val input = TextInputEditText(ctx).apply {
setText(currentHex)
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
setSingleLine(true)
}
inputLayout.addView(input)
val dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.accent_custom_pick)
.setView(inputLayout)
.setPositiveButton(android.R.string.ok, null)
.setNegativeButton(R.string.cancel) { _, _ -> revertAccentToggle() }
.setOnCancelListener { revertAccentToggle() }
.show()
dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val raw = input.text.toString().trim()
val hex = if (raw.startsWith("#")) raw else "#$raw"
try {
Color.parseColor(hex)
prefs.edit()
.putString("accent_preset", ThemeHelper.PRESET_CUSTOM)
.putString("accent_custom_color", hex)
.apply()
dialog.dismiss()
requireActivity().recreate()
} catch (_: Exception) {
Toast.makeText(ctx, R.string.accent_invalid_color, Toast.LENGTH_SHORT).show()
}
}
}
private fun revertAccentToggle() {
val saved = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
binding.accentToggle.check(when (saved) {
ThemeHelper.PRESET_RED -> R.id.btnAccentOrange
ThemeHelper.PRESET_GREEN -> R.id.btnAccentGreen
ThemeHelper.PRESET_CUSTOM -> R.id.btnAccentCustom
else -> R.id.btnAccentBlue
})
}
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
if (items === slots && !prefs.getBoolean("bottom_nav", false)) return
val isBottom = prefs.getBoolean("bottom_nav", false)
if (items === slots && !isBottom) return
if (items === quickActions && isBottom) return
val ctx = requireContext()
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
@@ -147,6 +273,7 @@ class SettingsAppearanceFragment : Fragment() {
LayoutInflater.from(ctx).inflate(R.layout.item_more_nav, listLayout, false).also { row ->
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
row.findViewById<TextView>(R.id.tvDescription).visibility = View.GONE
listLayout.addView(row)
}
}
@@ -169,7 +296,8 @@ class SettingsAppearanceFragment : Fragment() {
private inner class NavItemAdapter(
val items: MutableList<Int>,
val onSave: () -> Unit
val onSave: () -> Unit,
val isEnabled: () -> Boolean = { true }
) : RecyclerView.Adapter<NavItemAdapter.VH>() {
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
@@ -191,7 +319,12 @@ class SettingsAppearanceFragment : Fragment() {
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) }
val enabled = isEnabled()
holder.itemView.setOnClickListener(
if (enabled) View.OnClickListener { showItemPicker(items, holder.adapterPosition, this) }
else null
)
holder.itemView.isClickable = enabled
}
}
@@ -17,14 +17,15 @@ class SettingsFragment : Fragment() {
private data class SettingsItem(
@DrawableRes val icon: Int,
@StringRes val title: Int,
@StringRes val description: Int,
val dest: () -> Fragment
)
private val items = listOf(
SettingsItem(R.drawable.ic_contacts, R.string.settings_logins) { SettingsLoginsFragment() },
SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance) { SettingsAppearanceFragment() },
SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security) { SettingsSecurityFragment() },
SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage) { SettingsStorageFragment() },
SettingsItem(R.drawable.ic_contacts, R.string.settings_logins, R.string.settings_desc_logins) { SettingsLoginsFragment() },
SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance, R.string.settings_desc_appearance) { SettingsAppearanceFragment() },
SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security, R.string.settings_desc_privacy_security) { SettingsSecurityFragment() },
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 =
@@ -37,6 +38,7 @@ class SettingsFragment : Fragment() {
val row = inflater.inflate(R.layout.item_more_nav, list, false)
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.icon)
row.findViewById<TextView>(R.id.tvLabel).setText(item.title)
row.findViewById<TextView>(R.id.tvDescription).setText(item.description)
row.setOnClickListener {
(requireActivity() as HomeActivity).showWithBackStack(item.dest())
}
@@ -1,8 +1,15 @@
package sh.sar.basedbank.ui.home
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.provider.Settings as AndroidSettings
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Bundle
import android.util.Base64
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
@@ -10,6 +17,9 @@ import android.view.ViewGroup
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -22,11 +32,13 @@ import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
import sh.sar.basedbank.ui.login.LoginActivity
import sh.sar.basedbank.util.AccountCache
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.FinancingCache
import sh.sar.basedbank.util.ForeignLimitsCache
import sh.sar.basedbank.util.ProfileImageStore
import sh.sar.basedbank.util.RecentsCache
import androidx.lifecycle.lifecycleScope
import kotlin.coroutines.resume
@@ -37,6 +49,8 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlOtpChannel
import java.io.ByteArrayOutputStream
import java.io.File
class SettingsLoginsFragment : Fragment() {
@@ -44,6 +58,275 @@ class SettingsLoginsFragment : Fragment() {
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
// ── Profile image picker state ─────────────────────────────────────────────
private sealed class PendingImageTarget {
data class Mib(val loginId: String, val profile: MibProfile) : PendingImageTarget()
data class Bml(val profileId: String, val refreshImage: (Bitmap?) -> Unit) : PendingImageTarget()
data class Fahipay(val loginId: String, val refreshImage: (Bitmap?) -> Unit) : PendingImageTarget()
}
private var pendingImageTarget: PendingImageTarget? = null
private var cameraPhotoUri: Uri? = null
private val galleryLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) return@registerForActivityResult
val bitmap = loadAndScaleBitmap(uri) ?: return@registerForActivityResult
handlePickedImage(bitmap)
}
private val cameraLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
if (!success) return@registerForActivityResult
val uri = cameraPhotoUri ?: return@registerForActivityResult
val bitmap = loadAndScaleBitmap(uri) ?: return@registerForActivityResult
handlePickedImage(bitmap)
}
private val cameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
if (granted) cameraLauncher.launch(cameraPhotoUri ?: return@registerForActivityResult)
else showCameraPermissionRationale()
}
private fun showCameraPermissionRationale() {
val ctx = requireContext()
MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.qr_camera_permission_title)
.setMessage(R.string.camera_permission_profile_message)
.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() }
.setPositiveButton(R.string.go_to_settings) { _, _ ->
startActivity(
Intent(AndroidSettings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", ctx.packageName, null)
}
)
}
.show()
}
private fun loadAndScaleBitmap(uri: Uri): Bitmap? {
return try {
val ctx = requireContext()
val inputStream = ctx.contentResolver.openInputStream(uri) ?: return null
val original = BitmapFactory.decodeStream(inputStream)
inputStream.close()
if (original == null) return null
val maxDim = 512
val scale = minOf(maxDim.toFloat() / original.width, maxDim.toFloat() / original.height, 1f)
if (scale < 1f) {
Bitmap.createScaledBitmap(original, (original.width * scale).toInt(), (original.height * scale).toInt(), true)
} else original
} catch (_: Exception) { null }
}
private fun handlePickedImage(bitmap: Bitmap) {
val target = pendingImageTarget ?: return
when (target) {
is PendingImageTarget.Mib -> uploadMibProfileImage(target.loginId, target.profile, bitmap)
is PendingImageTarget.Bml -> {
ProfileImageStore.save(requireContext(), ProfileImageStore.bmlKey(target.profileId), bitmap)
target.refreshImage(bitmap)
}
is PendingImageTarget.Fahipay -> {
ProfileImageStore.save(requireContext(), ProfileImageStore.fahipayKey(target.loginId), bitmap)
target.refreshImage(bitmap)
}
}
}
private fun uploadMibProfileImage(loginId: String, profile: MibProfile, bitmap: Bitmap) {
val ctx = requireContext()
val app = requireActivity().application as BasedBankApp
val progress = MaterialAlertDialogBuilder(ctx)
.setMessage(getString(R.string.profile_image_uploading))
.setCancelable(false)
.show()
viewLifecycleOwner.lifecycleScope.launch {
val result = withContext(Dispatchers.IO) {
try {
val session = app.mibSessions[loginId] ?: app.anyMibSession() ?: return@withContext null
val flow = app.mibLoginFlows[loginId] ?: return@withContext null
flow.switchProfile(session, profile)
// MIB server enforces a small payload limit (~4KB); scale to 100px max
val mibMax = 100
val mibScale = minOf(mibMax.toFloat() / bitmap.width, mibMax.toFloat() / bitmap.height, 1f)
val mibBitmap = if (mibScale < 1f)
Bitmap.createScaledBitmap(bitmap, (bitmap.width * mibScale).toInt(), (bitmap.height * mibScale).toInt(), true)
else bitmap
val baos = ByteArrayOutputStream()
mibBitmap.compress(Bitmap.CompressFormat.JPEG, 50, baos)
val base64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
flow.uploadProfileImage(session, profile, base64)
} catch (_: Exception) { null }
}
progress.dismiss()
if (result == null) {
MaterialAlertDialogBuilder(ctx)
.setMessage(getString(R.string.profile_image_upload_failed))
.setPositiveButton(R.string.close, null)
.show()
} else {
clearAllCaches(ctx)
(activity as? HomeActivity)?.relogin()
}
}
}
private fun showImagePickerMenu(anchor: View, target: PendingImageTarget, currentBitmap: Bitmap?) {
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val items = mutableListOf<Triple<Int, String, () -> Unit>>()
items += Triple(R.drawable.ic_image, getString(R.string.profile_image_select)) {
pendingImageTarget = target
galleryLauncher.launch("image/*")
}
items += Triple(R.drawable.ic_camera, getString(R.string.profile_image_camera)) {
pendingImageTarget = target
val photoFile = File(ctx.cacheDir, "profile_photo_tmp.jpg")
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", photoFile)
cameraPhotoUri = uri
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
cameraLauncher.launch(uri)
} else {
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
if (target is PendingImageTarget.Mib || currentBitmap != null || hasSavedImage(ctx, target)) {
items += Triple(R.drawable.ic_delete, getString(R.string.profile_image_remove)) {
removeProfileImage(ctx, target)
}
}
val list = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val vp = (8 * dp).toInt()
setPadding(0, vp, 0, vp)
}
for ((iconRes, label, action) in items) {
val iconSize = (24 * dp).toInt()
val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
background = ta.getDrawable(0); ta.recycle()
isClickable = true; isFocusable = true
val hp = (24 * dp).toInt(); val vp2 = (12 * dp).toInt()
setPadding(hp, vp2, hp, vp2)
}
val iconColor = com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK)
val icon = ImageView(ctx).apply {
setImageResource(iconRes)
imageTintList = android.content.res.ColorStateList.valueOf(iconColor)
}
val tv = TextView(ctx).apply {
text = label
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
marginStart = (12 * dp).toInt()
}
}
row.addView(icon, LinearLayout.LayoutParams(iconSize, iconSize))
row.addView(tv)
list.addView(row)
}
val dialog = MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.profile_image_title)
.setView(list)
.setNegativeButton(R.string.cancel, null)
.show()
val rows = (0 until list.childCount).map { list.getChildAt(it) }
rows.forEachIndexed { i, row ->
row.setOnClickListener {
dialog.dismiss()
items[i].third()
}
}
}
private fun hasSavedImage(ctx: Context, target: PendingImageTarget): Boolean = when (target) {
is PendingImageTarget.Mib -> target.profile.customerImage != null
is PendingImageTarget.Bml -> ProfileImageStore.exists(ctx, ProfileImageStore.bmlKey(target.profileId))
is PendingImageTarget.Fahipay -> ProfileImageStore.exists(ctx, ProfileImageStore.fahipayKey(target.loginId))
}
private fun removeProfileImage(ctx: Context, target: PendingImageTarget) {
when (target) {
is PendingImageTarget.Mib -> {
val app = requireActivity().application as BasedBankApp
val progress = MaterialAlertDialogBuilder(ctx)
.setMessage(getString(R.string.profile_image_deleting))
.setCancelable(false)
.show()
viewLifecycleOwner.lifecycleScope.launch {
withContext(Dispatchers.IO) {
try {
val session = app.mibSessions[target.loginId] ?: app.anyMibSession() ?: return@withContext
val flow = app.mibLoginFlows[target.loginId] ?: return@withContext
flow.switchProfile(session, target.profile)
flow.deleteProfileImage(session, target.profile)
} catch (_: Exception) {}
}
progress.dismiss()
clearAllCaches(ctx)
(activity as? HomeActivity)?.relogin()
}
}
is PendingImageTarget.Bml -> {
ProfileImageStore.delete(ctx, ProfileImageStore.bmlKey(target.profileId))
target.refreshImage(null)
}
is PendingImageTarget.Fahipay -> {
ProfileImageStore.delete(ctx, ProfileImageStore.fahipayKey(target.loginId))
target.refreshImage(null)
}
}
}
private fun makePencilButton(ctx: Context): ImageView {
val dp = ctx.resources.displayMetrics.density
val size = (32 * dp).toInt()
return ImageView(ctx).apply {
setImageResource(R.drawable.ic_edit)
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackgroundBorderless))
background = ta.getDrawable(0); ta.recycle()
isClickable = true; isFocusable = true
layoutParams = LinearLayout.LayoutParams(size, size).apply {
marginStart = (4 * dp).toInt()
}
}
}
private fun makeCircleAvatarView(ctx: Context, sizeDp: Int): ImageView {
val dp = ctx.resources.displayMetrics.density
val size = (sizeDp * dp).toInt()
return ImageView(ctx).apply {
layoutParams = LinearLayout.LayoutParams(size, size)
scaleType = ImageView.ScaleType.CENTER_CROP
clipToOutline = true
outlineProvider = object : android.view.ViewOutlineProvider() {
override fun getOutline(view: View, outline: android.graphics.Outline) {
outline.setOval(0, 0, view.width, view.height)
}
}
}
}
private fun setAvatarBitmap(iv: ImageView, bitmap: Bitmap?) {
if (bitmap != null) {
iv.setImageBitmap(bitmap)
iv.imageTintList = null
} else {
iv.setImageResource(R.drawable.ic_image)
val color = com.google.android.material.color.MaterialColors.getColor(
iv, com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK)
iv.imageTintList = android.content.res.ColorStateList.valueOf(color)
}
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false)
return binding.root
@@ -100,18 +383,7 @@ class SettingsLoginsFragment : Fragment() {
val profile = store.loadFahipayUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
val hide = viewModel.hideAmounts.value ?: false
val masked = "••••••"
showLoginDetails(
title = getString(R.string.fahipay_name),
details = buildString {
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${if (hide) masked else profile!!.email}")
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${if (hide) masked else profile!!.mobile}")
if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${if (hide) masked else profile!!.nid}")
}.trim(),
onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) } }
)
showFahipayLoginDetails(store, loginId, profile)
}
}
}
@@ -218,8 +490,21 @@ class SettingsLoginsFragment : Fragment() {
alpha = 0.6f
})
}
val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
val pencil = makePencilButton(ctx).apply {
alpha = 0.38f
isEnabled = false
}
val toggle = MaterialSwitch(ctx).apply {
isChecked = p.profileId !in hidden
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
marginStart = (4 * dp).toInt()
}
}
pencil.setOnClickListener {
android.widget.Toast.makeText(ctx, "Work in progress", android.widget.Toast.LENGTH_SHORT).show()
}
row.addView(textCol)
row.addView(pencil)
row.addView(toggle)
container.addView(row)
p to toggle
@@ -327,6 +612,10 @@ class SettingsLoginsFragment : Fragment() {
}
val toggleRows = bmlProfiles.map { p ->
val avatarIv = makeCircleAvatarView(ctx, 36)
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
setAvatarBitmap(avatarIv, currentBitmap)
val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
@@ -349,8 +638,24 @@ class SettingsLoginsFragment : Fragment() {
alpha = 0.6f
})
}
val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
val pencil = makePencilButton(ctx)
val toggle = MaterialSwitch(ctx).apply {
isChecked = p.profileId !in hidden
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
marginStart = (4 * dp).toInt()
}
}
val target = PendingImageTarget.Bml(p.profileId) { newBitmap ->
setAvatarBitmap(avatarIv, newBitmap)
}
pencil.setOnClickListener {
val cur = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
showImagePickerMenu(pencil, target, cur)
}
val avatarSize = (36 * dp).toInt()
row.addView(avatarIv, LinearLayout.LayoutParams(avatarSize, avatarSize).apply { marginEnd = (8 * dp).toInt() })
row.addView(textCol)
row.addView(pencil)
row.addView(toggle)
container.addView(row)
p to toggle
@@ -433,6 +738,10 @@ class SettingsLoginsFragment : Fragment() {
return when (activationResult) {
is BmlActivationResult.Success -> {
store.saveBmlProfileSession(profile.profileId, activationResult.session.accessToken, activationResult.session.deviceId)
if (activationResult.session.refreshToken.isNotBlank())
store.saveBmlProfileRefreshToken(profile.profileId, activationResult.session.refreshToken)
if (activationResult.session.expiresAt > 0)
store.saveBmlProfileExpiresAt(profile.profileId, activationResult.session.expiresAt)
true
}
is BmlActivationResult.NeedsBusinessOtp ->
@@ -475,6 +784,10 @@ class SettingsLoginsFragment : Fragment() {
}
verifyProgress.dismiss()
store.saveBmlProfileSession(profile.profileId, session.accessToken, session.deviceId)
if (session.refreshToken.isNotBlank())
store.saveBmlProfileRefreshToken(profile.profileId, session.refreshToken)
if (session.expiresAt > 0)
store.saveBmlProfileExpiresAt(profile.profileId, session.expiresAt)
return true
} catch (e: Exception) {
verifyProgress.dismiss()
@@ -606,6 +919,75 @@ class SettingsLoginsFragment : Fragment() {
}
}
private fun showFahipayLoginDetails(
store: CredentialStore,
loginId: String,
profile: CredentialStore.FahipayUserProfile?
) {
val ctx = requireContext()
val dp = ctx.resources.displayMetrics.density
val hide = viewModel.hideAmounts.value ?: false
val masked = "••••••"
val scroll = android.widget.ScrollView(ctx)
val container = LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL
val pad = (16 * dp).toInt()
setPadding(pad, (8 * dp).toInt(), pad, pad)
}
scroll.addView(container)
// Avatar row with pencil
val avatarRow = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
bottomMargin = (12 * dp).toInt()
}
}
val avatarSize = (56 * dp).toInt()
val avatarIv = makeCircleAvatarView(ctx, 56)
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
setAvatarBitmap(avatarIv, currentBitmap)
val pencil = makePencilButton(ctx)
val target = PendingImageTarget.Fahipay(loginId) { newBitmap ->
setAvatarBitmap(avatarIv, newBitmap)
}
pencil.setOnClickListener {
val cur = ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
showImagePickerMenu(pencil, target, cur)
}
avatarRow.addView(avatarIv, LinearLayout.LayoutParams(avatarSize, avatarSize).apply { marginEnd = (8 * dp).toInt() })
avatarRow.addView(pencil)
container.addView(avatarRow)
// Account info lines
listOfNotNull(
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: ${if (hide) masked else it}" },
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: ${if (hide) masked else it}" },
profile?.nid?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_id_card)}: ${if (hide) masked else it}" }
).forEach { line ->
container.addView(TextView(ctx).apply {
text = line
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
bottomMargin = (4 * dp).toInt()
}
})
}
MaterialAlertDialogBuilder(ctx)
.setTitle(getString(R.string.fahipay_name))
.setView(scroll)
.setPositiveButton(R.string.close, null)
.setNegativeButton(R.string.settings_logout) { _, _ ->
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
}
.show()
}
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
MaterialAlertDialogBuilder(requireContext())
.setTitle(title)
@@ -634,6 +1016,7 @@ class SettingsLoginsFragment : Fragment() {
app.mibLoginFlows.remove(loginId)
app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" }
app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" }
viewModel.financing.value = emptyList()
clearAllCaches(ctx)
(activity as HomeActivity).relogin()
buildLoginsSection()
@@ -644,12 +1027,17 @@ class SettingsLoginsFragment : Fragment() {
val app = requireActivity().application as BasedBankApp
// Remove all per-profile sessions for this login from the in-memory map
val profiles = app.bmlProfilesMap[loginId] ?: emptyList()
profiles.forEach { app.bmlSessions.remove(it.profileId) }
profiles.forEach { p ->
app.bmlSessions.remove(p.profileId)
ProfileImageStore.delete(ctx, ProfileImageStore.bmlKey(p.profileId))
}
// clearBmlCredentials also clears per-profile tokens via loadBmlProfiles internally
store.clearBmlCredentials(loginId)
app.bmlProfilesMap.remove(loginId)
app.bmlLoginFlows.remove(loginId)
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
viewModel.bmlLimits.value = emptyList()
viewModel.bmlLoanDetails.value = emptyMap()
clearAllCaches(ctx)
(activity as HomeActivity).relogin()
buildLoginsSection()
@@ -657,6 +1045,7 @@ class SettingsLoginsFragment : Fragment() {
private fun logoutFahipay(store: CredentialStore, loginId: String) {
val ctx = requireContext()
ProfileImageStore.delete(ctx, ProfileImageStore.fahipayKey(loginId))
store.clearFahipayCredentials(loginId)
val app = requireActivity().application as BasedBankApp
app.fahipaySessions.remove(loginId)
@@ -669,6 +1058,7 @@ class SettingsLoginsFragment : Fragment() {
private fun clearAllCaches(ctx: Context) {
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
CardsCache.clear(ctx); TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
// Note: ProfileImageStore is intentionally NOT cleared here — profile images are user-set data
}
}
@@ -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
val canUseBiometrics = BiometricManager.from(requireContext())
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
@@ -60,7 +69,6 @@ class SettingsSecurityFragment : Fragment() {
// Auto-lock
binding.autolockToggle.check(when (prefs.getLong("autolock_timeout", 60_000L)) {
0L -> R.id.btnAutolockOff
30_000L -> R.id.btnAutolock30s
180_000L -> R.id.btnAutolock3m
300_000L -> R.id.btnAutolock5m
@@ -69,7 +77,6 @@ class SettingsSecurityFragment : Fragment() {
binding.autolockToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
if (!isChecked) return@addOnButtonCheckedListener
val timeout = when (checkedId) {
R.id.btnAutolockOff -> 0L
R.id.btnAutolock30s -> 30_000L
R.id.btnAutolock3m -> 180_000L
R.id.btnAutolock5m -> 300_000L
@@ -79,17 +86,6 @@ class SettingsSecurityFragment : Fragment() {
(activity as? HomeActivity)?.resetAutolockTimer()
}
// Hide sensitive information (enables/disables the eye icon in toolbar)
val viewModel = (requireActivity() as HomeActivity).let {
androidx.lifecycle.ViewModelProvider(it)[HomeViewModel::class.java]
}
binding.switchHideAmounts.isChecked = prefs.getBoolean("hide_sensitive_info", false)
binding.switchHideAmounts.setOnCheckedChangeListener { _, isChecked ->
prefs.edit().putBoolean("hide_sensitive_info", isChecked).apply()
if (!isChecked) viewModel.hideAmounts.value = false
requireActivity().invalidateOptionsMenu()
}
// Block screenshots
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
binding.switchBlockScreenshots.isChecked = blockScreenshots
@@ -7,10 +7,12 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentSettingsStorageBinding
import sh.sar.basedbank.util.AccountCache
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.FinancingCache
@@ -21,6 +23,7 @@ class SettingsStorageFragment : Fragment() {
private var _binding: FragmentSettingsStorageBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsStorageBinding.inflate(inflater, container, false)
@@ -32,6 +35,7 @@ class SettingsStorageFragment : Fragment() {
val ctx = requireContext()
clearAllCaches(ctx)
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) {
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.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()
}
}
File diff suppressed because it is too large Load Diff
@@ -30,6 +30,7 @@ import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibHistoryClient
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.databinding.FragmentTransferHistoryBinding
@@ -64,7 +65,7 @@ class TransferHistoryFragment : Fragment() {
) {
fun hasMore(): Boolean = when {
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.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
}
@@ -138,6 +139,14 @@ class TransferHistoryFragment : Fragment() {
}
(activity as? HomeActivity)?.setRefreshing(true)
loadNextPages()
binding.swipeRefresh.setOnRefreshListener {
if (isLoading) {
binding.swipeRefresh.isRefreshing = false
} else {
resetAndReload()
}
}
}
override fun onResume() {
@@ -145,6 +154,26 @@ class TransferHistoryFragment : Fragment() {
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() {
val activeStates = accountStates.filter { it.hasMore() }
if (isLoading || activeStates.isEmpty()) return
@@ -157,6 +186,14 @@ class TransferHistoryFragment : Fragment() {
val app = requireActivity().application as BasedBankApp
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 results = mutableListOf<BankTransaction>()
@@ -166,7 +203,7 @@ class TransferHistoryFragment : Fragment() {
async {
try {
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 cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -state.cardMonthOffset)
@@ -194,7 +231,7 @@ class TransferHistoryFragment : Fragment() {
list
}
}
} catch (_: Exception) { emptyList<BankTransaction>() }
} catch (e: Exception) { trackError(e); emptyList<BankTransaction>() }
}
}.awaitAll().flatten())
@@ -212,7 +249,7 @@ class TransferHistoryFragment : Fragment() {
if (total > 0) state.fahipayTotal = total
state.fahipayNextStart += list.size
results.addAll(list)
} catch (_: Exception) {}
} catch (e: Exception) { trackError(e) }
}
// MIB accounts: serialized per profile, protected by mutex to prevent session race
@@ -221,23 +258,25 @@ class TransferHistoryFragment : Fragment() {
val session = app.mibSessions[loginId] ?: continue
for ((profileId, states) in loginStates.groupBy { it.account.profileId }) {
app.mibMutex.withLock {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = profiles.firstOrNull { it.profileId == profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
for (state in states) {
try {
val (list, total) = MibHistoryClient().fetchHistory(
session = session,
accountNo = state.account.accountNumber,
accountDisplayName = state.account.accountBriefName,
start = state.mibNextStart,
pageSize = pageSize
)
if (total > 0) state.mibTotalCount = total
state.mibNextStart += list.size.coerceAtLeast(pageSize)
results.addAll(list)
} catch (_: Exception) {}
}
try {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = profiles.firstOrNull { it.profileId == profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
for (state in states) {
try {
val (list, total) = MibHistoryClient().fetchHistory(
session = session,
accountNo = state.account.accountNumber,
accountDisplayName = state.account.accountBriefName,
start = state.mibNextStart,
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) }
}
}
}
@@ -246,10 +285,21 @@ class TransferHistoryFragment : Fragment() {
}
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) {
firstBatchDone = true
(activity as? HomeActivity)?.setRefreshing(false)
binding.swipeRefresh.isRefreshing = false
}
if (newTransactions.isNotEmpty()) {
@@ -330,36 +330,48 @@ class TransferReceiptFragment : Fragment() {
}
private fun showFullScreenReceipt() {
captureReceiptBitmap { bitmap ->
if (bitmap == null) return@captureReceiptBitmap
val ctx = requireContext()
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
val iv = android.widget.ImageView(ctx).apply {
setImageBitmap(bitmap)
scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
setBackgroundColor(Color.BLACK)
}
iv.setOnClickListener { dialog.dismiss() }
dialog.setContentView(iv)
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, iv).apply {
hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
systemBarsBehavior = androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
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
}
}
}
@@ -1,5 +1,8 @@
package sh.sar.basedbank.ui.login
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
@@ -81,6 +84,17 @@ class CredentialsFragment : Fragment() {
binding.btnLogin.isEnabled = false
binding.btnLogin.setOnClickListener { attemptLogin() }
binding.cardOtp.setOnClickListener {
val code = binding.tvOtpCode.text.toString().replace(" ", "")
if (code.isNotEmpty()) {
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) {
Toast.makeText(requireContext(), "OTP copied", Toast.LENGTH_SHORT).show()
}
}
}
val loginFieldWatcher = object : TextWatcher {
override fun afterTextChanged(s: Editable?) { updateLoginButtonState() }
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
@@ -215,6 +229,7 @@ class CredentialsFragment : Fragment() {
app.mibSessions[loginId] = flow.lastSession!!
app.mibProfilesMap[loginId] = flow.lastProfiles
app.mibLoginFlows[loginId] = flow
app.isUnlocked = true
val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
@@ -267,6 +282,10 @@ class CredentialsFragment : Fragment() {
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
}
@@ -326,8 +345,16 @@ class CredentialsFragment : Fragment() {
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 {
@@ -352,6 +379,7 @@ class CredentialsFragment : Fragment() {
if (hasBusinessProfiles) {
Toast.makeText(requireContext(), "Business profiles can be enabled in Settings → Logins", Toast.LENGTH_LONG).show()
}
(requireActivity().application as BasedBankApp).isUnlocked = true
val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
@@ -484,6 +512,7 @@ class CredentialsFragment : Fragment() {
app.fahipaySessions[loginId] = session
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != loginTag } + listOf(account)
app.accounts = app.accounts.filter { it.loginTag != loginTag } + listOf(account)
app.isUnlocked = true
val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
@@ -1,17 +1,33 @@
package sh.sar.basedbank.ui.login
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.LockActivity
import sh.sar.basedbank.databinding.ActivityLoginBinding
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.ThemeHelper
class LoginActivity : AppCompatActivity() {
private lateinit var binding: ActivityLoginBinding
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.applyAccent(this)
super.onCreate(savedInstanceState)
// If security is configured and the user hasn't unlocked this session,
// they must authenticate first before being able to add more accounts.
val app = application as BasedBankApp
if (CredentialStore(this).loadSecurityHash() != null && !app.isUnlocked) {
startActivity(Intent(this, LockActivity::class.java))
finish()
return
}
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root)
@@ -20,5 +36,8 @@ class LoginActivity : AppCompatActivity() {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
ta.recycle()
}
}
@@ -13,9 +13,13 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayoutMediator
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.LockActivity
import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ActivityOnboardingBinding
import sh.sar.basedbank.ui.login.LoginActivity
import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.ThemeHelper
class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
@@ -24,7 +28,17 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
private var countDownTimer: CountDownTimer? = null
override fun onCreate(savedInstanceState: Bundle?) {
ThemeHelper.applyAccent(this)
super.onCreate(savedInstanceState)
// If security is already configured, onboarding is complete. Redirect to lock screen
// to prevent overwriting an existing PIN/pattern via direct activity launch.
if (CredentialStore(this).loadSecurityHash() != null) {
startActivity(Intent(this, LockActivity::class.java))
finish()
return
}
WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityOnboardingBinding.inflate(layoutInflater)
setContentView(binding.root)
@@ -33,6 +47,9 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight
}
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
ta.recycle()
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val originalBottomPadding = binding.bottomBar.paddingBottom
ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets ->
@@ -97,6 +114,9 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
binding.btnGetStarted.setOnClickListener {
prefs.edit().putBoolean("onboarding_done", true).apply()
// Mark as unlocked so LoginActivity doesn't redirect to LockActivity.
// The user just completed setup — they shouldn't have to re-authenticate immediately.
(application as BasedBankApp).isUnlocked = true
startActivity(Intent(this, LoginActivity::class.java))
finish()
}
@@ -11,19 +11,19 @@ class OnboardingPagerAdapter(activity: FragmentActivity) : FragmentStateAdapter(
OnboardingSlide(
titleRes = R.string.onboarding_title_1,
descRes = R.string.onboarding_desc_1,
iconRes = R.drawable.ic_launcher_foreground,
iconRes = R.drawable.ic_logo,
isFirst = true
),
OnboardingSlide(
titleRes = R.string.onboarding_title_2,
descRes = R.string.onboarding_desc_2,
iconRes = R.drawable.ic_launcher_foreground,
iconRes = R.drawable.ic_logo,
isFirst = false
),
OnboardingSlide(
titleRes = R.string.onboarding_title_3,
descRes = R.string.onboarding_desc_3,
iconRes = R.drawable.ic_launcher_foreground,
iconRes = R.drawable.ic_logo,
isFirst = false,
isLast = true
)

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