Compare commits

...

54 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 17:20:16 +05:00
shihaam 5cba468781 debug builds has debug suffix and different launcher icon color 2026-05-28 17:11:55 +05:00
shihaam ed2054fb81 fixbug that took user to empty dashboard without any accounts 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
Reviewed-on: shihaam/thijooree#5
2026-05-28 16:23:33 +05:00
shihaam a32841a319 compress images 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 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 2026-05-28 14:06:49 +05:00
shihaam 86063d600f Fix Bug that allowed lockscreen bypass on rooted androids 2026-05-28 13:41:39 +05:00
shihaam da85a31bc6 release version 1.0.9 2026-05-28 02:18:38 +05:00
shihaam d292e73fd9 added support for custom per-profile image for BML and Fahipay, MIB works pending 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 2026-05-28 01:14:20 +05:00
shihaam 6daeb5f72e Bug fix: contacts page infinite loading without internet 2026-05-28 00:19:22 +05:00
shihaam c4d3c1efd4 better network error handling, fix crash when no network in transaction history page 2026-05-28 00:14:11 +05:00
shihaam 0560c53ae3 Show no accounts found text when there are no accounts in cache 2026-05-27 23:42:05 +05:00
shihaam a37454de00 improve clearing cache and logout (it was showing logged-out account info on dashboard 2026-05-27 23:37:34 +05:00
shihaam daf9b0475a add zoom QR and flashlight button 2026-05-27 23:07:01 +05:00
shihaam c4ad35e6b9 Fix bug: transfer source drop down automatically closing to update profile image 2026-05-27 22:40:05 +05:00
shihaam 3e8ea90701 handle server timeouts instead of crashing 2026-05-27 22:14:31 +05:00
shihaam ef919aa179 show bank/profile image in accounts and drop down 2026-05-27 22:00:47 +05:00
shihaam c98a3e3e89 show card network in source account drop down 2026-05-27 21:35:27 +05:00
shihaam 0654c711d6 bug fix: nav bar buttons disappearing after some updates 2026-05-27 21:28:19 +05:00
shihaam b67368c94a unified pay with QR and tranfer confirm dialog box 2026-05-27 21:22:04 +05:00
shihaam a6e7e61b58 added support for QR payments from BML gateway 2026-05-27 21:08:01 +05:00
shihaam e974a95708 added support for static QR payments from BML cards 2026-05-27 20:32:17 +05:00
shihaam de11fbe0d3 skill issue on mib 2026-05-27 19:00:12 +05:00
shihaam 5d8ab76477 update docs 2026-05-27 18:35:42 +05:00
shihaam d637877167 update docs 2026-05-27 18:04:39 +05:00
shihaam ea227bf3b9 impprove ci performance 2026-05-24 00:29:40 +05:00
shihaam 6b3131069e update docs 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
Reviewed-on: shihaam/thijooree#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 2026-05-23 23:46:00 +05:00
shihaam 0a27de4a34 update bml api docs 2026-05-23 23:33:31 +05:00
shihaam a3f8852163 some android studio bs 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 2026-05-23 22:52:27 +05:00
shihaam f9c182fe9a fix weird error on failed pin 2026-05-23 22:47:47 +05:00
shihaam 339dae8a37 hide money value in transfer drop down with privacy mode on 2026-05-23 22:42:48 +05:00
shihaam a6a1f28144 disable transfer button when there is issue with source bank or connectivity 2026-05-23 22:31:27 +05:00
shihaam 523d1248bd add connetivity banners 2026-05-23 22:23:54 +05:00
shihaam ee9f98b720 fix caching reading issue when refreshed without internet 2026-05-23 21:49:27 +05:00
shihaam 219ca9bf00 add more card support, include credit cards in accounts 2026-05-23 21:21:01 +05:00
shihaam e9f0cec698 compress mib cards and add prep support for bml cards 2026-05-23 21:03:25 +05:00
shihaam 268f3dada0 fix useragents to give out actual device model os version and etc 2026-05-22 20:50:50 +05:00
shihaam e0a554c769 fix useragents to give out actual device model os version and etc 2026-05-22 06:53:07 +05:00
195 changed files with 7989 additions and 2380 deletions
+1 -3
View File
@@ -1,11 +1,9 @@
services: services:
release: release:
# image: git.shihaam.dev/dockerfiles/android-builder
image: git.shihaam.dev/dockerfiles/runners/gradle image: git.shihaam.dev/dockerfiles/runners/gradle
hostname: isodroid
network_mode: host network_mode: host
env_file: .env env_file: .env
volumes: volumes:
- ./release:/release - ./release:/release
- ../../:/source - ../../:/source
# - /root/.cache/cache-runners/gradle:/root/.gradle - /root/.cache/cache-runners/gradle:/root/.gradle
+1 -1
View File
@@ -3,7 +3,7 @@
<component name="deploymentTargetSelector"> <component name="deploymentTargetSelector">
<selectionStates> <selectionStates>
<SelectionState runConfigName="app"> <SelectionState runConfigName="app">
<option name="selectionMode" value="DIALOG" /> <option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-05-22T00:11:32.873305232Z"> <DropdownSelection timestamp="2026-05-22T00:11:32.873305232Z">
<Target type="DEFAULT_BOOT"> <Target type="DEFAULT_BOOT">
<handle> <handle>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="MarkdownSettings">
<option name="previewPanelProviderInfo">
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
</option>
</component>
</project>
+9 -50
View File
@@ -1,6 +1,6 @@
# BasedBank # Thijooree
A unified Android banking app for Maldivians that combines MIB (Faisanet), BML (Bank of Maldives), and Fahipay into a single interface — with no analytics, no tracking, and no phone-home behaviour outside the banks themselves. A native Android client for Maldivian banking services. It is a pure client: requests go directly from your device to the banks' own servers using the same protocols as their official apps. No proxy, no backend, no middleman.
[![AI Slop Inside](https://sladge.net/badge.svg)](https://sladge.net) [![AI Slop Inside](https://sladge.net/badge.svg)](https://sladge.net)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE)
@@ -8,60 +8,14 @@ A unified Android banking app for Maldivians that combines MIB (Faisanet), BML (
![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?logo=jetpackcompose&logoColor=white) ![Jetpack Compose](https://img.shields.io/badge/Jetpack%20Compose-4285F4?logo=jetpackcompose&logoColor=white)
![Maintained](https://img.shields.io/badge/Maintained-yes-green.svg) ![Maintained](https://img.shields.io/badge/Maintained-yes-green.svg)
## What it does
- **Multi-bank dashboard** — view balances across all your MIB, BML, and Fahipay accounts in one place, with a combined MVR and USD total
- **Transaction history** — paginated, searchable transaction history per account for MIB CASA, BML CASA, BML prepaid cards, and Fahipay wallet
- **Transfers** — send money between accounts and to saved contacts; supports MIB-to-MIB, BML-to-BML, and cross-bank (MIB↔BML via FAVARA)
- **Contacts** — manage saved beneficiaries across all banks; validates Dhiraagu and Ooredoo numbers and shows the account owner name before you add
- **Fahipay** — full wallet support including balance, history with merchant icons, and Fahipay favourites (Raastas, Reload, Ooredoo Bill, Dhiraagu Bill)
- **QR payments** — scan PayMV QR codes to pre-fill transfers
- **BML foreign limits** — view your foreign currency spending allowances and breakdowns by ATM / POS / ECOM
- **MIB financing** — view active financing deals
## Authentication
The app requires your existing credentials for each bank — the same username/password/OTP seed you use with the official apps. It stores them encrypted using AES-256-GCM backed by the Android Keystore (hardware secure enclave).
Each bank's 2FA uses TOTP, so you need to have your OTP seed (the same secret used by your authenticator app).
## Security
- All credentials encrypted at rest with **AES-256-GCM** (Android Keystore)
- Lock screen protected by **PBKDF2-HMAC-SHA256** (100,000 iterations) with optional biometric unlock
- **FLAG_SECURE** on by default — content hidden in app switcher and screenshots blocked
- All sensitive data excluded from Android cloud backup
- Zero analytics, crash reporters, or third-party SDKs — network traffic goes only to MIB, BML, Fahipay, and the Maldivian telecoms for number validation
See [`docs/AI_SECURITY_CHECK.md`](docs/AI_SECURITY_CHECK.md) for the full security audit.
## Supported banks
| Bank | Login | Accounts | History | Transfers | Contacts |
|---|---|---|---|---|---|
| MIB (Faisanet) | username + password + TOTP | ✓ | ✓ | ✓ | ✓ |
| BML (Bank of Maldives) | username + password + TOTP | ✓ | ✓ | ✓ | ✓ |
| Fahipay | national ID + password + TOTP | ✓ | ✓ | — | ✓ (favourites) |
## Requirements ## Requirements
- Android 8.0+ (API 26) - Android 8.0+ (API 26)
- Existing accounts with MIB, BML, or Fahipay - Existing accounts with MIB, BML, or Fahipay
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank - Your TOTP seed (base32 secret from your authenticator app setup) for each bank
## Building ## Download
[Download latest APK](https://git.shihaam.dev/shihaam/ISODroid/releases/latest)
Open in Android Studio and run. No API keys or secrets required — all protocol constants are derived from the official apps and are included in the source.
The release signing config reads from environment variables (`KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`).
## How it works
BasedBank talks directly to each bank's existing mobile API using the same protocol as their official apps, reverse-engineered from the APKs. It does not use any intermediary server — requests go straight from your device to the bank.
- **MIB**: Blowfish/ECB encrypted JSON over HTTPS with a Diffie-Hellman session key exchange
- **BML**: PKCE OAuth 2.0 flow via the BML web login, exchanged for a Bearer token used on the mobile API
- **Fahipay**: multipart form login with TOTP, session maintained via `__Secure-sess` cookie and `authid` header
## Privacy ## Privacy
@@ -70,3 +24,8 @@ No data ever leaves your device except the API calls to the banking services the
## Disclaimer ## Disclaimer
This is an unofficial third-party app. It is not affiliated with, endorsed by, or supported by MIB, BML, or Fahipay. Use at your own risk. Review the source code before entering your banking credentials. This is an unofficial third-party app. It is not affiliated with, endorsed by, or supported by MIB, BML, or Fahipay. Use at your own risk. Review the source code before entering your banking credentials.
## License
GNU General Public License v3.0 - See [LICENSE](LICENSE) file for details
+6 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "sh.sar.basedbank" applicationId = "sh.sar.basedbank"
minSdk = 26 minSdk = 26
targetSdk = 36 targetSdk = 36
versionCode = 6 versionCode = 8
versionName = "1.0.7" versionName = "1.0.9"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -27,6 +27,10 @@ android {
} }
buildTypes { buildTypes {
debug {
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
}
release { release {
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false 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 <application
android:name=".BasedBankApp" android:name=".BasedBankApp"
android:allowBackup="true" android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
@@ -30,6 +30,9 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
</activity> </activity>
<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.

Before

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 633 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

@@ -16,6 +16,13 @@ import sh.sar.basedbank.util.CredentialStore
class BasedBankApp : Application() { 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 // Held in memory after successful login; cleared on logout
var accounts: List<BankAccount> = emptyList() var accounts: List<BankAccount> = emptyList()
var fullName: String = "" var fullName: String = ""
@@ -21,6 +21,7 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.databinding.ActivityLockBinding import sh.sar.basedbank.databinding.ActivityLockBinding
import sh.sar.basedbank.ui.home.HomeActivity import sh.sar.basedbank.ui.home.HomeActivity
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.BasedBankApp
import javax.crypto.SecretKeyFactory import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec import javax.crypto.spec.PBEKeySpec
@@ -203,15 +204,15 @@ class LockActivity : AppCompatActivity() {
if (remaining <= 0) return false if (remaining <= 0) return false
val secs = ((remaining + 999L) / 1000L).toInt() val secs = ((remaining + 999L) / 1000L).toInt()
val msg = getString(R.string.unlock_locked_out, secs) val msg = getString(R.string.unlock_locked_out, secs)
binding.tvLockPinDots.text = msg binding.tvPinHint.text = msg
binding.root.postDelayed({ updateDots() }, remaining) binding.root.postDelayed({ binding.tvPinHint.text = ""; updateDots() }, remaining)
return true return true
} }
private fun showFailure() { private fun showFailure() {
val msg = failureMessage() val msg = failureMessage()
binding.tvLockPinDots.text = msg binding.tvPinHint.text = msg
binding.root.postDelayed({ updateDots() }, 1200) binding.root.postDelayed({ binding.tvPinHint.text = ""; updateDots() }, 1200)
} }
private fun failureMessage(): String { private fun failureMessage(): String {
@@ -259,10 +260,23 @@ class LockActivity : AppCompatActivity() {
} }
private fun proceed() { private fun proceed() {
(application as BasedBankApp).isUnlocked = true
if (intent.getBooleanExtra(EXTRA_RESUME, false)) { if (intent.getBooleanExtra(EXTRA_RESUME, false)) {
finish() finish()
} else { } 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() finish()
} }
} }
@@ -7,24 +7,43 @@ import sh.sar.basedbank.ui.home.HomeActivity
import sh.sar.basedbank.ui.login.LoginActivity import sh.sar.basedbank.ui.login.LoginActivity
import sh.sar.basedbank.ui.onboarding.OnboardingActivity import sh.sar.basedbank.ui.onboarding.OnboardingActivity
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val prefs = getSharedPreferences("prefs", MODE_PRIVATE) val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
val onboardingDone = prefs.getBoolean("onboarding_done", false) val onboardingDone = prefs.getBoolean("onboarding_done", false)
val securitySet = prefs.getString("security_method", null) != null val securitySet = prefs.getString("security_method", null) != null
val store = CredentialStore(this) val store = CredentialStore(this)
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials() 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 { val target = when {
!onboardingDone -> OnboardingActivity::class.java !onboardingDone -> OnboardingActivity::class.java
!hasCredentials -> LoginActivity::class.java !hasCredentials -> LoginActivity::class.java
securitySet -> LockActivity::class.java // proceed() → HomeActivity securitySet -> LockActivity::class.java // proceed() → HomeActivity
else -> HomeActivity::class.java 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() finish()
} }
} }
@@ -2,6 +2,7 @@ package sh.sar.basedbank.api.bml
import org.json.JSONObject import org.json.JSONObject
import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankServerException
data class BmlUserInfo( data class BmlUserInfo(
val fullName: String, val fullName: String,
@@ -27,6 +28,7 @@ class BmlAccountClient {
val json = resp.body?.string() val json = resp.body?.string()
resp.close() resp.close()
if (code == 401 || code == 419) throw AuthExpiredException() if (code == 401 || code == 419) throw AuthExpiredException()
if (code in 500..599) throw BankServerException("BML")
return parseDashboard(json ?: return emptyList(), loginTag, profileName, profileId) return parseDashboard(json ?: return emptyList(), loginTag, profileName, profileId)
} }
@@ -36,6 +38,7 @@ class BmlAccountClient {
val code = resp.code val code = resp.code
resp.close() resp.close()
if (code == 401 || code == 419) throw AuthExpiredException() if (code == 401 || code == 419) throw AuthExpiredException()
if (code in 500..599) throw BankServerException("BML")
} }
fun fetchUserInfo(session: BmlSession): BmlUserInfo? { fun fetchUserInfo(session: BmlSession): BmlUserInfo? {
@@ -166,16 +169,22 @@ class BmlAccountClient {
internalId = internalId internalId = internalId
)) ))
} else if (accountType == "Card") { } else if (accountType == "Card") {
val isVisible = item.optBoolean("account_visible", false)
if (!isVisible) continue
val isPrepaid = item.optBoolean("prepaid_card", false) val isPrepaid = item.optBoolean("prepaid_card", false)
val productCode = item.optString("product_code", "")
val cardBalance = item.optJSONObject("cardBalance") val cardBalance = item.optJSONObject("cardBalance")
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0 val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0 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( prepaidCards.add(BankAccount(
bank = "BML", bank = "BML",
profileName = profileName, profileName = profileName,
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT", profileType = cardProfileType,
productCode = productCode,
accountNumber = accountNumber, accountNumber = accountNumber,
accountBriefName = item.optString("alias").ifBlank { product }, accountBriefName = item.optString("alias").ifBlank { product },
currencyName = currency, currencyName = currency,
@@ -1,11 +1,12 @@
package sh.sar.basedbank.api.bml package sh.sar.basedbank.api.bml
import android.os.Build
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking" 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 const val BML_APP_VERSION = "2.1.44.348"
internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder() internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
@@ -4,6 +4,7 @@ import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import sh.sar.basedbank.api.models.BankTransaction import sh.sar.basedbank.api.models.BankTransaction
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Locale import java.util.Locale
@@ -30,6 +31,7 @@ class BmlHistoryClient {
val json = resp.body?.string() ?: return Pair(emptyList(), 0) val json = resp.body?.string() ?: return Pair(emptyList(), 0)
resp.close() resp.close()
if (code == 401 || code == 419) throw AuthExpiredException() if (code == 401 || code == 419) throw AuthExpiredException()
if (code in 500..599) throw BankServerException("BML")
return try { return try {
val root = JSONObject(json) val root = JSONObject(json)
if (!root.optBoolean("success")) return Pair(emptyList(), 0) if (!root.optBoolean("success")) return Pair(emptyList(), 0)
@@ -82,6 +84,7 @@ class BmlHistoryClient {
val json = resp.body?.string() ?: return emptyList() val json = resp.body?.string() ?: return emptyList()
resp.close() resp.close()
if (code == 401 || code == 419) throw AuthExpiredException() if (code == 401 || code == 419) throw AuthExpiredException()
if (code in 500..599) throw BankServerException("BML")
return try { return try {
val root = JSONObject(json) val root = JSONObject(json)
if (!root.optBoolean("success")) return emptyList() if (!root.optBoolean("success")) return emptyList()
@@ -25,9 +25,9 @@ class BmlLoginFlow {
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking" private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7" private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback" private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
private val APP_USER_AGENT = "bml-mobile-banking/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 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 cookieStore = mutableMapOf<String, MutableList<Cookie>>()
private val cookieJar = object : CookieJar { private val cookieJar = object : CookieJar {
@@ -66,6 +66,22 @@ data class BmlLoanDetail(
val overdueAmount: Double val overdueAmount: Double
) )
data class BmlQrPayInfo(
val requestId: String, // base64-encoded full QR URL (trxn_hash)
val merchantName: String, // narrative1
val merchantAddress: String, // narrative2 + narrative3
val amount: Double, // 0.0 for static QR
val currency: String
)
data class BmlQrPayResult(
val success: Boolean,
val merchant: String = "",
val amount: String = "",
val currency: String = "",
val errorMessage: String = ""
)
data class BmlForeignLimit( data class BmlForeignLimit(
val type: String, val type: String,
val used: Double, val used: Double,
@@ -0,0 +1,153 @@
package sh.sar.basedbank.api.bml
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
class BmlQrPayClient {
private val client = newBmlApiClient()
/**
* Resolves a BML QR URL to merchant details.
* [base64Url] is the full QR URL Base64-encoded (standard, with padding).
*/
fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo {
val request = bmlApiRequest(session,
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url")
return client.newCall(request).execute().use { response ->
val body = response.body?.string() ?: throw Exception("No response")
val json = JSONObject(body)
if (!json.optBoolean("success"))
throw Exception(json.optString("message").ifBlank { "Lookup failed" })
val payload = json.getJSONObject("payload")
val addr2 = payload.optString("narrative2").trim()
val addr3 = payload.optString("narrative3").trim()
val address = listOf(addr2, addr3).filter { it.isNotBlank() }.joinToString(", ")
BmlQrPayInfo(
requestId = payload.optString("trxn_hash"),
merchantName = payload.optString("narrative1").trim(),
merchantAddress = address,
amount = payload.optString("amount").toDoubleOrNull() ?: 0.0,
currency = payload.optString("currency").ifBlank { "MVR" }
)
}
}
/**
* Pre-initiate step required for gateway QR (pay.bml.com.mv).
* POST without channel expects code 99 (OTP channel selection required).
*/
fun preInitiatePayment(
session: BmlSession,
debitAccount: String,
requestId: String,
amount: Double,
currency: String
): Boolean {
val jo = JSONObject().apply {
put("action", "approve")
put("debitAccount", debitAccount)
put("requestId", requestId)
put("amount", amount)
put("currency", currency)
}
val request = Request.Builder()
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
.post(jo.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("accept", "application/json")
.build()
return client.newCall(request).execute().use { response ->
val body = response.body?.string() ?: return@use false
val json = try { JSONObject(body) } catch (_: Exception) { return@use false }
json.optBoolean("success") && json.optInt("code") == 99
}
}
/**
* Step 1 initiate: POST with channel but no OTP.
* Returns true when server responds with code 22 (OTP generated).
*/
fun initiatePayment(
session: BmlSession,
debitAccount: String,
requestId: String,
amount: Double,
currency: String
): Boolean {
val jo = JSONObject().apply {
put("action", "approve")
put("debitAccount", debitAccount)
put("requestId", requestId)
put("amount", amount)
put("currency", currency)
put("channel", "token")
}
val request = Request.Builder()
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
.post(jo.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("accept", "application/json")
.build()
return client.newCall(request).execute().use { response ->
val body = response.body?.string() ?: return@use false
val json = try { JSONObject(body) } catch (_: Exception) { return@use false }
json.optBoolean("success") && json.optInt("code") == 22
}
}
/**
* Step 2 confirm: POST with channel + OTP.
* Returns [BmlQrPayResult] with success/error.
*/
fun confirmPayment(
session: BmlSession,
debitAccount: String,
requestId: String,
amount: Double,
currency: String,
otp: String
): BmlQrPayResult {
val jo = JSONObject().apply {
put("action", "approve")
put("debitAccount", debitAccount)
put("requestId", requestId)
put("amount", amount)
put("currency", currency)
put("channel", "token")
put("otp", otp)
}
val request = Request.Builder()
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
.post(jo.toString().toRequestBody("application/json".toMediaType()))
.header("Authorization", "Bearer ${session.accessToken}")
.header("User-Agent", BML_USER_AGENT)
.header("x-app-version", BML_APP_VERSION)
.header("accept", "application/json")
.build()
return client.newCall(request).execute().use { response ->
val body = response.body?.string()
?: return@use BmlQrPayResult(false, errorMessage = "No response")
val json = try { JSONObject(body) } catch (_: Exception) {
return@use BmlQrPayResult(false, errorMessage = "Parse error")
}
if (!json.optBoolean("success")) {
BmlQrPayResult(false, errorMessage = json.optString("message").ifBlank { "Payment failed" })
} else {
val payload = json.optJSONObject("payload")
BmlQrPayResult(
success = true,
merchant = payload?.optString("merchant") ?: "",
amount = payload?.optString("amount") ?: "",
currency = payload?.optString("currency") ?: currency
)
}
}
}
}
@@ -4,6 +4,7 @@ import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.json.JSONObject import org.json.JSONObject
import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.models.BankServerException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class FahipayAccountClient { class FahipayAccountClient {
@@ -27,8 +28,10 @@ class FahipayAccountClient {
Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en") Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en")
.auth(session).build() .auth(session).build()
).execute() ).execute()
val code = resp.code
val json = resp.body?.string() ?: throw Exception("Empty profile response") val json = resp.body?.string() ?: throw Exception("Empty profile response")
resp.close() resp.close()
if (code in 500..599) throw BankServerException("Fahipay")
val obj = JSONObject(json) val obj = JSONObject(json)
val props = obj.optJSONObject("props") ?: JSONObject() val props = obj.optJSONObject("props") ?: JSONObject()
return FahipayUserProfile( return FahipayUserProfile(
@@ -47,8 +50,10 @@ class FahipayAccountClient {
Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en") Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en")
.auth(session).build() .auth(session).build()
).execute() ).execute()
val code = resp.code
val json = resp.body?.string() ?: return 0.0 val json = resp.body?.string() ?: return 0.0
resp.close() resp.close()
if (code in 500..599) throw BankServerException("Fahipay")
return try { return try {
val obj = JSONObject(json) val obj = JSONObject(json)
if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0) 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.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.json.JSONObject import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import sh.sar.basedbank.api.models.BankTransaction import sh.sar.basedbank.api.models.BankTransaction
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -32,8 +33,10 @@ class FahipayHistoryClient {
.header("User-Agent", UA) .header("User-Agent", UA)
.build() .build()
).execute() ).execute()
val code = resp.code
val json = resp.body?.string() ?: return Pair(emptyList(), 0) val json = resp.body?.string() ?: return Pair(emptyList(), 0)
resp.close() resp.close()
if (code in 500..599) throw BankServerException("Fahipay")
return try { return try {
val obj = JSONObject(json) val obj = JSONObject(json)
val total = obj.optInt("total", 0) val total = obj.optInt("total", 0)
@@ -16,7 +16,7 @@ import java.util.concurrent.TimeUnit
class FahipayLoginFlow { class FahipayLoginFlow {
private val BASE_URL = "https://fahipay.mv" private val BASE_URL = "https://fahipay.mv"
private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android 14; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36" private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>() private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
private val cookieJar = object : CookieJar { private val cookieJar = object : CookieJar {
@@ -1,5 +1,6 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -31,7 +32,7 @@ class MibCardsClient {
.url("$BASE_WV_URL/ajaxDebitCard/fetchCardInfos") .url("$BASE_WV_URL/ajaxDebitCard/fetchCardInfos")
.post(body) .post(body)
.header("Cookie", cookieHeader(session)) .header("Cookie", cookieHeader(session))
.header("User-Agent", "Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36") .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("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*") .header("Accept", "*/*")
.header("Origin", BASE_WV_URL) .header("Origin", BASE_WV_URL)
@@ -1,5 +1,6 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -24,7 +25,7 @@ class MibContactsClient {
.header("Cookie", cookieHeader(session)) .header("Cookie", cookieHeader(session))
.header( .header(
"User-Agent", "User-Agent",
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36" "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
) )
.header("X-Requested-With", "XMLHttpRequest") .header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*") .header("Accept", "*/*")
@@ -1,5 +1,6 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -27,7 +28,7 @@ class MibFinancingClient {
.header("Cookie", cookieHeader) .header("Cookie", cookieHeader)
.header( .header(
"User-Agent", "User-Agent",
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36" "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
) )
.header("X-Requested-With", "mv.com.mib.faisamobilex") .header("X-Requested-With", "mv.com.mib.faisamobilex")
.get() .get()
@@ -1,9 +1,11 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.json.JSONObject import org.json.JSONObject
import sh.sar.basedbank.api.models.BankServerException
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class MibHistoryClient { class MibHistoryClient {
@@ -50,7 +52,7 @@ class MibHistoryClient {
.header("Cookie", cookieHeader(session)) .header("Cookie", cookieHeader(session))
.header( .header(
"User-Agent", "User-Agent",
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36" "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
) )
.header("X-Requested-With", "XMLHttpRequest") .header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*") .header("Accept", "*/*")
@@ -59,6 +61,7 @@ class MibHistoryClient {
.build() .build()
return client.newCall(request).execute().use { response -> return client.newCall(request).execute().use { response ->
if (response.code in 500..599) throw BankServerException("MIB")
val bodyStr = response.body?.string() ?: return Pair(emptyList(), 0) val bodyStr = response.body?.string() ?: return Pair(emptyList(), 0)
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return Pair(emptyList(), 0) } val json = try { JSONObject(bodyStr) } catch (_: Exception) { return Pair(emptyList(), 0) }
if (!json.optBoolean("success")) return Pair(emptyList(), 0) if (!json.optBoolean("success")) return Pair(emptyList(), 0)
@@ -9,6 +9,7 @@ import org.json.JSONObject
import java.security.MessageDigest import java.security.MessageDigest
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.abs
import kotlin.random.Random import kotlin.random.Random
class SessionExpiredException : Exception("MIB session expired") class SessionExpiredException : Exception("MIB session expired")
@@ -39,7 +40,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
.connectTimeout(30, TimeUnit.SECONDS) .connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS)
.addInterceptor { chain -> .addNetworkInterceptor { chain ->
val req = chain.request().newBuilder() val req = chain.request().newBuilder()
.header("User-Agent", "android/1.0") .header("User-Agent", "android/1.0")
.header("Accept", "application/json") .header("Accept", "application/json")
@@ -161,14 +162,14 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
bank = "MIB", bank = "MIB",
profileName = profile.name, profileName = profile.name,
profileType = profile.profileType, profileType = profile.profileType,
cifType = profile.cifType, productCode = profile.cifType,
accountNumber = a.optString("accountNumber"), accountNumber = a.optString("accountNumber"),
accountBriefName = a.optString("accountBriefName"), accountBriefName = a.optString("accountBriefName"),
currencyName = a.optString("currencyName"), currencyName = a.optString("currencyName"),
accountTypeName = a.optString("accountTypeName"), accountTypeName = a.optString("accountTypeName"),
availableBalance = a.optString("availableBalance"), availableBalance = a.optString("availableBalance"),
currentBalance = a.optString("currentBalance"), currentBalance = a.optString("currentBalance"),
blockedAmount = a.optString("blockedAmount"), blockedAmount = absBlockedAmount(a.optString("blockedAmount")),
mvrBalance = a.optString("mvrBalance"), mvrBalance = a.optString("mvrBalance"),
statusDesc = a.optString("statusDesc"), statusDesc = a.optString("statusDesc"),
profileImageHash = profile.customerImage, profileImageHash = profile.customerImage,
@@ -188,6 +189,13 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
// ─── Helpers ───────────────────────────────────────────────────────────── // ─── 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( private fun initialKeyExchange(
appId: String, encKey: String, sfunc: String, key2: String? = null appId: String, encKey: String, sfunc: String, key2: String? = null
): Pair<MibSession, String> { ): Pair<MibSession, String> {
@@ -318,14 +326,14 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
bank = "MIB", bank = "MIB",
profileName = profile.name, profileName = profile.name,
profileType = profile.profileType, profileType = profile.profileType,
cifType = profile.cifType, productCode = profile.cifType,
accountNumber = a.optString("accountNumber"), accountNumber = a.optString("accountNumber"),
accountBriefName = a.optString("accountBriefName"), accountBriefName = a.optString("accountBriefName"),
currencyName = a.optString("currencyName"), currencyName = a.optString("currencyName"),
accountTypeName = a.optString("accountTypeName"), accountTypeName = a.optString("accountTypeName"),
availableBalance = a.optString("availableBalance"), availableBalance = a.optString("availableBalance"),
currentBalance = a.optString("currentBalance"), currentBalance = a.optString("currentBalance"),
blockedAmount = a.optString("blockedAmount"), blockedAmount = absBlockedAmount(a.optString("blockedAmount")),
mvrBalance = a.optString("mvrBalance"), mvrBalance = a.optString("mvrBalance"),
statusDesc = a.optString("statusDesc"), statusDesc = a.optString("statusDesc"),
profileImageHash = profile.customerImage, profileImageHash = profile.customerImage,
@@ -366,6 +374,34 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
return resp.optString("profileImage").takeIf { it.isNotBlank() } return resp.optString("profileImage").takeIf { it.isNotBlank() }
} }
/**
* Uploads a profile image via P40.
* [imageBase64] is a base64-encoded JPEG string.
* Returns the new imageHash on success, or null on failure.
*/
fun uploadProfileImage(session: MibSession, profile: MibProfile, imageBase64: String): String? {
val payload = baseData(session, "P40").apply {
put("profileId", profile.profileId)
put("profileImage", imageBase64)
}
val resp = doRequest(session, payload, "n")
if (!resp.optBoolean("success", false)) return null
return resp.optString("imageHash").takeIf { it.isNotBlank() }
?: resp.optString("customerImage").takeIf { it.isNotBlank() }
}
/**
* Deletes the profile image via P42.
* Returns true on success.
*/
fun deleteProfileImage(session: MibSession, profile: MibProfile): Boolean {
val payload = baseData(session, "P42").apply {
put("profileId", profile.profileId)
}
val resp = doRequest(session, payload, "n")
return resp.optBoolean("success", false)
}
private fun post(body: FormBody): String { private fun post(body: FormBody): String {
val request = Request.Builder() val request = Request.Builder()
.url(BASE_URL) .url(BASE_URL)
@@ -373,6 +409,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
.build() .build()
val response = client.newCall(request).execute() val response = client.newCall(request).execute()
if (response.code == 419) throw SessionExpiredException() if (response.code == 419) throw SessionExpiredException()
if (response.code in 500..599) throw sh.sar.basedbank.api.models.BankServerException("MIB")
return response.body?.string() ?: throw IllegalStateException("Empty response body") return response.body?.string() ?: throw IllegalStateException("Empty response body")
} }
@@ -1,5 +1,6 @@
package sh.sar.basedbank.api.mib package sh.sar.basedbank.api.mib
import android.os.Build
import okhttp3.FormBody import okhttp3.FormBody
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -26,7 +27,7 @@ class MibTransferClient {
.header("Cookie", cookieHeader(session)) .header("Cookie", cookieHeader(session))
.header( .header(
"User-Agent", "User-Agent",
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36" "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
) )
.header("X-Requested-With", "XMLHttpRequest") .header("X-Requested-With", "XMLHttpRequest")
.header("Accept", "*/*") .header("Accept", "*/*")
@@ -1,5 +1,8 @@
package sh.sar.basedbank.api.models 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, ...). * Unified account model used across all banks (MIB, BML, Fahipay, ...).
* The [bank] field identifies which bank owns this account. * 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 bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow
val profileName: String, val profileName: String,
val profileType: 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 accountNumber: String,
val accountBriefName: String, val accountBriefName: String,
val currencyName: String, val currencyName: String,
@@ -23,6 +23,7 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.models.BankAccount 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.mib.MibContactsClient
import sh.sar.basedbank.api.models.BankTransaction import sh.sar.basedbank.api.models.BankTransaction
import sh.sar.basedbank.api.mib.TransactionCache import sh.sar.basedbank.api.mib.TransactionCache
@@ -141,6 +142,8 @@ class AccountHistoryFragment : Fragment() {
} }
adapter.setTransactions(filtered) adapter.setTransactions(filtered)
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
binding.skeletonContainer.visibility =
if (allTransactions.isEmpty() && isLoading) View.VISIBLE else View.GONE
} }
private fun resetAndReload() { private fun resetAndReload() {
@@ -149,8 +152,15 @@ class AccountHistoryFragment : Fragment() {
pendingIconUrls.clear() pendingIconUrls.clear()
firstPageDone = false firstPageDone = false
fetcher = HistoryFetcher(account) fetcher = HistoryFetcher(account)
adapter.setTransactions(emptyList()) // Restore cache immediately so data stays visible while refreshing
binding.emptyView.visibility = View.GONE val cached = TransactionCache.load(requireContext(), account.accountNumber)
if (cached.isNotEmpty()) {
allTransactions.addAll(cached)
filterAndDisplay()
} else {
adapter.setTransactions(emptyList())
binding.emptyView.visibility = View.GONE
}
loadNextPage() loadNextPage()
} }
@@ -160,14 +170,29 @@ class AccountHistoryFragment : Fragment() {
if (firstPageDone && allTransactions.isNotEmpty()) { if (firstPageDone && allTransactions.isNotEmpty()) {
adapter.showLoadingFooter = true adapter.showLoadingFooter = true
} else if (allTransactions.isEmpty()) {
binding.skeletonContainer.visibility = View.VISIBLE
binding.emptyView.visibility = View.GONE
} }
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
lifecycleScope.launch { 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 isLoading = false
if (_binding == null) return@launch
binding.skeletonContainer.visibility = View.GONE
if (!firstPageDone) { if (!firstPageDone) {
firstPageDone = true firstPageDone = true
@@ -175,6 +200,13 @@ class AccountHistoryFragment : Fragment() {
binding.swipeRefresh.isRefreshing = false binding.swipeRefresh.isRefreshing = false
} }
if (transactions == null) {
adapter.showLoadingFooter = false
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
return@launch
}
(activity as? HomeActivity)?.hideConnectivityBanner()
if (transactions.isNotEmpty()) { if (transactions.isNotEmpty()) {
val existingIds = allTransactions.map { it.id }.toHashSet() val existingIds = allTransactions.map { it.id }.toHashSet()
val newOnes = transactions.filter { it.id !in existingIds } val newOnes = transactions.filter { it.id !in existingIds }
@@ -3,11 +3,13 @@ package sh.sar.basedbank.ui.home
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.ItemAccountBinding import sh.sar.basedbank.databinding.ItemAccountBinding
import sh.sar.basedbank.databinding.ItemCardBinding import sh.sar.basedbank.databinding.ItemCardBinding
@@ -17,7 +19,11 @@ import sh.sar.basedbank.util.AccountListParser
class AccountsAdapter( class AccountsAdapter(
accounts: List<BankAccount>, 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>() { ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
var onTransferClick: ((BankAccount) -> Unit)? = null var onTransferClick: ((BankAccount) -> Unit)? = null
@@ -72,7 +78,7 @@ class AccountsAdapter(
else -> account.bank else -> account.bank
} }
val profileLabel = when (account.bank) { val profileLabel = when (account.bank) {
"MIB" -> account.cifType.ifBlank { account.profileName } "MIB" -> account.productCode.ifBlank { account.profileName }
else -> account.profileName else -> account.profileName
} }
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
@@ -112,17 +118,51 @@ class AccountsAdapter(
private inner class AccountViewHolder(private val binding: ItemAccountBinding) : private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
RecyclerView.ViewHolder(binding.root) { RecyclerView.ViewHolder(binding.root) {
private var boundHash: String? = null
fun bind(account: BankAccount, display: AccountListDisplay) { fun bind(account: BankAccount, display: AccountListDisplay) {
binding.tvAccountName.text = display.name binding.tvAccountName.text = display.name
binding.tvAccountNumber.text = display.number binding.tvAccountNumber.text = display.number
binding.tvAccountType.text = display.typeLabel binding.tvAccountType.text = display.typeLabel
binding.tvBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance 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.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
binding.root.setOnClickListener { onAccountClick(account) } binding.root.setOnClickListener { onAccountClick(account) }
binding.root.setOnLongClickListener { binding.root.setOnLongClickListener {
copyToClipboard(it.context, display.number) copyToClipboard(it.context, display.number)
true true
} }
val staticLogo = when (account.bank) {
"BML" -> R.drawable.bml_logo_vector
"FAHIPAY" -> R.drawable.fahipay_logo
"MIB" -> R.drawable.mib_logo
else -> null
}
if (staticLogo != null) binding.ivBankLogo.setImageResource(staticLogo)
else binding.ivBankLogo.setImageDrawable(null)
val hash = account.profileImageHash
boundHash = hash
when {
account.bank == "MIB" && hash != null && profileImageLoader != null -> {
profileImageLoader.invoke(hash) { bitmap ->
if (boundHash == hash) binding.ivBankLogo.setImageBitmap(bitmap)
}
}
(account.bank == "BML" || account.bank == "FAHIPAY") && localProfileImageLoader != null -> {
localProfileImageLoader.invoke(account.loginTag, account.profileId) { bitmap ->
if (boundHash == hash) binding.ivBankLogo.setImageBitmap(bitmap)
}
}
}
} }
} }
@@ -1,6 +1,9 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Bundle import android.os.Bundle
import android.util.Base64
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@@ -8,9 +11,15 @@ import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.FragmentAccountsBinding import sh.sar.basedbank.databinding.FragmentAccountsBinding
import sh.sar.basedbank.util.ProfileImageStore
class AccountsFragment : Fragment() { class AccountsFragment : Fragment() {
@@ -18,6 +27,7 @@ class AccountsFragment : Fragment() {
private val binding get() = _binding!! private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels() private val viewModel: HomeViewModel by activityViewModels()
private lateinit var adapter: AccountsAdapter private lateinit var adapter: AccountsAdapter
private val profileImageCache = mutableMapOf<String, Bitmap>()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentAccountsBinding.inflate(inflater, container, false) _binding = FragmentAccountsBinding.inflate(inflater, container, false)
@@ -25,9 +35,49 @@ class AccountsFragment : Fragment() {
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = AccountsAdapter(emptyList()) { account -> val app = requireActivity().application as BasedBankApp
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account)) adapter = AccountsAdapter(
} accounts = emptyList(),
onAccountClick = { account ->
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
},
profileImageLoader = { hash, onLoaded ->
profileImageCache[hash]?.let { onLoaded(it); return@AccountsAdapter }
viewLifecycleOwner.lifecycleScope.launch {
val bitmap = withContext(Dispatchers.IO) {
try {
val session = app.anyMibSession() ?: return@withContext null
val b64 = app.anyMibFlow()?.fetchProfileImage(session, hash) ?: return@withContext null
val bytes = Base64.decode(b64, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
} catch (_: Exception) { null }
}
if (bitmap != null) {
profileImageCache[hash] = bitmap
onLoaded(bitmap)
}
}
},
localProfileImageLoader = { loginTag, profileId, onLoaded ->
val cacheKey = "$loginTag|$profileId"
profileImageCache[cacheKey]?.let { onLoaded(it); return@AccountsAdapter }
viewLifecycleOwner.lifecycleScope.launch {
val bitmap = withContext(Dispatchers.IO) {
val ctx = requireContext()
if (loginTag.startsWith("bml_") && profileId.isNotBlank()) {
ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(profileId))
} else if (loginTag.startsWith("fahipay_")) {
val loginId = ProfileImageStore.loginIdFromTag(loginTag)
ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
} else null
}
if (bitmap != null) {
profileImageCache[cacheKey] = bitmap
onLoaded(bitmap)
}
}
}
)
adapter.onTransferClick = { account -> adapter.onTransferClick = { account ->
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(account)) (activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(account))
} }
@@ -43,7 +93,10 @@ class AccountsFragment : Fragment() {
insets 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) } viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
binding.swipeRefresh.setOnRefreshListener { binding.swipeRefresh.setOnRefreshListener {
@@ -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}"
} ?: ""
}
}
}
@@ -1,116 +1,3 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.content.Context // Merged into CardsFragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.databinding.FragmentCardSettingsBinding
class CardSettingsFragment : Fragment() {
private var _binding: FragmentCardSettingsBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentCardSettingsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val adapter = CardSettingsAdapter(emptyList(), requireContext())
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
binding.recyclerView.adapter = adapter
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
insets
}
binding.swipeRefresh.setOnRefreshListener {
(activity as? HomeActivity)?.triggerRefreshCards()
}
viewModel.mibCards.observe(viewLifecycleOwner) { cards ->
if (cards == null) return@observe
adapter.update(cards)
binding.loadingView.visibility = View.GONE
binding.swipeRefresh.isRefreshing = false
binding.emptyView.visibility = if (cards.isEmpty()) View.VISIBLE else View.GONE
binding.recyclerView.visibility = if (cards.isEmpty()) View.GONE else View.VISIBLE
}
if (viewModel.mibCards.value == null) {
binding.loadingView.visibility = View.VISIBLE
(activity as? HomeActivity)?.triggerRefreshCards()
}
}
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_card_settings)
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
private inner class CardSettingsAdapter(
private var cards: List<MibCard>,
private val context: Context
) : RecyclerView.Adapter<CardSettingsAdapter.VH>() {
fun update(newCards: List<MibCard>) {
cards = newCards
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
VH(LayoutInflater.from(context).inflate(R.layout.item_card_settings_entry, parent, false))
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position])
override fun getItemCount() = cards.size
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
private val tvCardType: TextView = view.findViewById(R.id.tvCardType)
private val btnChangePin: View = view.findViewById(R.id.btnChangePin)
private val btnFreeze: View = view.findViewById(R.id.btnFreeze)
private val btnBlock: View = view.findViewById(R.id.btnBlock)
fun bind(card: MibCard) {
tvCardOwner.text = card.cardHolderName
tvCardNumber.text = PayWithCardFragment.formatMasked(card.maskedCardNumber)
tvCardType.text = card.cardTypeDesc
val assetPath = PayWithCardFragment.cardImageAsset(card)
if (assetPath != null) PayWithCardFragment.loadCardImage(ivCardImage, assetPath)
else ivCardImage.setImageDrawable(null)
val wip = View.OnClickListener {
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
}
btnChangePin.setOnClickListener(wip)
btnFreeze.setOnClickListener(wip)
btnBlock.setOnClickListener(wip)
}
}
}
}
@@ -31,7 +31,9 @@ class ContactPickerAdapter(
val isSameAsFrom: Boolean = false, val isSameAsFrom: Boolean = false,
val isManualEntry: Boolean = false, val isManualEntry: Boolean = false,
val imageHash: String? = null, val imageHash: String? = null,
val inactiveReason: String? = null val inactiveReason: String? = null,
val balance: String? = null,
val bankLogoRes: Int? = null
) : PickerItem() ) : PickerItem()
} }
@@ -89,14 +91,31 @@ class ContactPickerAdapter(
binding.tvPrimary.text = item.displayName binding.tvPrimary.text = item.displayName
binding.tvSecondary.text = item.subtitle binding.tvSecondary.text = item.subtitle
val cached = item.imageHash?.let { imageCache[it] } if (item.balance != null) {
if (cached != null) { binding.tvBalance.text = item.balance
binding.ivIcon.setImageBitmap(cached) binding.tvBalance.visibility = android.view.View.VISIBLE
} else { } else {
val iconChar = if (item.isManualEntry) "" else item.displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?" binding.tvBalance.visibility = android.view.View.GONE
val iconColor = if (item.isManualEntry) "#546E7A" else item.colorHex }
binding.ivIcon.setImageBitmap(makeInitialsBitmap(iconChar, iconColor, binding.ivIcon.context))
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash) val cached = item.imageHash?.let { imageCache[it] }
when {
cached != null -> {
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivIcon.setImageBitmap(cached)
}
item.bankLogoRes != null -> {
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
binding.ivIcon.setImageResource(item.bankLogoRes)
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
}
else -> {
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
val iconChar = if (item.isManualEntry) "" else item.displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
val iconColor = if (item.isManualEntry) "#546E7A" else item.colorHex
binding.ivIcon.setImageBitmap(makeInitialsBitmap(iconChar, iconColor, binding.ivIcon.context))
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
}
} }
binding.root.alpha = if (item.isSameAsFrom || item.inactiveReason != null) 0.4f else 1.0f binding.root.alpha = if (item.isSameAsFrom || item.inactiveReason != null) 0.4f else 1.0f
@@ -24,7 +24,11 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.databinding.SheetContactPickerBinding import sh.sar.basedbank.databinding.SheetContactPickerBinding
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.ProfileImageStore
import sh.sar.basedbank.util.RecentsCache import sh.sar.basedbank.util.RecentsCache
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
class ContactPickerSheetFragment : BottomSheetDialogFragment() { class ContactPickerSheetFragment : BottomSheetDialogFragment() {
@@ -145,8 +149,9 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
viewModel.contacts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() } viewModel.contacts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() } viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
(activity as? HomeActivity)?.loadAllContacts() (activity as? HomeActivity)?.triggerRefresh()
} }
private fun attachMediator(pages: List<TabDef>) { private fun attachMediator(pages: List<TabDef>) {
@@ -183,6 +188,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
private fun buildPageItems(tabTag: String?): List<ContactPickerAdapter.PickerItem> { private fun buildPageItems(tabTag: String?): List<ContactPickerAdapter.PickerItem> {
val search = binding.etSheetSearch.text?.toString()?.trim() ?: "" val search = binding.etSheetSearch.text?.toString()?.trim() ?: ""
val hide = viewModel.hideAmounts.value ?: false
val items = mutableListOf<ContactPickerAdapter.PickerItem>() val items = mutableListOf<ContactPickerAdapter.PickerItem>()
if (tabTag == RECENTS_TAG) { if (tabTag == RECENTS_TAG) {
@@ -209,11 +215,11 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber } val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
val fromCurrency = fromAccount?.currencyName ?: "" val fromCurrency = fromAccount?.currencyName ?: ""
val fromLoginTag = fromAccount?.loginTag ?: "" val fromLoginTag = fromAccount?.loginTag ?: ""
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT" val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT" || fromAccount?.profileType == "BML_DEBIT"
if (tabTag == MY_ACCOUNTS_TAG) { if (tabTag == MY_ACCOUNTS_TAG) {
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" } val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" }
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" } val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
val filteredRegular = if (search.isBlank()) regularAccounts else regularAccounts.filter { val filteredRegular = if (search.isBlank()) regularAccounts else regularAccounts.filter {
it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search) it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
@@ -223,16 +229,29 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
for (acc in filteredRegular) { for (acc in filteredRegular) {
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash) if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
val isSame = acc.accountNumber == fromAccountNumber val isSame = acc.accountNumber == fromAccountNumber
val parsedBalance = AccountListParser.from(acc)?.balance
?: "${acc.currencyName} ${acc.availableBalance}"
val balance = if (hide) maskAmount(parsedBalance) else parsedBalance
val logoRes = when (acc.bank) {
"BML" -> R.drawable.bml_logo_vector
"FAHIPAY" -> R.drawable.fahipay_logo
"MIB" -> R.drawable.mib_logo
else -> null
}
val localKey = localImageKeyFor(acc)
if (localKey != null) profileImageHashes.add("local:$localKey")
items.add(ContactPickerAdapter.PickerItem.Row( items.add(ContactPickerAdapter.PickerItem.Row(
accountNumber = acc.accountNumber, accountNumber = acc.accountNumber,
displayName = acc.accountBriefName, displayName = acc.accountBriefName,
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}", subtitle = acc.accountNumber,
colorHex = "#FE860E", colorHex = "#FE860E",
isSameAsFrom = isSame, isSameAsFrom = isSame,
imageHash = acc.profileImageHash, imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
inactiveReason = if (isSame) null inactiveReason = if (isSame) null
else if (fromIsCard && acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account" else if (fromIsCard && acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
else currencyMismatchReason(fromCurrency, acc.currencyName) else currencyMismatchReason(fromCurrency, acc.currencyName),
balance = balance,
bankLogoRes = logoRes
)) ))
} }
} }
@@ -246,17 +265,26 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash) if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
val isSame = acc.accountNumber == fromAccountNumber val isSame = acc.accountNumber == fromAccountNumber
val isActive = acc.statusDesc.equals("Active", ignoreCase = true) val isActive = acc.statusDesc.equals("Active", ignoreCase = true)
val isDebit = acc.profileType == "BML_DEBIT"
val parsedBalance = if (isDebit) null
else AccountListParser.from(acc)?.balance ?: "${acc.currencyName} ${acc.availableBalance}"
val balance = parsedBalance?.let { if (hide) maskAmount(it) else it }
val logoRes = BmlCardParser.cardNetworkIcon(acc) ?: R.drawable.bml_logo_vector
val localKey = localImageKeyFor(acc)
if (localKey != null) profileImageHashes.add("local:$localKey")
items.add(ContactPickerAdapter.PickerItem.Row( items.add(ContactPickerAdapter.PickerItem.Row(
accountNumber = acc.accountNumber, accountNumber = acc.accountNumber,
displayName = acc.accountBriefName, displayName = acc.accountBriefName,
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}", subtitle = acc.accountNumber,
colorHex = "#FE860E", colorHex = "#FE860E",
isSameAsFrom = isSame, isSameAsFrom = isSame,
imageHash = acc.profileImageHash, imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
inactiveReason = if (isSame) null inactiveReason = if (isSame) null
else if (!isActive) acc.statusDesc else if (!isActive) acc.statusDesc
else if (acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account" else if (acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
else currencyMismatchReason(fromCurrency, acc.currencyName) else currencyMismatchReason(fromCurrency, acc.currencyName),
balance = balance,
bankLogoRes = logoRes
)) ))
} }
} }
@@ -287,6 +315,17 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
private fun fetchImage(hash: String) { private fun fetchImage(hash: String) {
if (!pendingHashes.add(hash)) return if (!pendingHashes.add(hash)) return
// Local image keys for BML/Fahipay (prefixed with "local:")
if (hash.startsWith("local:")) {
val key = hash.removePrefix("local:")
lifecycleScope.launch(Dispatchers.IO) {
val bitmap = ProfileImageStore.load(requireContext(), key) ?: run {
pendingHashes.remove(hash); return@launch
}
withContext(Dispatchers.Main) { pagerAdapter.updateImage(hash, bitmap) }
}
return
}
val sess = session ?: return val sess = session ?: return
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
try { try {
@@ -306,9 +345,24 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
} }
} }
private fun maskAmount(formatted: String): String {
val currency = formatted.substringBefore(' ', formatted)
return "$currency ••••••"
}
private fun currencyMismatchReason(fromCurrency: String, toCurrency: String): String? = private fun currencyMismatchReason(fromCurrency: String, toCurrency: String): String? =
if (fromCurrency == "MVR" && toCurrency == "USD") "Cannot transfer from MVR to USD account" else null if (fromCurrency == "MVR" && toCurrency == "USD") "Cannot transfer from MVR to USD account" else null
/** Returns the ProfileImageStore key for BML/Fahipay accounts, or null for MIB/others. */
private fun localImageKeyFor(acc: sh.sar.basedbank.api.models.BankAccount): String? = when (acc.bank) {
"BML" -> if (acc.profileId.isNotBlank()) sh.sar.basedbank.util.ProfileImageStore.bmlKey(acc.profileId) else null
"FAHIPAY" -> {
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(acc.loginTag)
if (loginId.isNotBlank()) sh.sar.basedbank.util.ProfileImageStore.fahipayKey(loginId) else null
}
else -> null
}
override fun onDestroyView() { override fun onDestroyView() {
super.onDestroyView() super.onDestroyView()
_binding = null _binding = null
@@ -9,6 +9,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
@@ -48,7 +49,6 @@ class ContactsFragment : Fragment() {
private var currentSearch: String = "" private var currentSearch: String = ""
private var mediator: TabLayoutMediator? = null private var mediator: TabLayoutMediator? = null
private lateinit var pagerAdapter: ContactsPagerAdapter private lateinit var pagerAdapter: ContactsPagerAdapter
private var contactsRefreshing = false
private data class TabPage(val categoryId: String?, val label: String) private data class TabPage(val categoryId: String?, val label: String)
@@ -136,8 +136,8 @@ class ContactsFragment : Fragment() {
(activity as? HomeActivity)?.loadAllContacts() (activity as? HomeActivity)?.loadAllContacts()
binding.swipeRefresh.setOnRefreshListener { binding.swipeRefresh.setOnRefreshListener {
contactsRefreshing = true (activity as? HomeActivity)?.triggerRefresh()
(activity as? HomeActivity)?.loadAllContacts() binding.swipeRefresh.isRefreshing = false
} }
viewModel.contactCategories.observe(viewLifecycleOwner) { cats -> viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
@@ -149,10 +149,6 @@ class ContactsFragment : Fragment() {
pagerAdapter.updateContacts(allContacts) pagerAdapter.updateContacts(allContacts)
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE binding.loadingView.visibility = View.GONE
if (contactsRefreshing) {
contactsRefreshing = false
binding.swipeRefresh.isRefreshing = false
}
} }
} }
@@ -199,10 +195,10 @@ class ContactsFragment : Fragment() {
viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.lifecycleScope.launch {
val success = withContext(Dispatchers.IO) { ContactManager.delete(contact, app) } val success = withContext(Dispatchers.IO) { ContactManager.delete(contact, app) }
if (success) { if (success) {
Toast.makeText(requireContext(), R.string.contact_deleted, Toast.LENGTH_SHORT).show()
removeFromViewModel(contact) removeFromViewModel(contact)
Snackbar.make(binding.root, R.string.contact_deleted, Snackbar.LENGTH_SHORT).show()
} else { } else {
Toast.makeText(requireContext(), R.string.contact_delete_failed, Toast.LENGTH_SHORT).show() Snackbar.make(binding.root, R.string.contact_delete_failed, Snackbar.LENGTH_LONG).show()
} }
} }
} }
@@ -1,6 +1,8 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@@ -8,6 +10,7 @@ import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
@@ -21,6 +24,7 @@ import sh.sar.basedbank.api.bml.BmlForeignLimit
import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibCard import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.api.mib.MibFinanceDeal
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import kotlin.math.abs import kotlin.math.abs
import sh.sar.basedbank.databinding.FragmentDashboardBinding import sh.sar.basedbank.databinding.FragmentDashboardBinding
import sh.sar.basedbank.databinding.ItemForeignLimitBinding import sh.sar.basedbank.databinding.ItemForeignLimitBinding
@@ -31,20 +35,46 @@ class DashboardFragment : Fragment() {
private val binding get() = _binding!! private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels() private val viewModel: HomeViewModel by activityViewModels()
private var pendingQrAccountNumber: String? = null
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
raw.startsWith("https://pay.bml.com.mv/app/")) {
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, pendingQrAccountNumber)
)
} else {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
}
pendingQrAccountNumber = null
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentDashboardBinding.inflate(inflater, container, false) _binding = FragmentDashboardBinding.inflate(inflater, container, false)
return binding.root return binding.root
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) } viewModel.accounts.observe(viewLifecycleOwner) {
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances() } updateBalances(it)
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) { updatePendingFinances() } updateAttentionRow()
}
viewModel.financing.observe(viewLifecycleOwner) {
updatePendingFinances()
updateAttentionRow()
}
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) {
updatePendingFinances()
updateAttentionRow()
}
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) } viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
viewModel.hideAmounts.observe(viewLifecycleOwner) { viewModel.hideAmounts.observe(viewLifecycleOwner) {
updateBalances(viewModel.accounts.value ?: emptyList()) updateBalances(viewModel.accounts.value ?: emptyList())
updatePendingFinances() updatePendingFinances()
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList()) updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
updateAttentionRow()
} }
binding.swipeRefresh.setOnRefreshListener { binding.swipeRefresh.setOnRefreshListener {
@@ -52,16 +82,30 @@ class DashboardFragment : Fragment() {
binding.swipeRefresh.isRefreshing = false 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() val cardAdapter = DashboardCardAdapter()
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false) binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
binding.rvCards.adapter = cardAdapter binding.rvCards.adapter = cardAdapter
LinearSnapHelper().attachToRecyclerView(binding.rvCards) LinearSnapHelper().attachToRecyclerView(binding.rvCards)
viewModel.mibCards.observe(viewLifecycleOwner) { cards -> val updateCardList = {
if (cards.isNullOrEmpty()) return@observe val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
cardAdapter.update(cards) val bmlItems = (viewModel.accounts.value ?: emptyList())
binding.sectionCards.visibility = View.VISIBLE .filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) }
.map { CardItem.Bml(it) }
val all = mibItems + bmlItems
cardAdapter.update(all)
binding.sectionCards.visibility = if (all.isNotEmpty()) View.VISIBLE else View.GONE
} }
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt() val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets -> ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
@@ -82,6 +126,12 @@ class DashboardFragment : Fragment() {
private fun refreshQuickActions() { private fun refreshQuickActions() {
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val isBottom = prefs.getBoolean("bottom_nav", false)
if (isBottom) {
binding.buttonBar.visibility = View.GONE
return
}
binding.buttonBar.visibility = View.VISIBLE
val ids = NavCustomization.getQuickActions(prefs) val ids = NavCustomization.getQuickActions(prefs)
listOf(binding.btnQuickAction1, binding.btnQuickAction2).forEachIndexed { i, btn -> listOf(binding.btnQuickAction1, binding.btnQuickAction2).forEachIndexed { i, btn ->
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == ids[i] } val def = NavCustomization.ALL_SWAPPABLE.find { it.id == ids[i] }
@@ -214,6 +264,56 @@ class DashboardFragment : Fragment() {
} }
} }
private fun updateAttentionRow() {
val hide = viewModel.hideAmounts.value ?: false
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 blockedTotal = blockedByCurrency.values.sum()
if (blockedTotal > 0.0) {
// Primary line: prefer MVR if present, otherwise the first currency.
val primaryCcy = if ("MVR" in blockedByCurrency) "MVR" else blockedByCurrency.keys.first()
val primaryAmt = blockedByCurrency.getValue(primaryCcy)
binding.tvBlockedTotal.text = if (hide) "$primaryCcy ••••••" else "$primaryCcy %,.2f".format(primaryAmt)
val secondary = blockedByCurrency.filterKeys { it != primaryCcy }
if (secondary.isNotEmpty()) {
binding.tvBlockedSecondary.text = secondary.entries.joinToString(" · ") { (ccy, amt) ->
if (hide) "$ccy ••••••" else "$ccy %,.2f".format(amt)
}
binding.tvBlockedSecondary.visibility = View.VISIBLE
} else {
binding.tvBlockedSecondary.visibility = View.GONE
}
binding.cardBlocked.visibility = View.VISIBLE
} else {
binding.cardBlocked.visibility = 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 (blockedTotal > 0.0 || overdueTotal > 0.0) View.VISIBLE else View.GONE
}
private fun updatePendingFinances() { private fun updatePendingFinances() {
val hide = viewModel.hideAmounts.value ?: false val hide = viewModel.hideAmounts.value ?: false
val mibTotal = (viewModel.financing.value ?: emptyList()).sumOf { it.outstandingAmount } val mibTotal = (viewModel.financing.value ?: emptyList()).sumOf { it.outstandingAmount }
@@ -229,9 +329,9 @@ class DashboardFragment : Fragment() {
} }
private inner class DashboardCardAdapter : RecyclerView.Adapter<DashboardCardAdapter.VH>() { private inner class DashboardCardAdapter : RecyclerView.Adapter<DashboardCardAdapter.VH>() {
private var cards: List<MibCard> = emptyList() private var cards: List<CardItem> = emptyList()
fun update(newCards: List<MibCard>) { fun update(newCards: List<CardItem>) {
cards = newCards cards = newCards
notifyDataSetChanged() notifyDataSetChanged()
} }
@@ -249,27 +349,42 @@ class DashboardFragment : Fragment() {
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage) private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner) private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber) 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 btnPayQr: View = view.findViewById(R.id.btnPayQr)
private val btnPayNfc: View = view.findViewById(R.id.btnPayNfc) private val btnPayNfc: View = view.findViewById(R.id.btnPayNfc)
fun bind(card: MibCard) { fun bind(item: CardItem) {
tvCardOwner.text = card.cardHolderName when (item) {
tvCardNumber.text = PayWithCardFragment.formatMasked(card.maskedCardNumber) is CardItem.Mib -> {
val assetPath = PayWithCardFragment.cardImageAsset(card) tvCardOwner.text = item.card.cardHolderName
if (assetPath != null) PayWithCardFragment.loadCardImage(ivCardImage, assetPath) tvCardNumber.text = CardsFragment.formatMasked(item.card.maskedCardNumber)
else ivCardImage.setImageDrawable(null) 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 { btnPayQr.setOnClickListener {
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show() 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 nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(requireContext())
val nfcSupported = nfcAdapter != null val nfcSupported = nfcAdapter != null
btnPayNfc.isEnabled = nfcSupported btnPayNfc.isEnabled = nfcSupported
if (nfcSupported) { btnPayNfc.setOnClickListener {
btnPayNfc.setOnClickListener { val msg = if (isMib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
}
} else {
btnPayNfc.setOnClickListener(null)
} }
} }
} }
@@ -21,7 +21,6 @@ class FinancingFragment : Fragment() {
private val binding get() = _binding!! private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels() private val viewModel: HomeViewModel by activityViewModels()
private lateinit var adapter: FinancingAdapter private lateinit var adapter: FinancingAdapter
private var financingRefreshing = false
private var latestMibDeals: List<MibFinanceDeal> = emptyList() private var latestMibDeals: List<MibFinanceDeal> = emptyList()
private var latestBmlLoanDetails: Map<String, BmlLoanDetail> = emptyMap() private var latestBmlLoanDetails: Map<String, BmlLoanDetail> = emptyMap()
@@ -46,8 +45,8 @@ class FinancingFragment : Fragment() {
} }
binding.swipeRefresh.setOnRefreshListener { binding.swipeRefresh.setOnRefreshListener {
financingRefreshing = true (activity as? HomeActivity)?.triggerRefresh()
(activity as? HomeActivity)?.triggerRefreshFinancing() binding.swipeRefresh.isRefreshing = false
} }
viewModel.accounts.observe(viewLifecycleOwner) { rebuildAdapter() } viewModel.accounts.observe(viewLifecycleOwner) { rebuildAdapter() }
@@ -74,10 +73,6 @@ class FinancingFragment : Fragment() {
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
binding.emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE binding.emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE
binding.loadingView.visibility = View.GONE binding.loadingView.visibility = View.GONE
if (financingRefreshing) {
financingRefreshing = false
binding.swipeRefresh.isRefreshing = false
}
} }
override fun onResume() { override fun onResume() {
@@ -38,6 +38,8 @@ import okhttp3.RequestBody.Companion.toRequestBody
import sh.sar.basedbank.BasedBankApp import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.AuthExpiredException 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.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlContactsClient import sh.sar.basedbank.api.bml.BmlContactsClient
import sh.sar.basedbank.api.bml.BmlForeignLimitsClient import sh.sar.basedbank.api.bml.BmlForeignLimitsClient
@@ -116,6 +118,18 @@ class HomeActivity : AppCompatActivity() {
isAppearanceLightStatusBars = isLight isAppearanceLightStatusBars = isLight
isAppearanceLightNavigationBars = isLight isAppearanceLightNavigationBars = isLight
} }
// 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) setSupportActionBar(binding.toolbar)
toggle = ActionBarDrawerToggle( toggle = ActionBarDrawerToggle(
@@ -145,8 +159,7 @@ class HomeActivity : AppCompatActivity() {
R.id.nav_finances -> FinancingFragment() R.id.nav_finances -> FinancingFragment()
R.id.nav_otp -> OtpFragment() R.id.nav_otp -> OtpFragment()
R.id.nav_settings -> SettingsFragment() R.id.nav_settings -> SettingsFragment()
R.id.nav_pay_with_card -> PayWithCardFragment() R.id.nav_pay_with_card -> CardsFragment()
R.id.nav_card_settings -> CardSettingsFragment()
else -> null else -> null
} }
if (frag != null) show(frag) if (frag != null) show(frag)
@@ -163,7 +176,6 @@ class HomeActivity : AppCompatActivity() {
} }
// Load data // Load data
val app = application as BasedBankApp
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) { if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
// Came from fresh manual login — accounts ready, rest fetched in background // Came from fresh manual login — accounts ready, rest fetched in background
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts
@@ -216,10 +228,19 @@ class HomeActivity : AppCompatActivity() {
viewModel.hideAmounts.value = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_amounts", false) 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) { if (savedInstanceState == null) {
show(DashboardFragment()) val navDest = intent.getIntExtra("nav_destination", -1)
binding.navigationView.setCheckedItem(R.id.nav_dashboard) 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) { onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
@@ -357,8 +378,7 @@ fun applyNavLabelVisibility() {
R.id.nav_finances -> FinancingFragment() R.id.nav_finances -> FinancingFragment()
R.id.nav_otp -> OtpFragment() R.id.nav_otp -> OtpFragment()
R.id.nav_settings -> SettingsFragment() R.id.nav_settings -> SettingsFragment()
R.id.nav_pay_with_card -> PayWithCardFragment() R.id.nav_pay_with_card -> CardsFragment()
R.id.nav_card_settings -> CardSettingsFragment()
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return } else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
} }
show(dest) show(dest)
@@ -547,14 +567,25 @@ fun applyNavLabelVisibility() {
autoRefresh(store) 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) { private fun autoRefresh(store: CredentialStore) {
val mibLoginIds = store.getMibLoginIds() val mibLoginIds = store.getMibLoginIds()
val bmlLoginIds = store.getBmlLoginIds() val bmlLoginIds = store.getBmlLoginIds()
val fahipayLoginIds = store.getFahipayLoginIds() val fahipayLoginIds = store.getFahipayLoginIds()
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) return if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) return
binding.refreshIndicator.visibility = View.VISIBLE binding.refreshIndicator.visibility = View.VISIBLE
hideConnectivityBanner()
lifecycleScope.launch { lifecycleScope.launch {
val refreshErrors = ConcurrentLinkedQueue<String>()
// One async job per MIB login, all run in parallel // One async job per MIB login, all run in parallel
val mibJobs = mibLoginIds.mapNotNull { loginId -> val mibJobs = mibLoginIds.mapNotNull { loginId ->
val creds = store.loadMibCredentials(loginId) ?: return@mapNotNull null val creds = store.loadMibCredentials(loginId) ?: return@mapNotNull null
@@ -568,7 +599,15 @@ fun applyNavLabelVisibility() {
app.mibLoginFlows[loginId] = flow app.mibLoginFlows[loginId] = flow
store.saveMibProfiles(loginId, flow.lastProfiles) store.saveMibProfiles(loginId, flow.lastProfiles)
accounts 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" }
}
} }
} }
@@ -623,6 +662,14 @@ fun applyNavLabelVisibility() {
tryRefresh() 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) { } catch (_: Exception) {
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId) allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
.filter { it.profileId == profile.profileId } .filter { it.profileId == profile.profileId }
@@ -638,6 +685,12 @@ fun applyNavLabelVisibility() {
val accounts = BmlAccountClient().fetchAccounts(session, loginTag) val accounts = BmlAccountClient().fetchAccounts(session, loginTag)
app.bmlSessions[loginId] = session app.bmlSessions[loginId] = session
allAccounts += accounts allAccounts += accounts
} 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) { } catch (_: Exception) {
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId) allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
} }
@@ -689,6 +742,12 @@ fun applyNavLabelVisibility() {
app.fahipaySessions[loginId] = session app.fahipaySessions[loginId] = session
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts) AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
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) { } catch (_: Exception) {
AccountCache.loadFahipay(this@HomeActivity, loginId) AccountCache.loadFahipay(this@HomeActivity, loginId)
} }
@@ -708,6 +767,22 @@ fun applyNavLabelVisibility() {
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts()) viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts())
binding.refreshIndicator.visibility = View.GONE 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 ((_, session) in app.bmlSessions) refreshBmlLimits(session)
for ((loginId, session) in app.mibSessions) { for ((loginId, session) in app.mibSessions) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList() val profiles = app.mibProfilesMap[loginId] ?: emptyList()
@@ -10,6 +10,11 @@ import sh.sar.basedbank.api.models.BankContactCategory
import sh.sar.basedbank.api.mib.MibCard import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.api.mib.MibFinanceDeal import sh.sar.basedbank.api.mib.MibFinanceDeal
sealed class CardItem {
data class Mib(val card: MibCard) : CardItem()
data class Bml(val account: BankAccount) : CardItem()
}
class HomeViewModel : ViewModel() { class HomeViewModel : ViewModel() {
val accounts = MutableLiveData<List<BankAccount>>(emptyList()) val accounts = MutableLiveData<List<BankAccount>>(emptyList())
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList()) val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
@@ -24,4 +29,11 @@ class HomeViewModel : ViewModel() {
val mibCards = MutableLiveData<List<MibCard>?>(null) val mibCards = MutableLiveData<List<MibCard>?>(null)
val hideAmounts = MutableLiveData<Boolean>(false) 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())
} }
@@ -9,6 +9,7 @@ object NavCustomization {
data class NavItemDef( data class NavItemDef(
val id: Int, val id: Int,
val key: String,
@DrawableRes val iconRes: Int, @DrawableRes val iconRes: Int,
@StringRes val titleRes: Int, @StringRes val titleRes: Int,
@StringRes val descriptionRes: Int @StringRes val descriptionRes: Int
@@ -16,42 +17,47 @@ object NavCustomization {
/** All items that can occupy either a bottom nav slot or the "More" screen. */ /** All items that can occupy either a bottom nav slot or the "More" screen. */
val ALL_SWAPPABLE = listOf( val ALL_SWAPPABLE = listOf(
NavItemDef(R.id.nav_accounts, R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts), 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, R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts), 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, R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer), 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, R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr), 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, R.drawable.ic_nav_activities, R.string.nav_activities, R.string.nav_desc_activities), 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, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history, R.string.nav_desc_transfer_history), 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, R.drawable.ic_nav_finances, R.string.nav_finances, R.string.nav_desc_finances), 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, R.drawable.ic_nav_card, R.string.nav_pay_with_card, R.string.nav_desc_pay_with_card), NavItemDef(R.id.nav_pay_with_card, "nav_pay_with_card", R.drawable.ic_nav_card, R.string.nav_pay_with_card, R.string.nav_desc_pay_with_card),
NavItemDef(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings, R.string.nav_desc_card_settings), NavItemDef(R.id.nav_otp, "nav_otp", R.drawable.ic_nav_otp, R.string.nav_otp, R.string.nav_desc_otp),
NavItemDef(R.id.nav_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),
NavItemDef(R.id.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( fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
prefs.getInt("bottom_nav_slot_1", R.id.nav_accounts), keyToId(prefs.getString("bottom_nav_slot_1_key", null), R.id.nav_accounts),
prefs.getInt("bottom_nav_slot_2", R.id.nav_contacts), keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_contacts),
prefs.getInt("bottom_nav_slot_3", R.id.nav_transfer), keyToId(prefs.getString("bottom_nav_slot_3_key", null), R.id.nav_transfer),
) )
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) { fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
prefs.edit() prefs.edit()
.putInt("bottom_nav_slot_1", slots[0]) .putString("bottom_nav_slot_1_key", idToKey(slots[0]) ?: "nav_accounts")
.putInt("bottom_nav_slot_2", slots[1]) .putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_contacts")
.putInt("bottom_nav_slot_3", slots[2]) .putString("bottom_nav_slot_3_key", idToKey(slots[2]) ?: "nav_transfer")
.apply() .apply()
} }
fun getQuickActions(prefs: SharedPreferences): List<Int> = listOf( fun getQuickActions(prefs: SharedPreferences): List<Int> = listOf(
prefs.getInt("quick_action_1", R.id.nav_transfer), keyToId(prefs.getString("quick_action_1_key", null), R.id.nav_transfer),
prefs.getInt("quick_action_2", R.id.nav_pay_mv_qr), keyToId(prefs.getString("quick_action_2_key", null), R.id.nav_pay_mv_qr),
) )
fun saveQuickActions(prefs: SharedPreferences, ids: List<Int>) { fun saveQuickActions(prefs: SharedPreferences, ids: List<Int>) {
prefs.edit() prefs.edit()
.putInt("quick_action_1", ids[0]) .putString("quick_action_1_key", idToKey(ids[0]) ?: "nav_transfer")
.putInt("quick_action_2", ids[1]) .putString("quick_action_2_key", idToKey(ids[1]) ?: "nav_pay_mv_qr")
.apply() .apply()
} }
@@ -32,11 +32,15 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.databinding.FragmentPayMvQrBinding import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.PaymvQrParser 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.File
import java.io.FileOutputStream import java.io.FileOutputStream
@@ -49,10 +53,19 @@ class PayMvQrFragment : Fragment() {
private var selectedAccount: BankAccount? = null private var selectedAccount: BankAccount? = null
private var generatedBitmap: Bitmap? = null private var generatedBitmap: Bitmap? = null
private var generateJob: Job? = null private var generateJob: Job? = null
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
// 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) val qr = PaymvQrParser.parse(raw)
if (qr == null || qr.accountNumber == null) { if (qr == null || qr.accountNumber == null) {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
@@ -97,7 +110,7 @@ class PayMvQrFragment : Fragment() {
private fun setupDropdown() { private fun setupDropdown() {
viewModel.accounts.observe(viewLifecycleOwner) { accounts -> viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
val eligible = accounts.filter { val eligible = accounts.filter {
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_LOAN" it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN"
} }
val adapter = QrAccountAdapter(requireContext(), eligible) val adapter = QrAccountAdapter(requireContext(), eligible)
binding.actvAccount.setAdapter(adapter) binding.actvAccount.setAdapter(adapter)
@@ -402,9 +415,105 @@ class PayMvQrFragment : Fragment() {
} }
val ownerPrefix = if (acc.bank == "BML" && acc.profileName.isNotBlank()) "${acc.profileName} · " else "" val ownerPrefix = if (acc.bank == "BML" && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}" 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.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.text = displayData?.balance ?: ""
b.root.alpha = 1f 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 return b.root
} }
@@ -1,62 +1,124 @@
package sh.sar.basedbank.ui.home package sh.sar.basedbank.ui.home
import android.app.Activity
import android.content.Context import android.content.Context
import android.graphics.BitmapFactory import android.content.Intent
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.color.MaterialColors
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.MibCard import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.databinding.FragmentPayWithCardBinding import sh.sar.basedbank.databinding.FragmentCardsBinding
import sh.sar.basedbank.util.CardsCache import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import kotlin.math.abs
class PayWithCardFragment : Fragment() { class CardsFragment : Fragment() {
private var _binding: FragmentPayWithCardBinding? = null private var _binding: FragmentCardsBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels() private val viewModel: HomeViewModel by activityViewModels()
private var cards: List<CardItem> = emptyList()
private var currentCardPosition: Int = 0
private var cardWidth: Int = 0
private var pendingQrAccountNumber: String? = null
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
raw.startsWith("https://pay.bml.com.mv/app/")) {
(requireActivity() as HomeActivity).navigateTo(
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, pendingQrAccountNumber)
)
} else {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
}
pendingQrAccountNumber = null
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentPayWithCardBinding.inflate(inflater, container, false) _binding = FragmentCardsBinding.inflate(inflater, container, false)
return binding.root return binding.root
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val adapter = CardWalletAdapter(emptyList(), requireContext()) val screenW = resources.displayMetrics.widthPixels
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext()) val peekPx = screenW / 8
binding.recyclerView.adapter = adapter cardWidth = screenW - 2 * peekPx
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt() val stackAdapter = CardStackAdapter(cardWidth)
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets -> binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", 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 navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
val extraBottom = if (isBottomNav) 0 else navBar.bottom val extraBottom = if (isBottomNav) 0 else navBar.bottom
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom) v.setPadding(0, 0, 0, (16 * resources.displayMetrics.density).toInt() + extraBottom)
insets insets
} }
binding.swipeRefresh.setOnRefreshListener { val updateCardList = {
(activity as? HomeActivity)?.triggerRefreshCards() 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" }
viewModel.mibCards.observe(viewLifecycleOwner) { cards -> .map { CardItem.Bml(it) }
if (cards == null) return@observe cards = mibItems + bmlItems
adapter.update(cards) stackAdapter.update(cards)
binding.loadingView.visibility = View.GONE binding.loadingView.visibility = View.GONE
binding.swipeRefresh.isRefreshing = false val empty = cards.isEmpty()
binding.emptyView.visibility = if (cards.isEmpty()) View.VISIBLE else View.GONE binding.emptyView.visibility = if (empty) View.VISIBLE else View.GONE
binding.recyclerView.visibility = if (cards.isEmpty()) View.GONE else View.VISIBLE binding.contentLayout.visibility = if (empty) View.GONE else View.VISIBLE
if (!empty) {
buildDots(cards.size, currentCardPosition)
updateCardInfo(currentCardPosition)
}
} }
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
val cached = CardsCache.load(requireContext()) val cached = CardsCache.load(requireContext())
if (cached.isNotEmpty()) { if (cached.isNotEmpty()) {
@@ -65,6 +127,84 @@ class PayWithCardFragment : Fragment() {
binding.loadingView.visibility = View.VISIBLE binding.loadingView.visibility = View.VISIBLE
} }
(activity as? HomeActivity)?.triggerRefreshCards() (activity as? HomeActivity)?.triggerRefreshCards()
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 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) {
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
}
} }
override fun onResume() { override fun onResume() {
@@ -77,49 +217,58 @@ class PayWithCardFragment : Fragment() {
_binding = null _binding = null
} }
private inner class CardWalletAdapter( private inner class CardStackAdapter(private val cardWidth: Int) : RecyclerView.Adapter<CardStackAdapter.VH>() {
private var cards: List<MibCard>, private var items: List<CardItem> = emptyList()
private val context: Context
) : RecyclerView.Adapter<CardWalletAdapter.VH>() {
fun update(newCards: List<MibCard>) { fun update(newItems: List<CardItem>) {
cards = newCards items = newItems
notifyDataSetChanged() notifyDataSetChanged()
} }
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH = override fun getItemCount() = items.size
VH(LayoutInflater.from(context).inflate(R.layout.item_card_wallet, parent, false))
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position]) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
override fun getItemCount() = cards.size 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) { inner class VH(view: View) : RecyclerView.ViewHolder(view) {
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage) private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner) private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber) private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
private val tvCardType: TextView = view.findViewById(R.id.tvCardType) private val tvCardStatus: TextView = view.findViewById(R.id.tvCardStatus)
private val btnPayQr: View = view.findViewById(R.id.btnPayQr)
private val btnPayNfc: View = view.findViewById(R.id.btnPayNfc)
fun bind(card: MibCard) { init {
tvCardOwner.text = card.cardHolderName itemView.layoutParams = RecyclerView.LayoutParams(cardWidth, RecyclerView.LayoutParams.WRAP_CONTENT)
tvCardNumber.text = formatMasked(card.maskedCardNumber) }
tvCardType.text = card.cardTypeDesc
val assetPath = cardImageAsset(card) fun bind(item: CardItem) {
if (assetPath != null) loadCardImage(ivCardImage, assetPath) when (item) {
else ivCardImage.setImageDrawable(null) is CardItem.Mib -> {
btnPayQr.setOnClickListener { tvCardOwner.text = item.card.cardHolderName
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show() tvCardNumber.text = formatMasked(item.card.maskedCardNumber)
} val assetPath = cardImageAsset(item.card)
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(context) if (assetPath != null) loadCardImage(ivCardImage, assetPath)
val nfcSupported = nfcAdapter != null else ivCardImage.setImageDrawable(null)
btnPayNfc.isEnabled = nfcSupported bindCardStatus(tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
if (nfcSupported) { itemView.alpha = 1f
btnPayNfc.setOnClickListener { }
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show() 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
} }
} else {
btnPayNfc.setOnClickListener(null)
} }
} }
} }
@@ -127,16 +276,19 @@ class PayWithCardFragment : Fragment() {
companion object { companion object {
fun cardImageAsset(card: MibCard): String? = when (card.cardType) { fun cardImageAsset(card: MibCard): String? = when (card.cardType) {
"53" -> "cards/mib/visa_black_platinum.jpg" "51" -> "cards/mib/faisa_card.png"
"57" -> "cards/mib/visa_blue_everyday.jpg" "53" -> "cards/mib/visa_black_platinum.png"
"70" -> "cards/mib/visa_business.jpg" "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 else -> null
} }
fun loadCardImage(imageView: ImageView, assetPath: String) { fun loadCardImage(imageView: ImageView, assetPath: String) {
try { try {
val bitmap = imageView.context.assets.open(assetPath).use { val bitmap = imageView.context.assets.open(assetPath).use {
BitmapFactory.decodeStream(it) android.graphics.BitmapFactory.decodeStream(it)
} }
imageView.setImageBitmap(bitmap) imageView.setImageBitmap(bitmap)
} catch (_: Exception) { } catch (_: Exception) {
@@ -144,6 +296,18 @@ class PayWithCardFragment : Fragment() {
} }
} }
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
tv.setBackgroundResource(R.drawable.card_overlay_pill_bg)
}
fun formatMasked(masked: String): String { fun formatMasked(masked: String): String {
if (masked.length < 4) return masked if (masked.length < 4) return masked
return "\u2022\u2022\u2022\u2022 ${masked.takeLast(4)}" return "\u2022\u2022\u2022\u2022 ${masked.takeLast(4)}"
@@ -13,6 +13,12 @@ import android.provider.Settings
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.drawable.Animatable
import android.view.ScaleGestureDetector
import androidx.appcompat.content.res.AppCompatResources
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
@@ -31,8 +37,10 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ActivityQrScannerBinding import sh.sar.basedbank.databinding.ActivityQrScannerBinding
import sh.sar.basedbank.util.CredentialStore
import java.util.concurrent.Executors import java.util.concurrent.Executors
class QrScannerActivity : AppCompatActivity() { class QrScannerActivity : AppCompatActivity() {
@@ -52,6 +60,8 @@ class QrScannerActivity : AppCompatActivity() {
textMode = ZxingCpp.TextMode.PLAIN textMode = ZxingCpp.TextMode.PLAIN
) )
private var camera: Camera? = null
private var torchEnabled = false
private var cameraStarted = false private var cameraStarted = false
private val permissionLauncher = registerForActivityResult( private val permissionLauncher = registerForActivityResult(
@@ -87,6 +97,14 @@ class QrScannerActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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) WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityQrScannerBinding.inflate(layoutInflater) binding = ActivityQrScannerBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
@@ -104,8 +122,36 @@ class QrScannerActivity : AppCompatActivity() {
} }
insets insets
} }
binding.btnCancel.setOnClickListener { finish() }
binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") } binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") }
binding.zoomSlider.addOnChangeListener { _, value, fromUser ->
if (fromUser) camera?.cameraControl?.setLinearZoom(value)
}
val scaleDetector = ScaleGestureDetector(this,
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScale(detector: ScaleGestureDetector): Boolean {
val state = camera?.cameraInfo?.zoomState?.value ?: return true
camera?.cameraControl?.setZoomRatio(
(state.zoomRatio * detector.scaleFactor)
.coerceIn(state.minZoomRatio, state.maxZoomRatio)
)
return true
}
})
binding.previewView.setOnTouchListener { _, event ->
scaleDetector.onTouchEvent(event)
true
}
binding.btnFlashlight.setOnClickListener {
torchEnabled = !torchEnabled
camera?.cameraControl?.enableTorch(torchEnabled)
val drawableRes = if (torchEnabled) R.drawable.ic_flashlight_to_on else R.drawable.ic_flashlight_to_off
val drawable = AppCompatResources.getDrawable(this, drawableRes)
binding.btnFlashlight.icon = drawable
(drawable as? Animatable)?.start()
binding.btnFlashlight.iconTint = ColorStateList.valueOf(
if (torchEnabled) Color.parseColor("#FFEB3B") else Color.WHITE
)
}
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
== PackageManager.PERMISSION_GRANTED == PackageManager.PERMISSION_GRANTED
@@ -179,9 +225,12 @@ class QrScannerActivity : AppCompatActivity() {
try { try {
provider.unbindAll() provider.unbindAll()
provider.bindToLifecycle( camera = provider.bindToLifecycle(
this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
) )
camera?.cameraInfo?.zoomState?.observe(this@QrScannerActivity) { state ->
binding.zoomSlider.value = state.linearZoom
}
} catch (_: Exception) { } catch (_: Exception) {
finish() finish()
} }
@@ -54,19 +54,29 @@ class SettingsAppearanceFragment : Fragment() {
// Quick actions // Quick actions
quickActions.clear() quickActions.clear()
quickActions.addAll(NavCustomization.getQuickActions(prefs)) quickActions.addAll(NavCustomization.getQuickActions(prefs))
quickActionAdapter = NavItemAdapter(quickActions) { quickActionAdapter = NavItemAdapter(
NavCustomization.saveQuickActions(prefs, quickActions) 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 // Bottom bar shortcuts
slots.clear() slots.clear()
slots.addAll(NavCustomization.getSlots(prefs)) slots.addAll(NavCustomization.getSlots(prefs))
slotAdapter = NavItemAdapter(slots) { slotAdapter = NavItemAdapter(
NavCustomization.saveSlots(prefs, slots) items = slots,
(activity as? HomeActivity)?.rebuildBottomNav(prefs) 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 // Show labels toggle
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true) val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
binding.switchShowLabels.isChecked = showLabels binding.switchShowLabels.isChecked = showLabels
@@ -109,13 +119,18 @@ class SettingsAppearanceFragment : Fragment() {
private fun setupNavItemRecyclerView( private fun setupNavItemRecyclerView(
rv: RecyclerView, rv: RecyclerView,
adapter: NavItemAdapter, adapter: NavItemAdapter,
items: MutableList<Int> items: MutableList<Int>,
isEnabled: () -> Boolean
) { ) {
rv.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false) rv.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
rv.adapter = adapter rv.adapter = adapter
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.START or ItemTouchHelper.END, 0 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( override fun onMove(
rv: RecyclerView, rv: RecyclerView,
from: RecyclerView.ViewHolder, from: RecyclerView.ViewHolder,
@@ -134,11 +149,17 @@ class SettingsAppearanceFragment : Fragment() {
private fun updateShortcutsVisibility() { private fun updateShortcutsVisibility() {
val isBottom = prefs.getBoolean("bottom_nav", false) 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.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
binding.switchShowLabels.isClickable = isBottom
quickActionAdapter.notifyDataSetChanged()
slotAdapter.notifyDataSetChanged()
} }
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) { 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 ctx = requireContext()
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet() val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds } val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
@@ -169,7 +190,8 @@ class SettingsAppearanceFragment : Fragment() {
private inner class NavItemAdapter( private inner class NavItemAdapter(
val items: MutableList<Int>, val items: MutableList<Int>,
val onSave: () -> Unit val onSave: () -> Unit,
val isEnabled: () -> Boolean = { true }
) : RecyclerView.Adapter<NavItemAdapter.VH>() { ) : RecyclerView.Adapter<NavItemAdapter.VH>() {
inner class VH(view: View) : RecyclerView.ViewHolder(view) { inner class VH(view: View) : RecyclerView.ViewHolder(view) {
@@ -191,7 +213,12 @@ class SettingsAppearanceFragment : Fragment() {
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == items[position] } ?: return val def = NavCustomization.ALL_SWAPPABLE.find { it.id == items[position] } ?: return
holder.ivNavIcon.setImageResource(def.iconRes) holder.ivNavIcon.setImageResource(def.iconRes)
holder.tvNavLabel.setText(def.titleRes) 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
} }
} }
@@ -2,7 +2,11 @@ package sh.sar.basedbank.ui.home
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.util.Base64
import android.view.Gravity import android.view.Gravity
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@@ -10,6 +14,8 @@ import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.TextView import android.widget.TextView
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -22,11 +28,13 @@ import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
import sh.sar.basedbank.ui.login.LoginActivity import sh.sar.basedbank.ui.login.LoginActivity
import sh.sar.basedbank.util.AccountCache import sh.sar.basedbank.util.AccountCache
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.ContactImageCache import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.ContactsCache import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.FinancingCache import sh.sar.basedbank.util.FinancingCache
import sh.sar.basedbank.util.ForeignLimitsCache import sh.sar.basedbank.util.ForeignLimitsCache
import sh.sar.basedbank.util.ProfileImageStore
import sh.sar.basedbank.util.RecentsCache import sh.sar.basedbank.util.RecentsCache
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import kotlin.coroutines.resume import kotlin.coroutines.resume
@@ -37,6 +45,8 @@ import kotlinx.coroutines.withContext
import sh.sar.basedbank.api.bml.BmlActivationResult import sh.sar.basedbank.api.bml.BmlActivationResult
import sh.sar.basedbank.api.bml.BmlLoginFlow import sh.sar.basedbank.api.bml.BmlLoginFlow
import sh.sar.basedbank.api.bml.BmlOtpChannel import sh.sar.basedbank.api.bml.BmlOtpChannel
import java.io.ByteArrayOutputStream
import java.io.File
class SettingsLoginsFragment : Fragment() { class SettingsLoginsFragment : Fragment() {
@@ -44,6 +54,248 @@ class SettingsLoginsFragment : Fragment() {
private val binding get() = _binding!! private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels() 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 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
cameraLauncher.launch(uri)
}
if (target is PendingImageTarget.Mib || currentBitmap != null || hasSavedImage(ctx, target)) {
items += Triple(R.drawable.ic_delete, getString(R.string.profile_image_remove)) {
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 { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false) _binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false)
return binding.root return binding.root
@@ -100,18 +352,7 @@ class SettingsLoginsFragment : Fragment() {
val profile = store.loadFahipayUserProfile(loginId) val profile = store.loadFahipayUserProfile(loginId)
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name) val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
addLoginRow(container, R.drawable.fahipay_logo, displayName) { addLoginRow(container, R.drawable.fahipay_logo, displayName) {
val hide = viewModel.hideAmounts.value ?: false showFahipayLoginDetails(store, loginId, profile)
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) } }
)
} }
} }
} }
@@ -218,8 +459,21 @@ class SettingsLoginsFragment : Fragment() {
alpha = 0.6f 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(textCol)
row.addView(pencil)
row.addView(toggle) row.addView(toggle)
container.addView(row) container.addView(row)
p to toggle p to toggle
@@ -327,6 +581,10 @@ class SettingsLoginsFragment : Fragment() {
} }
val toggleRows = bmlProfiles.map { p -> 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 { val row = LinearLayout(ctx).apply {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL gravity = Gravity.CENTER_VERTICAL
@@ -349,8 +607,24 @@ class SettingsLoginsFragment : Fragment() {
alpha = 0.6f 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(textCol)
row.addView(pencil)
row.addView(toggle) row.addView(toggle)
container.addView(row) container.addView(row)
p to toggle p to toggle
@@ -614,6 +888,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) { private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
MaterialAlertDialogBuilder(requireContext()) MaterialAlertDialogBuilder(requireContext())
.setTitle(title) .setTitle(title)
@@ -642,6 +985,7 @@ class SettingsLoginsFragment : Fragment() {
app.mibLoginFlows.remove(loginId) app.mibLoginFlows.remove(loginId)
app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" } app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" }
app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" } app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" }
viewModel.financing.value = emptyList()
clearAllCaches(ctx) clearAllCaches(ctx)
(activity as HomeActivity).relogin() (activity as HomeActivity).relogin()
buildLoginsSection() buildLoginsSection()
@@ -652,12 +996,17 @@ class SettingsLoginsFragment : Fragment() {
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
// Remove all per-profile sessions for this login from the in-memory map // Remove all per-profile sessions for this login from the in-memory map
val profiles = app.bmlProfilesMap[loginId] ?: emptyList() 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 // clearBmlCredentials also clears per-profile tokens via loadBmlProfiles internally
store.clearBmlCredentials(loginId) store.clearBmlCredentials(loginId)
app.bmlProfilesMap.remove(loginId) app.bmlProfilesMap.remove(loginId)
app.bmlLoginFlows.remove(loginId) app.bmlLoginFlows.remove(loginId)
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" } app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
viewModel.bmlLimits.value = emptyList()
viewModel.bmlLoanDetails.value = emptyMap()
clearAllCaches(ctx) clearAllCaches(ctx)
(activity as HomeActivity).relogin() (activity as HomeActivity).relogin()
buildLoginsSection() buildLoginsSection()
@@ -665,6 +1014,7 @@ class SettingsLoginsFragment : Fragment() {
private fun logoutFahipay(store: CredentialStore, loginId: String) { private fun logoutFahipay(store: CredentialStore, loginId: String) {
val ctx = requireContext() val ctx = requireContext()
ProfileImageStore.delete(ctx, ProfileImageStore.fahipayKey(loginId))
store.clearFahipayCredentials(loginId) store.clearFahipayCredentials(loginId)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
app.fahipaySessions.remove(loginId) app.fahipaySessions.remove(loginId)
@@ -677,6 +1027,7 @@ class SettingsLoginsFragment : Fragment() {
private fun clearAllCaches(ctx: Context) { private fun clearAllCaches(ctx: Context) {
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx) AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx) ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx) CardsCache.clear(ctx); TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
// Note: ProfileImageStore is intentionally NOT cleared here — profile images are user-set data
} }
} }
@@ -7,10 +7,12 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast import android.widget.Toast
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.mib.TransactionCache import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentSettingsStorageBinding import sh.sar.basedbank.databinding.FragmentSettingsStorageBinding
import sh.sar.basedbank.util.AccountCache import sh.sar.basedbank.util.AccountCache
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.ContactImageCache import sh.sar.basedbank.util.ContactImageCache
import sh.sar.basedbank.util.ContactsCache import sh.sar.basedbank.util.ContactsCache
import sh.sar.basedbank.util.FinancingCache import sh.sar.basedbank.util.FinancingCache
@@ -21,6 +23,7 @@ class SettingsStorageFragment : Fragment() {
private var _binding: FragmentSettingsStorageBinding? = null private var _binding: FragmentSettingsStorageBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private val viewModel: HomeViewModel by activityViewModels()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
_binding = FragmentSettingsStorageBinding.inflate(inflater, container, false) _binding = FragmentSettingsStorageBinding.inflate(inflater, container, false)
@@ -32,6 +35,7 @@ class SettingsStorageFragment : Fragment() {
val ctx = requireContext() val ctx = requireContext()
clearAllCaches(ctx) clearAllCaches(ctx)
Toast.makeText(ctx, R.string.settings_cache_cleared, Toast.LENGTH_SHORT).show() Toast.makeText(ctx, R.string.settings_cache_cleared, Toast.LENGTH_SHORT).show()
(activity as? HomeActivity)?.triggerRefresh()
} }
} }
@@ -48,6 +52,13 @@ class SettingsStorageFragment : Fragment() {
private fun clearAllCaches(ctx: Context) { private fun clearAllCaches(ctx: Context) {
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx) AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx) ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx) CardsCache.clear(ctx); TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
viewModel.accounts.value = emptyList()
viewModel.mibCards.value = null
viewModel.financing.value = emptyList()
viewModel.bmlLoanDetails.value = emptyMap()
viewModel.bmlLimits.value = emptyList()
viewModel.contacts.value = emptyList()
viewModel.contactCategories.value = emptyList()
} }
} }
@@ -40,6 +40,8 @@ import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.api.bml.BmlAccountClient import sh.sar.basedbank.api.bml.BmlAccountClient
import sh.sar.basedbank.api.bml.BmlOtpChannel import sh.sar.basedbank.api.bml.BmlOtpChannel
import sh.sar.basedbank.api.bml.BmlQrPayClient
import sh.sar.basedbank.api.bml.BmlQrPayInfo
import sh.sar.basedbank.api.bml.BmlTransferClient import sh.sar.basedbank.api.bml.BmlTransferClient
import sh.sar.basedbank.api.bml.BmlTransferResult import sh.sar.basedbank.api.bml.BmlTransferResult
import sh.sar.basedbank.api.bml.BmlValidateClient import sh.sar.basedbank.api.bml.BmlValidateClient
@@ -59,6 +61,8 @@ import sh.sar.basedbank.util.AccountListParser
import sh.sar.basedbank.util.CredentialStore import sh.sar.basedbank.util.CredentialStore
import sh.sar.basedbank.util.AccountInputParser import sh.sar.basedbank.util.AccountInputParser
import sh.sar.basedbank.util.PaymvQrParser import sh.sar.basedbank.util.PaymvQrParser
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
import sh.sar.basedbank.util.RecentPick import sh.sar.basedbank.util.RecentPick
import sh.sar.basedbank.util.RecentsCache import sh.sar.basedbank.util.RecentsCache
import sh.sar.basedbank.util.ReceiptStore import sh.sar.basedbank.util.ReceiptStore
@@ -82,11 +86,17 @@ class TransferFragment : Fragment() {
private var resolvedAccountNumber = "" private var resolvedAccountNumber = ""
private var resolvedRecipientName = "" private var resolvedRecipientName = ""
private var resolvedBankName = "" private var resolvedBankName = ""
private var resolvedToOwnAccount: BankAccount? = null
// Selected Fahipay service when source is Fahipay and destination is a phone number // Selected Fahipay service when source is Fahipay and destination is a phone number
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL" // Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
private var selectedFahipayService: String? = null private var selectedFahipayService: String? = null
// BML QR merchant payment mode (set when navigated from a card QR scan)
private var bmlQrInfo: BmlQrPayInfo? = null
private var bmlGatewayQr = false // true for pay.bml.com.mv QRs (requires pre-initiate step)
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
// BML business profile OTP flow state // BML business profile OTP flow state
private enum class BmlOtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP } private enum class BmlOtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
private var bmlOtpState = BmlOtpState.NONE private var bmlOtpState = BmlOtpState.NONE
@@ -108,10 +118,19 @@ class TransferFragment : Fragment() {
val toAvatar: Bitmap? val toAvatar: Bitmap?
) )
private var pendingBmlTransfer: PendingBmlTransfer? = null private var pendingBmlTransfer: PendingBmlTransfer? = null
private var accountDropdownAdapter: AccountDropdownAdapter? = null
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
// 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) val qr = PaymvQrParser.parse(raw)
if (qr == null || qr.accountNumber == null) { if (qr == null || qr.accountNumber == null) {
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
@@ -131,6 +150,19 @@ class TransferFragment : Fragment() {
private const val ARG_FROM_ACCOUNT = "from_account" private const val ARG_FROM_ACCOUNT = "from_account"
private const val ARG_AMOUNT_PREFILL = "amount_prefill" private const val ARG_AMOUNT_PREFILL = "amount_prefill"
private const val ARG_REMARKS_PREFILL = "remarks_prefill" private const val ARG_REMARKS_PREFILL = "remarks_prefill"
private const val ARG_BML_QR_URL = "bml_qr_url"
private const val ARG_AUTO_SCAN = "auto_scan"
fun newInstanceWithAutoScan() = TransferFragment().apply {
arguments = Bundle().apply { putBoolean(ARG_AUTO_SCAN, true) }
}
fun newInstanceFromBmlQr(qrUrl: String, fromAccountNumber: String? = null) = TransferFragment().apply {
arguments = Bundle().apply {
putString(ARG_BML_QR_URL, qrUrl)
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
}
}
fun newInstanceFrom(account: BankAccount) = TransferFragment().apply { fun newInstanceFrom(account: BankAccount) = TransferFragment().apply {
arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) } arguments = Bundle().apply { putString(ARG_FROM_ACCOUNT, account.accountNumber) }
@@ -178,6 +210,12 @@ class TransferFragment : Fragment() {
setupFromDropdown() setupFromDropdown()
setupAccountLookup() setupAccountLookup()
viewModel.hideAmounts.observe(viewLifecycleOwner) {
accountDropdownAdapter?.notifyDataSetChanged()
selectedAccount?.let { showFromCard(it) }
resolvedToOwnAccount?.let { showToCard(it) }
}
childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle -> childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: "" val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
@@ -196,6 +234,8 @@ class TransferFragment : Fragment() {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java)) qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
} }
viewModel.connectivityErrors.observe(viewLifecycleOwner) { updateTransferButton() }
binding.btnTransfer.isEnabled = false binding.btnTransfer.isEnabled = false
binding.btnTransfer.setOnClickListener { binding.btnTransfer.setOnClickListener {
if (bmlOtpState == BmlOtpState.AWAITING_OTP) verifyBmlOtp() if (bmlOtpState == BmlOtpState.AWAITING_OTP) verifyBmlOtp()
@@ -216,6 +256,62 @@ class TransferFragment : Fragment() {
} }
arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) } arguments?.getString(ARG_AMOUNT_PREFILL)?.let { binding.etAmount.setText(it) }
arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) } arguments?.getString(ARG_REMARKS_PREFILL)?.let { binding.etRemarks.setText(it) }
arguments?.getString(ARG_BML_QR_URL)?.let { lookupBmlQrMerchant(it) }
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
}
}
private fun lookupBmlQrMerchant(qrUrl: String) {
bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/")
val base64Url = android.util.Base64.encodeToString(
qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP)
val app = requireActivity().application as BasedBankApp
val session = app.anyBmlSession() ?: return
// Lock the "To" input row while loading
binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE
(activity as? HomeActivity)?.setRefreshing(true)
viewLifecycleOwner.lifecycleScope.launch {
val info = withContext(Dispatchers.IO) {
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
catch (_: Exception) { null }
}
(activity as? HomeActivity)?.setRefreshing(false)
if (info == null) {
Toast.makeText(requireContext(), R.string.bml_qr_lookup_failed, Toast.LENGTH_LONG).show()
requireActivity().onBackPressedDispatcher.onBackPressed()
return@launch
}
bmlQrInfo = info
// Show merchant in the "To" card — clear button hidden (can't change recipient for QR)
binding.tvToAccountName.text = info.merchantName
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(info.merchantName, "#0066A1"))
binding.btnClearToInfo.visibility = View.GONE
binding.cardToInfo.visibility = View.VISIBLE
// Pre-fill amount if dynamic QR
if (info.amount > 0.0) {
binding.etAmount.setText("%.2f".format(info.amount))
binding.tilAmount.isEnabled = false
}
// Remarks not applicable for merchant QR payments
binding.tilRemarks.isEnabled = false
binding.tilRemarks.alpha = 0.4f
updateTransferButton()
}
} }
private fun startLookupLoading() { private fun startLookupLoading() {
@@ -234,6 +330,13 @@ class TransferFragment : Fragment() {
binding.tilTo.endIconDrawable = ContextCompat.getDrawable(requireContext(), android.R.drawable.ic_menu_search) binding.tilTo.endIconDrawable = ContextCompat.getDrawable(requireContext(), android.R.drawable.ic_menu_search)
} }
// ── Sensitive masking helpers ─────────────────────────────────────────────
private fun maskAmount(formatted: String): String {
val currency = formatted.substringBefore(' ', formatted)
return "$currency ••••••"
}
private fun setupFromDropdown() { private fun setupFromDropdown() {
binding.btnClearFromInfo.setOnClickListener { binding.btnClearFromInfo.setOnClickListener {
selectedAccount = null selectedAccount = null
@@ -245,11 +348,11 @@ class TransferFragment : Fragment() {
} }
viewModel.accounts.observe(viewLifecycleOwner) { accounts -> viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
val adapter = AccountDropdownAdapter(requireContext(), accounts) accountDropdownAdapter = AccountDropdownAdapter(requireContext(), accounts)
binding.actvFrom.setAdapter(adapter) binding.actvFrom.setAdapter(accountDropdownAdapter)
binding.actvFrom.setOnItemClickListener { _, _, position, _ -> binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
val picked = adapter.getAccount(position) ?: return@setOnItemClickListener val picked = accountDropdownAdapter?.getAccount(position) ?: return@setOnItemClickListener
selectedAccount = picked selectedAccount = picked
updateAmountPrefix(picked) updateAmountPrefix(picked)
showFromCard(picked) showFromCard(picked)
@@ -278,24 +381,102 @@ class TransferFragment : Fragment() {
val bankLabel = when (account.bank) { val bankLabel = when (account.bank) {
"BML" -> "BML" "BML" -> "BML"
"FAHIPAY" -> "FP" "FAHIPAY" -> "FP"
"MIB" -> "MIB"
else -> null else -> null
} }
val typeLabel = when { val typeLabel = AccountListParser.from(account)?.typeLabel
account.profileType == "BML_PREPAID" -> "Prepaid Card" ?: if (account.bank == "BML") BmlDashboardParser.productLabel(account.accountTypeName)
account.profileType == "BML_CREDIT" -> "Credit Card" else account.accountTypeName.ifBlank { account.profileType }
account.accountTypeName.isNotBlank() -> account.accountTypeName
else -> account.profileType
}
val hide = viewModel.hideAmounts.value ?: false
val isDebitCard = account.profileType == "BML_DEBIT"
val balancePart = if (isDebitCard) null else "${account.currencyName} ${account.availableBalance}"
binding.tvFromAccountName.text = account.accountBriefName binding.tvFromAccountName.text = account.accountBriefName
binding.tvFromAccountNumber.text = account.accountNumber binding.tvFromAccountNumber.text = account.accountNumber
binding.tvFromAccountDetails.text = listOfNotNull(bankLabel, typeLabel, "${account.currencyName} ${account.availableBalance}").joinToString(" · ") binding.tvFromAccountDetails.text = listOfNotNull(bankLabel, typeLabel).joinToString(" · ")
binding.ivFromPhoto.setImageBitmap(makeInitialsBitmap(account.accountBriefName, colorHex)) val balanceDisplay = balancePart?.let { if (hide) maskAmount(it) else it }
if (balanceDisplay != null) {
binding.tvFromBalance.text = balanceDisplay
binding.tvFromBalance.visibility = View.VISIBLE
} else {
binding.tvFromBalance.visibility = View.GONE
}
val networkIcon = BmlCardParser.cardNetworkIcon(account)
when {
networkIcon != null -> {
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
binding.ivFromPhoto.setImageResource(networkIcon)
}
account.bank == "BML" -> {
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
binding.ivFromPhoto.setImageResource(R.drawable.bml_logo_vector)
}
account.bank == "FAHIPAY" -> {
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
binding.ivFromPhoto.setImageResource(R.drawable.fahipay_logo)
}
else -> {
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
binding.ivFromPhoto.setImageResource(R.drawable.mib_logo)
if (account.profileImageHash != null) loadFromPhoto(account.profileImageHash)
}
}
binding.tilFrom.visibility = View.GONE binding.tilFrom.visibility = View.GONE
binding.cardFromInfo.visibility = View.VISIBLE binding.cardFromInfo.visibility = View.VISIBLE
}
if (account.bank != "BML" && account.profileImageHash != null) { private fun showToCard(account: BankAccount) {
loadFromPhoto(account.profileImageHash) resolvedToOwnAccount = account
val colorHex = when (account.bank) {
"BML" -> "#0066A1"
"FAHIPAY" -> "#15BEA7"
else -> "#FE860E"
}
val bankLabel = when (account.bank) {
"BML" -> "BML"
"FAHIPAY" -> "FP"
"MIB" -> "MIB"
else -> null
}
val typeLabel = AccountListParser.from(account)?.typeLabel
?: if (account.bank == "BML") BmlDashboardParser.productLabel(account.accountTypeName)
else account.accountTypeName.ifBlank { account.profileType }
val hide = viewModel.hideAmounts.value ?: false
val isDebitCard = account.profileType == "BML_DEBIT"
val balancePart = if (isDebitCard) null else AccountListParser.from(account)?.balance
?: "${account.currencyName} ${account.availableBalance}"
binding.tvToAccountName.text = account.accountBriefName
binding.tvToBankBic.text = account.accountNumber
binding.tvToAccountDetails.text = listOfNotNull(bankLabel, typeLabel).joinToString(" · ")
binding.tvToAccountDetails.visibility = View.VISIBLE
val balanceDisplay = balancePart?.let { if (hide) maskAmount(it) else it }
if (balanceDisplay != null) {
binding.tvToBalance.text = balanceDisplay
binding.tvToBalance.visibility = View.VISIBLE
} else {
binding.tvToBalance.visibility = View.GONE
}
val networkIcon = BmlCardParser.cardNetworkIcon(account)
when {
networkIcon != null -> {
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
binding.ivToPhoto.setImageResource(networkIcon)
}
account.bank == "BML" -> {
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
binding.ivToPhoto.setImageResource(R.drawable.bml_logo_vector)
}
account.bank == "FAHIPAY" -> {
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
binding.ivToPhoto.setImageResource(R.drawable.fahipay_logo)
}
else -> {
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
binding.ivToPhoto.setImageResource(R.drawable.mib_logo)
if (account.profileImageHash != null) loadToPhoto(account.profileImageHash, isProfile = true)
}
} }
} }
@@ -308,7 +489,10 @@ class TransferFragment : Fragment() {
val bytes = Base64.decode(base64, Base64.DEFAULT) val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (_binding != null) binding.ivFromPhoto.setImageBitmap(bitmap) if (_binding != null) {
binding.ivFromPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivFromPhoto.setImageBitmap(bitmap)
}
} }
} catch (_: Exception) { } } catch (_: Exception) { }
} }
@@ -324,6 +508,7 @@ class TransferFragment : Fragment() {
binding.btnClearToInfo.setOnClickListener { binding.btnClearToInfo.setOnClickListener {
resolvedAccountNumber = "" resolvedAccountNumber = ""
resolvedRecipientName = "" resolvedRecipientName = ""
resolvedToOwnAccount = null
selectedFahipayService = null selectedFahipayService = null
binding.cardToInfo.visibility = View.GONE binding.cardToInfo.visibility = View.GONE
binding.layoutServiceSelector.visibility = View.INVISIBLE binding.layoutServiceSelector.visibility = View.INVISIBLE
@@ -339,6 +524,7 @@ class TransferFragment : Fragment() {
if (binding.cardToInfo.visibility == View.VISIBLE) { if (binding.cardToInfo.visibility == View.VISIBLE) {
resolvedAccountNumber = "" resolvedAccountNumber = ""
resolvedRecipientName = "" resolvedRecipientName = ""
resolvedToOwnAccount = null
binding.cardToInfo.visibility = View.GONE binding.cardToInfo.visibility = View.GONE
binding.tilTo.visibility = View.VISIBLE binding.tilTo.visibility = View.VISIBLE
binding.btnPickContact.visibility = View.VISIBLE binding.btnPickContact.visibility = View.VISIBLE
@@ -448,9 +634,16 @@ class TransferFragment : Fragment() {
resolvedRecipientName = info.accountName resolvedRecipientName = info.accountName
resolvedBankName = info.bankId resolvedBankName = info.bankId
binding.tvToAccountName.text = displayName if (matchedAcc != null) {
binding.tvToBankBic.text = "${info.accountNumber} · ${info.bankId}" showToCard(matchedAcc)
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex)) } else {
binding.tvToAccountName.text = displayName
binding.tvToBankBic.text = "${info.accountNumber} · ${info.bankId}"
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
}
binding.tilTo.visibility = View.GONE binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE binding.btnScanQr.visibility = View.GONE
@@ -575,9 +768,18 @@ class TransferFragment : Fragment() {
val contacts = viewModel.contacts.value ?: emptyList() val contacts = viewModel.contacts.value ?: emptyList()
resolvedBankName = contacts.firstOrNull { it.benefAccount == accountNumber }?.benefBankName ?: "" resolvedBankName = contacts.firstOrNull { it.benefAccount == accountNumber }?.benefBankName ?: ""
binding.tvToAccountName.text = displayName val ownAccount = viewModel.accounts.value?.firstOrNull { it.accountNumber == accountNumber }
binding.tvToBankBic.text = subtitle if (ownAccount != null) {
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex)) showToCard(ownAccount)
} else {
resolvedToOwnAccount = null
binding.tvToAccountName.text = displayName
binding.tvToBankBic.text = subtitle
binding.tvToAccountDetails.visibility = View.GONE
binding.tvToBalance.visibility = View.GONE
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(displayName, colorHex))
}
binding.tilTo.visibility = View.GONE binding.tilTo.visibility = View.GONE
binding.btnPickContact.visibility = View.GONE binding.btnPickContact.visibility = View.GONE
binding.btnScanQr.visibility = View.GONE binding.btnScanQr.visibility = View.GONE
@@ -612,7 +814,76 @@ class TransferFragment : Fragment() {
// ── Transfer ────────────────────────────────────────────────────────────── // ── Transfer ──────────────────────────────────────────────────────────────
/** Shared confirm dialog with optional biometric gate, used for both transfers and QR payments. */
private fun showConfirmWithBiometric(
title: String,
message: String? = null,
customView: android.view.View? = null,
biometricSubtitle: String,
onConfirmed: () -> Unit
) {
val builder = MaterialAlertDialogBuilder(requireContext())
.setTitle(title)
.setPositiveButton(R.string.transfer_confirm) { _, _ -> onConfirmed() }
.setNegativeButton(android.R.string.cancel, null)
if (customView != null) builder.setView(customView) else builder.setMessage(message)
val dialog = builder.show()
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false)
val canAuth = BiometricManager.from(requireContext())
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
if (biometricTransferConfirm && canAuth) {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()),
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
dialog.dismiss()
onConfirmed()
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
if (errorCode != BiometricPrompt.ERROR_CANCELED &&
errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
Toast.makeText(requireContext(), errString, Toast.LENGTH_SHORT).show()
}
}
override fun onAuthenticationFailed() { /* keep dialog open */ }
})
prompt.authenticate(
BiometricPrompt.PromptInfo.Builder()
.setTitle(getString(R.string.biometric_transfer_title))
.setSubtitle(biometricSubtitle)
.setNegativeButtonText(getString(android.R.string.cancel))
.build()
)
}
}
}
private fun initiateTransfer() { private fun initiateTransfer() {
// BML QR merchant payment — uses shared confirm dialog, no receipt
bmlQrInfo?.let { info ->
val src = selectedAccount ?: run {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, 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 = src.internalId.ifBlank {
Toast.makeText(requireContext(), R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
return
}
showConfirmWithBiometric(
title = getString(R.string.transfer),
message = "Pay ${info.currency} ${"%.2f".format(amount)} to ${info.merchantName}?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}",
biometricSubtitle = "${info.currency} ${"%.2f".format(amount)}${info.merchantName}",
onConfirmed = { executeBmlQrPayment(src, debitAccount, info, amount) }
)
return
}
val src = selectedAccount ?: run { val src = selectedAccount ?: run {
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show() Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
return return
@@ -632,7 +903,7 @@ class TransferFragment : Fragment() {
val isSrcBml = src.bank == "BML" val isSrcBml = src.bank == "BML"
val isBmlBusiness = isSrcBml && isBusinessProfile(src) // to test on personal accounts: use `isSrcBml` val isBmlBusiness = isSrcBml && isBusinessProfile(src) // to test on personal accounts: use `isSrcBml`
val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT" val isSrcCard = src.profileType == "BML_PREPAID" || src.profileType == "BML_CREDIT" || src.profileType == "BML_DEBIT"
val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT val isDestMib = AccountInputParser.detect(resolvedAccountNumber) == AccountInputParser.InputType.MIB_ACCOUNT
val currency = src.currencyName.ifBlank { "MVR" } val currency = src.currencyName.ifBlank { "MVR" }
val allAccounts = viewModel.accounts.value ?: emptyList() val allAccounts = viewModel.accounts.value ?: emptyList()
@@ -692,36 +963,31 @@ class TransferFragment : Fragment() {
activity.triggerRefresh() activity.triggerRefresh()
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar)) activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
} else if (!ok) { } else if (!ok) {
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show() if (msg == "CONNECTIVITY") {
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
} else {
Toast.makeText(requireContext(), msg, Toast.LENGTH_LONG).show()
}
} }
} }
} }
} }
val dialogBuilder = MaterialAlertDialogBuilder(requireContext()) val warningView: android.view.View? = if (isUsdToMvr || isSrcCredit) {
.setTitle(R.string.transfer)
.setPositiveButton(R.string.transfer_confirm) { _, _ -> doTransfer() }
.setNegativeButton(android.R.string.cancel, null)
if (isUsdToMvr || isSrcCredit) {
val ctx = requireContext() val ctx = requireContext()
val dp = resources.displayMetrics.density val dp = resources.displayMetrics.density
val container = LinearLayout(ctx).apply { LinearLayout(ctx).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
setPadding((24 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt(), 0) setPadding((24 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt(), 0)
} addView(TextView(ctx).apply { text = mainMsg })
container.addView(TextView(ctx).apply { text = mainMsg }) if (isUsdToMvr) addView(TextView(ctx).apply {
if (isUsdToMvr) {
container.addView(TextView(ctx).apply {
text = "⚠ You are transferring from a USD account to an MVR account. The currency will be converted at the bank's rate and this cannot be reversed!" text = "⚠ You are transferring from a USD account to an MVR account. The currency will be converted at the bank's rate and this cannot be reversed!"
setTextColor(Color.RED) setTextColor(Color.RED)
textSize = 16f textSize = 16f
typeface = Typeface.DEFAULT_BOLD typeface = Typeface.DEFAULT_BOLD
setPadding(0, (16 * dp).toInt(), 0, 0) setPadding(0, (16 * dp).toInt(), 0, 0)
}) })
} if (isSrcCredit) addView(TextView(ctx).apply {
if (isSrcCredit) {
container.addView(TextView(ctx).apply {
text = "⚠ Transferring from a credit card is treated as a cash advance. Cash advance fees will be charged on the 10th of the month." text = "⚠ Transferring from a credit card is treated as a cash advance. Cash advance fees will be charged on the 10th of the month."
setTextColor(Color.RED) setTextColor(Color.RED)
textSize = 16f textSize = 16f
@@ -729,45 +995,123 @@ class TransferFragment : Fragment() {
setPadding(0, (16 * dp).toInt(), 0, 0) setPadding(0, (16 * dp).toInt(), 0, 0)
}) })
} }
dialogBuilder.setView(container) } else null
} else { showConfirmWithBiometric(
dialogBuilder.setMessage(mainMsg) title = getString(R.string.transfer),
message = if (warningView == null) mainMsg else null,
customView = warningView,
biometricSubtitle = "$currency $amountStr$destDisplay",
onConfirmed = { doTransfer() }
)
}
private fun executeBmlQrPayment(
src: BankAccount,
debitAccount: String,
info: BmlQrPayInfo,
amount: Double
) {
val app = requireActivity().application as BasedBankApp
val loginId = src.loginTag.removePrefix("bml_")
val session = bmlSessionFor(src) ?: 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 }
val dialog = dialogBuilder.show() binding.btnTransfer.isEnabled = false
(activity as? HomeActivity)?.setRefreshing(true)
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE) viewLifecycleOwner.lifecycleScope.launch {
val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false) val result = withContext(Dispatchers.IO) {
val canAuth = BiometricManager.from(requireContext()) try {
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS if (bmlGatewayQr) {
if (biometricTransferConfirm && canAuth) { val preOk = BmlQrPayClient().preInitiatePayment(
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { session, debitAccount, info.requestId, amount, info.currency)
val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()), if (!preOk) return@withContext null
object : BiometricPrompt.AuthenticationCallback() { }
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { val initiated = BmlQrPayClient().initiatePayment(
dialog.dismiss() session, debitAccount, info.requestId, amount, info.currency)
doTransfer() if (!initiated) return@withContext null
} val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials(loginId)
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { ?.otpSeed?.let { Totp.generate(it) } ?: otp
if (errorCode != BiometricPrompt.ERROR_CANCELED && BmlQrPayClient().confirmPayment(
errorCode != BiometricPrompt.ERROR_USER_CANCELED && session, debitAccount, info.requestId, amount, info.currency, confirmOtp)
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) { } catch (e: Exception) {
Toast.makeText(requireContext(), errString, Toast.LENGTH_SHORT).show() sh.sar.basedbank.api.bml.BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
} }
} }
override fun onAuthenticationFailed() { /* keep dialog open */ } (activity as? HomeActivity)?.setRefreshing(false)
}) if (_binding == null) return@launch
prompt.authenticate(
BiometricPrompt.PromptInfo.Builder() if (result == null) {
.setTitle(getString(R.string.biometric_transfer_title)) binding.btnTransfer.isEnabled = true
.setSubtitle("$currency $amountStr$destDisplay") Toast.makeText(requireContext(), "Failed to initiate payment", Toast.LENGTH_LONG).show()
.setNegativeButtonText(getString(android.R.string.cancel)) return@launch
.build() }
if (result.success) {
showBmlQrSuccessDialog(
merchant = result.merchant.ifBlank { info.merchantName },
amount = result.amount.ifBlank { "%.2f".format(amount) },
currency = result.currency.ifBlank { info.currency }
) )
} else {
binding.btnTransfer.isEnabled = true
Toast.makeText(requireContext(), result.errorMessage, Toast.LENGTH_LONG).show()
} }
} }
} }
private fun showBmlQrSuccessDialog(merchant: String, amount: String, currency: String) {
val ctx = requireContext()
val dp = resources.displayMetrics.density
val container = android.widget.LinearLayout(ctx).apply {
orientation = android.widget.LinearLayout.VERTICAL
gravity = android.view.Gravity.CENTER_HORIZONTAL
setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
}
container.addView(android.widget.ImageView(ctx).apply {
setImageResource(R.drawable.ic_check_circle)
setColorFilter(android.graphics.Color.parseColor("#4CAF50"))
layoutParams = android.widget.LinearLayout.LayoutParams(
(64 * dp).toInt(), (64 * dp).toInt()
).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL; bottomMargin = (16 * dp).toInt() }
})
container.addView(android.widget.TextView(ctx).apply {
text = "$currency $amount"
textSize = 28f
setTypeface(null, android.graphics.Typeface.BOLD)
setTextColor(com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK))
gravity = android.view.Gravity.CENTER
layoutParams = android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT,
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL; bottomMargin = (8 * dp).toInt() }
})
container.addView(android.widget.TextView(ctx).apply {
text = merchant
textSize = 14f
setTextColor(com.google.android.material.color.MaterialColors.getColor(
requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, android.graphics.Color.GRAY))
gravity = android.view.Gravity.CENTER
layoutParams = android.widget.LinearLayout.LayoutParams(
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT,
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL }
})
MaterialAlertDialogBuilder(ctx)
.setTitle(R.string.bml_qr_payment_success)
.setView(container)
.setPositiveButton(android.R.string.ok) { _, _ ->
requireActivity().onBackPressedDispatcher.onBackPressed()
}
.setCancelable(false)
.show()
}
private fun doMibTransfer( private fun doMibTransfer(
src: BankAccount, src: BankAccount,
destAccount: String, destAccount: String,
@@ -780,12 +1124,6 @@ class TransferFragment : Fragment() {
val sess = session ?: return Triple(false, getString(R.string.transfer_session_unavailable), null) val sess = session ?: return Triple(false, getString(R.string.transfer_session_unavailable), null)
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
val loginId = src.loginTag.removePrefix("mib_") val loginId = src.loginTag.removePrefix("mib_")
// Switch to the profile that owns the source account
if (src.profileId.isNotBlank()) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = profiles.firstOrNull { it.profileId == src.profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
}
val otp = CredentialStore(requireContext()).loadMibCredentials(loginId)?.otpSeed val otp = CredentialStore(requireContext()).loadMibCredentials(loginId)?.otpSeed
?.let { Totp.generate(it) } ?.let { Totp.generate(it) }
?: return Triple(false, "OTP unavailable", null) ?: return Triple(false, "OTP unavailable", null)
@@ -802,6 +1140,12 @@ class TransferFragment : Fragment() {
} }
} }
return try { return try {
// Switch to the profile that owns the source account
if (src.profileId.isNotBlank()) {
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
val profile = profiles.firstOrNull { it.profileId == src.profileId }
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
}
val result = MibTransferClient().transfer( val result = MibTransferClient().transfer(
session = sess, session = sess,
fromAccount = src.accountNumber, fromAccount = src.accountNumber,
@@ -833,7 +1177,7 @@ class TransferFragment : Fragment() {
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null) Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
} }
} catch (e: Exception) { } catch (e: Exception) {
Triple(false, e.message ?: "Transfer failed", null) Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
} }
} }
@@ -860,7 +1204,7 @@ class TransferFragment : Fragment() {
} }
// Determine type + credit account // Determine type + credit account
val isDestMyCard = allAccounts.any { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && it.accountNumber == destAccount } val isDestMyCard = allAccounts.any { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.accountNumber == destAccount }
val (transferType, creditAccount, bank) = when { val (transferType, creditAccount, bank) = when {
isSrcCard -> { isSrcCard -> {
// CAD: card → own BML account // CAD: card → own BML account
@@ -869,7 +1213,7 @@ class TransferFragment : Fragment() {
} }
isDestMyCard -> { isDestMyCard -> {
// CPA: BML CASA → own card top-up // CPA: BML CASA → own card top-up
val card = allAccounts.first { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && it.accountNumber == destAccount } val card = allAccounts.first { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.accountNumber == destAccount }
Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?) Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?)
} }
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB") isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
@@ -886,7 +1230,7 @@ class TransferFragment : Fragment() {
// Step 1: initiate // Step 1: initiate
val initiated = try { val initiated = try {
BmlTransferClient().initiateTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, bank) BmlTransferClient().initiateTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, bank)
} catch (e: Exception) { return Triple(false, e.message ?: "Initiation failed", null) } } catch (e: Exception) { return Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Initiation failed"), null) }
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null) if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
@@ -918,7 +1262,7 @@ class TransferFragment : Fragment() {
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null) Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
} }
} catch (e: Exception) { } catch (e: Exception) {
Triple(false, e.message ?: "Transfer failed", null) Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
} }
} }
@@ -950,7 +1294,7 @@ class TransferFragment : Fragment() {
return return
} }
val isDestMyCard = allAccounts.any { val isDestMyCard = allAccounts.any {
(it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && it.accountNumber == destAccount (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.accountNumber == destAccount
} }
val (transferType, creditAccount, bank) = when { val (transferType, creditAccount, bank) = when {
isSrcCard -> { isSrcCard -> {
@@ -959,7 +1303,7 @@ class TransferFragment : Fragment() {
} }
isDestMyCard -> { isDestMyCard -> {
val card = allAccounts.first { val card = allAccounts.first {
(it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && it.accountNumber == destAccount (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.accountNumber == destAccount
} }
Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?) Triple("CPA", card.internalId.ifBlank { destAccount }, null as String?)
} }
@@ -1160,7 +1504,7 @@ class TransferFragment : Fragment() {
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null as TransferReceiptData?) Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null as TransferReceiptData?)
} }
} catch (e: Exception) { } catch (e: Exception) {
Triple(false, e.message ?: "Transfer failed", null as TransferReceiptData?) Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null as TransferReceiptData?)
} }
} }
(activity as? HomeActivity)?.setRefreshing(false) (activity as? HomeActivity)?.setRefreshing(false)
@@ -1174,7 +1518,11 @@ class TransferFragment : Fragment() {
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar)) activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
} else { } else {
binding.btnTransfer.isEnabled = true binding.btnTransfer.isEnabled = true
binding.tilBmlOtp.error = msg if (msg == "CONNECTIVITY") {
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
} else {
binding.tilBmlOtp.error = msg
}
} }
} }
} }
@@ -1214,7 +1562,13 @@ class TransferFragment : Fragment() {
private fun updateTransferButton() { private fun updateTransferButton() {
if (bmlOtpState != BmlOtpState.NONE) return if (bmlOtpState != BmlOtpState.NONE) return
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0 val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
binding.btnTransfer.isEnabled = selectedAccount != null && resolvedAccountNumber.isNotBlank() && amount > 0 val recipientReady = if (bmlQrInfo != null) bmlQrInfo != null else resolvedAccountNumber.isNotBlank()
val hasAll = selectedAccount != null && recipientReady && amount > 0
if (!hasAll) { binding.btnTransfer.isEnabled = false; return }
val errors = viewModel.connectivityErrors.value ?: emptySet()
val bankOffline = "NO_INTERNET" in errors ||
selectedAccount?.bank?.uppercase()?.let { it in errors } == true
binding.btnTransfer.isEnabled = !bankOffline
} }
private fun clearForm() { private fun clearForm() {
@@ -1229,6 +1583,7 @@ class TransferFragment : Fragment() {
resolvedAccountNumber = "" resolvedAccountNumber = ""
resolvedRecipientName = "" resolvedRecipientName = ""
resolvedBankName = "" resolvedBankName = ""
resolvedToOwnAccount = null
selectedFahipayService = null selectedFahipayService = null
binding.cardToInfo.visibility = View.GONE binding.cardToInfo.visibility = View.GONE
binding.chipGroupService.visibility = View.GONE binding.chipGroupService.visibility = View.GONE
@@ -1255,7 +1610,10 @@ class TransferFragment : Fragment() {
val bytes = Base64.decode(base64, Base64.DEFAULT) val bytes = Base64.decode(base64, Base64.DEFAULT)
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (_binding != null) binding.ivToPhoto.setImageBitmap(bitmap) if (_binding != null) {
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
binding.ivToPhoto.setImageBitmap(bitmap)
}
} }
} catch (_: Exception) { } } catch (_: Exception) { }
} }
@@ -1337,8 +1695,8 @@ class TransferFragment : Fragment() {
) : BaseAdapter(), Filterable { ) : BaseAdapter(), Filterable {
private val items: List<Any> = buildList { private val items: List<Any> = buildList {
val regular = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_LOAN" } val regular = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" }
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" } val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
addAll(regular) addAll(regular)
if (cards.isNotEmpty()) { if (cards.isNotEmpty()) {
add(getString(R.string.cards)) add(getString(R.string.cards))
@@ -1347,7 +1705,7 @@ class TransferFragment : Fragment() {
} }
fun getAccount(position: Int): BankAccount? = (items.getOrNull(position) as? BankAccount) fun getAccount(position: Int): BankAccount? = (items.getOrNull(position) as? BankAccount)
?.takeUnless { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT") && !it.statusDesc.equals("Active", ignoreCase = true) } ?.takeUnless { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && !it.statusDesc.equals("Active", ignoreCase = true) }
override fun getCount() = items.size override fun getCount() = items.size
override fun getItem(position: Int) = items[position] override fun getItem(position: Int) = items[position]
@@ -1378,13 +1736,109 @@ class TransferFragment : Fragment() {
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false) ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
.also { it.root.tag = it } .also { it.root.tag = it }
} }
val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT") && !acc.statusDesc.equals("Active", ignoreCase = true) val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT" || acc.profileType == "BML_DEBIT") && !acc.statusDesc.equals("Active", ignoreCase = true)
val isBmlAccount = acc.bank == "BML" val isBmlAccount = acc.bank == "BML"
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else "" val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
val hide = viewModel.hideAmounts.value ?: false
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}" 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 = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber b.tvDropdownAccountNumber.text = if (inactive) "${acc.accountNumber} · ${acc.statusDesc}" else acc.accountNumber
b.tvDropdownBalance.text = AccountListParser.from(acc)?.balance ?: "" if (typeLabel.isNotBlank()) {
b.tvDropdownAccountType.text = typeLabel
b.tvDropdownAccountType.visibility = View.VISIBLE
} else {
b.tvDropdownAccountType.visibility = View.GONE
}
val balance = displayData?.balance ?: ""
b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance
b.root.alpha = if (inactive) 0.4f else 1f b.root.alpha = if (inactive) 0.4f else 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)
android.graphics.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
}
b.root b.root
} }
} }
@@ -30,6 +30,7 @@ import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
import sh.sar.basedbank.api.models.BankAccount import sh.sar.basedbank.api.models.BankAccount
import sh.sar.basedbank.api.mib.MibContactsClient import sh.sar.basedbank.api.mib.MibContactsClient
import sh.sar.basedbank.api.mib.MibHistoryClient import sh.sar.basedbank.api.mib.MibHistoryClient
import sh.sar.basedbank.api.models.BankServerException
import sh.sar.basedbank.api.models.BankTransaction import sh.sar.basedbank.api.models.BankTransaction
import sh.sar.basedbank.api.mib.TransactionCache import sh.sar.basedbank.api.mib.TransactionCache
import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding
@@ -64,7 +65,7 @@ class TransferHistoryFragment : Fragment() {
) { ) {
fun hasMore(): Boolean = when { fun hasMore(): Boolean = when {
account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2 account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT" -> cardMonthOffset < 2
account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
} }
@@ -161,8 +162,15 @@ class TransferHistoryFragment : Fragment() {
val accounts = accountStates.map { it.account } val accounts = accountStates.map { it.account }
accountStates.clear() accountStates.clear()
accounts.forEach { accountStates.add(AccountState(it)) } accounts.forEach { accountStates.add(AccountState(it)) }
adapter.setTransactions(emptyList()) // Restore cache immediately so data stays visible while refreshing
binding.emptyView.visibility = View.GONE val cached = TransactionCache.load(requireContext(), "transfer")
if (cached.isNotEmpty()) {
allTransactions.addAll(cached)
filterAndDisplay()
} else {
adapter.setTransactions(emptyList())
binding.emptyView.visibility = View.GONE
}
loadNextPages() loadNextPages()
} }
@@ -173,11 +181,22 @@ class TransferHistoryFragment : Fragment() {
if (firstBatchDone && allTransactions.isNotEmpty()) { if (firstBatchDone && allTransactions.isNotEmpty()) {
adapter.showLoadingFooter = true adapter.showLoadingFooter = true
} else if (allTransactions.isEmpty()) {
binding.skeletonContainer.visibility = View.VISIBLE
binding.emptyView.visibility = View.GONE
} }
val app = requireActivity().application as BasedBankApp val app = requireActivity().application as BasedBankApp
lifecycleScope.launch { lifecycleScope.launch {
val bannerMsg = java.util.concurrent.atomic.AtomicReference<String?>(null)
fun trackError(e: Exception) {
when {
e is java.io.IOException -> bannerMsg.compareAndSet(null, "IO")
e is BankServerException -> bannerMsg.compareAndSet(null, "SERVER:${e.bankName}")
}
}
val newTransactions = withContext(Dispatchers.IO) { val newTransactions = withContext(Dispatchers.IO) {
val results = mutableListOf<BankTransaction>() val results = mutableListOf<BankTransaction>()
@@ -187,7 +206,7 @@ class TransferHistoryFragment : Fragment() {
async { async {
try { try {
when { when {
state.account.profileType == "BML_PREPAID" || state.account.profileType == "BML_CREDIT" -> { state.account.profileType == "BML_PREPAID" || state.account.profileType == "BML_CREDIT" || state.account.profileType == "BML_DEBIT" -> {
val session = app.bmlSessionFor(state.account) ?: return@async emptyList() val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
val cal = Calendar.getInstance() val cal = Calendar.getInstance()
cal.add(Calendar.MONTH, -state.cardMonthOffset) cal.add(Calendar.MONTH, -state.cardMonthOffset)
@@ -215,7 +234,7 @@ class TransferHistoryFragment : Fragment() {
list list
} }
} }
} catch (_: Exception) { emptyList<BankTransaction>() } } catch (e: Exception) { trackError(e); emptyList<BankTransaction>() }
} }
}.awaitAll().flatten()) }.awaitAll().flatten())
@@ -233,7 +252,7 @@ class TransferHistoryFragment : Fragment() {
if (total > 0) state.fahipayTotal = total if (total > 0) state.fahipayTotal = total
state.fahipayNextStart += list.size state.fahipayNextStart += list.size
results.addAll(list) results.addAll(list)
} catch (_: Exception) {} } catch (e: Exception) { trackError(e) }
} }
// MIB accounts: serialized per profile, protected by mutex to prevent session race // MIB accounts: serialized per profile, protected by mutex to prevent session race
@@ -242,23 +261,25 @@ class TransferHistoryFragment : Fragment() {
val session = app.mibSessions[loginId] ?: continue val session = app.mibSessions[loginId] ?: continue
for ((profileId, states) in loginStates.groupBy { it.account.profileId }) { for ((profileId, states) in loginStates.groupBy { it.account.profileId }) {
app.mibMutex.withLock { app.mibMutex.withLock {
val profiles = app.mibProfilesMap[loginId] ?: emptyList() try {
val profile = profiles.firstOrNull { it.profileId == profileId } val profiles = app.mibProfilesMap[loginId] ?: emptyList()
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile) val profile = profiles.firstOrNull { it.profileId == profileId }
for (state in states) { if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
try { for (state in states) {
val (list, total) = MibHistoryClient().fetchHistory( try {
session = session, val (list, total) = MibHistoryClient().fetchHistory(
accountNo = state.account.accountNumber, session = session,
accountDisplayName = state.account.accountBriefName, accountNo = state.account.accountNumber,
start = state.mibNextStart, accountDisplayName = state.account.accountBriefName,
pageSize = pageSize start = state.mibNextStart,
) pageSize = pageSize
if (total > 0) state.mibTotalCount = total )
state.mibNextStart += list.size.coerceAtLeast(pageSize) if (total > 0) state.mibTotalCount = total
results.addAll(list) state.mibNextStart += list.size.coerceAtLeast(pageSize)
} catch (_: Exception) {} results.addAll(list)
} } catch (e: Exception) { trackError(e) }
}
} catch (e: Exception) { trackError(e) }
} }
} }
} }
@@ -267,6 +288,17 @@ class TransferHistoryFragment : Fragment() {
} }
isLoading = false isLoading = false
if (_binding == null) return@launch
binding.skeletonContainer.visibility = View.GONE
val raw = bannerMsg.get()
when {
raw == null -> (activity as? HomeActivity)?.hideConnectivityBanner()
raw == "IO" -> (activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
raw.startsWith("SERVER:") -> (activity as? HomeActivity)?.showConnectivityBanner(
getString(R.string.connectivity_server_error, raw.removePrefix("SERVER:"))
)
}
if (!firstBatchDone) { if (!firstBatchDone) {
firstBatchDone = true firstBatchDone = true
@@ -301,6 +333,8 @@ class TransferHistoryFragment : Fragment() {
} }
adapter.setTransactions(filtered) adapter.setTransactions(filtered)
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
binding.skeletonContainer.visibility =
if (allTransactions.isEmpty() && isLoading) View.VISIBLE else View.GONE
} }
private fun loadContactImage(name: String) { private fun loadContactImage(name: String) {
@@ -215,6 +215,7 @@ class CredentialsFragment : Fragment() {
app.mibSessions[loginId] = flow.lastSession!! app.mibSessions[loginId] = flow.lastSession!!
app.mibProfilesMap[loginId] = flow.lastProfiles app.mibProfilesMap[loginId] = flow.lastProfiles
app.mibLoginFlows[loginId] = flow app.mibLoginFlows[loginId] = flow
app.isUnlocked = true
val intent = Intent(requireContext(), HomeActivity::class.java) val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent) startActivity(intent)
@@ -364,6 +365,7 @@ class CredentialsFragment : Fragment() {
if (hasBusinessProfiles) { if (hasBusinessProfiles) {
Toast.makeText(requireContext(), "Business profiles can be enabled in Settings → Logins", Toast.LENGTH_LONG).show() 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) val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent) startActivity(intent)
@@ -496,6 +498,7 @@ class CredentialsFragment : Fragment() {
app.fahipaySessions[loginId] = session app.fahipaySessions[loginId] = session
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != loginTag } + listOf(account) app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != loginTag } + listOf(account)
app.accounts = app.accounts.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) val intent = Intent(requireContext(), HomeActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent) startActivity(intent)
@@ -1,10 +1,14 @@
package sh.sar.basedbank.ui.login package sh.sar.basedbank.ui.login
import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.os.Bundle import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.LockActivity
import sh.sar.basedbank.databinding.ActivityLoginBinding import sh.sar.basedbank.databinding.ActivityLoginBinding
import sh.sar.basedbank.util.CredentialStore
class LoginActivity : AppCompatActivity() { class LoginActivity : AppCompatActivity() {
@@ -12,6 +16,16 @@ class LoginActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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) WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityLoginBinding.inflate(layoutInflater) binding = ActivityLoginBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
@@ -13,9 +13,12 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.viewpager2.widget.ViewPager2 import androidx.viewpager2.widget.ViewPager2
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.LockActivity
import sh.sar.basedbank.R import sh.sar.basedbank.R
import sh.sar.basedbank.databinding.ActivityOnboardingBinding import sh.sar.basedbank.databinding.ActivityOnboardingBinding
import sh.sar.basedbank.ui.login.LoginActivity import sh.sar.basedbank.ui.login.LoginActivity
import sh.sar.basedbank.util.CredentialStore
class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback { class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
@@ -25,6 +28,15 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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) WindowCompat.setDecorFitsSystemWindows(window, false)
binding = ActivityOnboardingBinding.inflate(layoutInflater) binding = ActivityOnboardingBinding.inflate(layoutInflater)
setContentView(binding.root) setContentView(binding.root)
@@ -97,6 +109,9 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
binding.btnGetStarted.setOnClickListener { binding.btnGetStarted.setOnClickListener {
prefs.edit().putBoolean("onboarding_done", true).apply() 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)) startActivity(Intent(this, LoginActivity::class.java))
finish() finish()
} }
@@ -19,7 +19,7 @@ object AccountCache {
put("bank", acc.bank) put("bank", acc.bank)
put("profileName", acc.profileName) put("profileName", acc.profileName)
put("profileType", acc.profileType) put("profileType", acc.profileType)
put("cifType", acc.cifType) put("productCode", acc.productCode)
put("accountNumber", acc.accountNumber) put("accountNumber", acc.accountNumber)
put("accountBriefName", acc.accountBriefName) put("accountBriefName", acc.accountBriefName)
put("currencyName", acc.currencyName) put("currencyName", acc.currencyName)
@@ -44,6 +44,7 @@ object AccountCache {
arr.put(JSONObject().apply { arr.put(JSONObject().apply {
put("profileName", acc.profileName) put("profileName", acc.profileName)
put("profileType", acc.profileType) put("profileType", acc.profileType)
put("productCode", acc.productCode)
put("accountNumber", acc.accountNumber) put("accountNumber", acc.accountNumber)
put("accountBriefName", acc.accountBriefName) put("accountBriefName", acc.accountBriefName)
put("currencyName", acc.currencyName) put("currencyName", acc.currencyName)
@@ -55,6 +56,7 @@ object AccountCache {
put("statusDesc", acc.statusDesc) put("statusDesc", acc.statusDesc)
put("loginTag", acc.loginTag) put("loginTag", acc.loginTag)
put("internalId", acc.internalId) put("internalId", acc.internalId)
put("profileId", acc.profileId)
}) })
} }
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
@@ -72,6 +74,7 @@ object AccountCache {
bank = "BML", bank = "BML",
profileName = o.optString("profileName"), profileName = o.optString("profileName"),
profileType = o.optString("profileType"), profileType = o.optString("profileType"),
productCode = o.optString("productCode", ""),
accountNumber = o.optString("accountNumber"), accountNumber = o.optString("accountNumber"),
accountBriefName = o.optString("accountBriefName"), accountBriefName = o.optString("accountBriefName"),
currencyName = o.optString("currencyName"), currencyName = o.optString("currencyName"),
@@ -83,7 +86,8 @@ object AccountCache {
statusDesc = o.optString("statusDesc"), statusDesc = o.optString("statusDesc"),
profileImageHash = null, profileImageHash = null,
loginTag = o.optString("loginTag"), loginTag = o.optString("loginTag"),
internalId = o.optString("internalId", "") internalId = o.optString("internalId", ""),
profileId = o.optString("profileId", "")
) )
} }
} catch (_: Exception) { emptyList() } } catch (_: Exception) { emptyList() }
@@ -162,7 +166,7 @@ object AccountCache {
bank = o.optString("bank", "MIB"), bank = o.optString("bank", "MIB"),
profileName = o.optString("profileName"), profileName = o.optString("profileName"),
profileType = o.optString("profileType"), profileType = o.optString("profileType"),
cifType = o.optString("cifType", ""), productCode = o.optString("productCode", ""),
accountNumber = o.optString("accountNumber"), accountNumber = o.optString("accountNumber"),
accountBriefName = o.optString("accountBriefName"), accountBriefName = o.optString("accountBriefName"),
currencyName = o.optString("currencyName"), currencyName = o.optString("currencyName"),
@@ -5,6 +5,7 @@ data class AccountListDisplay(
val number: String, val number: String,
val typeLabel: String, val typeLabel: String,
val balance: String, val balance: String,
val blockedBalance: String? = null, // null when zero or not applicable
val isCard: Boolean = false, val isCard: Boolean = false,
val cardBrandIcon: Int = 0, // drawable res, only meaningful if isCard val cardBrandIcon: Int = 0, // drawable res, only meaningful if isCard
val statusLabel: String? = null // null = active; shown as status pill if set val statusLabel: String? = null // null = active; shown as status pill if set
@@ -21,7 +21,7 @@ import java.util.Locale
class HistoryFetcher(private val account: BankAccount) { class HistoryFetcher(private val account: BankAccount) {
private val isMib get() = account.bank == "MIB" private val isMib get() = account.bank == "MIB"
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
private val isBmlLoan get() = account.profileType == "BML_LOAN" private val isBmlLoan get() = account.profileType == "BML_LOAN"
private val isFahipay get() = account.bank == "FAHIPAY" private val isFahipay get() = account.bank == "FAHIPAY"
@@ -0,0 +1,56 @@
package sh.sar.basedbank.util
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import java.io.File
/**
* Stores and loads profile images locally for BML and Fahipay accounts.
*
* Key conventions:
* BML profile: "bml_${profileId}"
* Fahipay login: "fahipay_${loginId}"
*/
object ProfileImageStore {
private fun dir(context: Context): File =
File(context.filesDir, "profile_images").also { it.mkdirs() }
private fun file(context: Context, key: String): File =
File(dir(context), "${key.replace(Regex("[^A-Za-z0-9_\\-]"), "_")}.jpg")
fun save(context: Context, key: String, bitmap: Bitmap) {
try {
file(context, key).outputStream().use {
bitmap.compress(Bitmap.CompressFormat.JPEG, 90, it)
}
} catch (_: Exception) {}
}
fun load(context: Context, key: String): Bitmap? = try {
val f = file(context, key)
if (f.exists()) BitmapFactory.decodeFile(f.absolutePath) else null
} catch (_: Exception) { null }
fun delete(context: Context, key: String) {
try { file(context, key).delete() } catch (_: Exception) {}
}
fun exists(context: Context, key: String): Boolean =
try { file(context, key).exists() } catch (_: Exception) { false }
fun clearAll(context: Context) {
try { dir(context).listFiles()?.forEach { it.delete() } } catch (_: Exception) {}
}
/** Key for a BML profile given its profileId. */
fun bmlKey(profileId: String) = "bml_$profileId"
/** Key for a Fahipay login given its loginId. */
fun fahipayKey(loginId: String) = "fahipay_$loginId"
/** Derives the loginId from a loginTag (e.g. "fahipay_abc" → "abc"). */
fun loginIdFromTag(loginTag: String): String =
loginTag.substringAfter('_', loginTag)
}
@@ -0,0 +1,55 @@
package sh.sar.basedbank.util.bmlapi
import sh.sar.basedbank.R
import sh.sar.basedbank.api.models.BankAccount
object BmlCardParser {
/** Returns the drawable res for the card network logo, or null for non-card/unknown. */
fun cardNetworkIcon(account: BankAccount): Int? = when {
account.productCode.startsWith("C1") -> R.drawable.visa
account.productCode.startsWith("C3") -> R.drawable.americanexpress
account.productCode == "C8905" || account.productCode == "C8995" -> R.drawable.visa
account.productCode.startsWith("C8") -> R.drawable.mastercard
else -> null
}
/**
* Returns the asset path for the card image.
* The product code is stored in [BankAccount.productCode] for BML Card accounts.
*/
fun cardImageAsset(account: BankAccount): String =
productCodeToAsset(account.productCode)
fun productCodeToAsset(productCode: String): String = when (productCode) {
"C8201", "C8001", "C8009" -> "cards/bml/master_prepaid.png"
"C8205", "C8005", "C8008" -> "cards/bml/master_prepaid_travel.png"
"C3007", "C3017", "C3097", "C3095", "C3077", "C3177" -> "cards/bml/amex_debit_green.png"
"C3003", "C3013", "C3053", "C3023", "C3033", "C3052" -> "cards/bml/amex_debit_gold.png"
"C3009", "C3019", "C3029", "C3099", "C3088", "C3188" -> "cards/bml/amex_credit_gold.png"
"C3001", "C3011", "C3050", "C3051", "C3031" -> "cards/bml/amex_credit_green.png"
"C3005", "C3015", "C3055", "C3054" -> "cards/bml/amex_platinum.png"
"C1003", "C1013", "C1083", "C1084", "C1103", "C1113", "C1183", "C1184" -> "cards/bml/visa_gold.png"
"C1007", "C1027", "C1097", "C1107", "C1197", "C1077", "C1177" -> "cards/bml/visa_debit.png"
"C1020", "C1021" -> "cards/bml/visa_debit_platinum.png"
"C8020", "C8022" -> "cards/bml/master_gold.png"
"C8902", "C8907", "C8909", "C8912", "C8992", "C8996", "C8997", "C8982", "C8983" -> "cards/bml/master_islamic.png"
"C8101" -> "cards/bml/master_masveriyaa.png"
"C8102" -> "cards/bml/master_odiveriyaa.png"
"C8010", "C8011" -> "cards/bml/master_platinum.png"
"C8040", "C8044" -> "cards/bml/master_world.png"
"C8030", "C8033" -> "cards/bml/master_business_debit.png"
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png"
"C1030", "C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
"C8905", "C8995" -> "cards/bml/visa_credit.png"
"C1001", "C1011", "C1082", "C1081", "C1101", "C1111", "C1181", "C1182" -> "cards/bml/visa_debit_generic.png"
"C1005", "C1006", "C1089" -> "cards/bml/visa_debit_islamic.png"
"C1017" -> "cards/bml/visa_infinite.png"
"C1009", "C1019", "C1085", "C1086", "C1109", "C1119", "C1185", "C1186" -> "cards/bml/visa_platinum.png"
"C1050", "C1051", "C1087", "C1088", "C1150", "C1151", "C1187", "C1188", "C1040", "C1041", "C1047", "C1048", "C1140", "C1141", "C1147", "C1148" -> "cards/bml/visa_student_black.png"
"C8925", "C8926" -> "cards/bml/visa_student_blue.png"
"C1071", "C1073", "C1061", "C1063", "C1161", "C1163" -> "cards/bml/master.png"
"C1070", "C1072", "C1059", "C1062", "C1159", "C1162" -> "cards/bml/master_prepaid_business.png"
else -> "cards/bml/defaultcard.png"
}
}
@@ -11,7 +11,8 @@ object BmlDashboardParser {
* Handles both BML CASA accounts and BML prepaid/credit cards. * Handles both BML CASA accounts and BML prepaid/credit cards.
*/ */
fun displayData(account: BankAccount): AccountListDisplay? { fun displayData(account: BankAccount): AccountListDisplay? {
if (account.profileType == "BML_LOAN") return null // Loans shown on financing page only if (account.profileType == "BML_LOAN") return null // Loans shown on financing page only
if (account.profileType == "BML_DEBIT") return null // Debit cards shown on card screens only
val isCard = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" val isCard = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
return if (isCard) { return if (isCard) {
val isActive = account.statusDesc.equals("Active", ignoreCase = true) val isActive = account.statusDesc.equals("Active", ignoreCase = true)
@@ -25,11 +26,13 @@ object BmlDashboardParser {
statusLabel = if (isActive) null else account.statusDesc statusLabel = if (isActive) null else account.statusDesc
) )
} else { } else {
val blocked = account.blockedAmount.toDoubleOrNull() ?: 0.0
AccountListDisplay( AccountListDisplay(
name = account.accountBriefName, name = account.accountBriefName,
number = account.accountNumber, number = account.accountNumber,
typeLabel = productLabel(account.accountTypeName), typeLabel = productLabel(account.accountTypeName),
balance = listBalance(account) balance = listBalance(account),
blockedBalance = if (blocked > 0.0) "${account.currencyName} ${account.blockedAmount}" else null
) )
} }
} }
@@ -51,9 +54,9 @@ object BmlDashboardParser {
} }
} }
/** Balance shown in the accounts list — ledger (working) balance for BML CASA. */ /** Balance shown in the accounts list — available balance, consistent with transfer/contact picker. */
fun listBalance(account: BankAccount): String = fun listBalance(account: BankAccount): String =
"${account.currencyName} ${account.currentBalance}" "${account.currencyName} ${account.availableBalance}"
fun cardBrandIcon(productName: String): Int = when { fun cardBrandIcon(productName: String): Int = when {
productName.contains("AMEX", ignoreCase = true) || productName.contains("AMEX", ignoreCase = true) ||
@@ -5,12 +5,16 @@ import sh.sar.basedbank.util.AccountListDisplay
object MibAccountParser { object MibAccountParser {
fun displayData(account: BankAccount) = AccountListDisplay( fun displayData(account: BankAccount): AccountListDisplay {
name = account.accountBriefName, val blocked = account.blockedAmount.toDoubleOrNull() ?: 0.0
number = account.accountNumber, return AccountListDisplay(
typeLabel = productLabel(account.accountTypeName), name = account.accountBriefName,
balance = "${account.currencyName} ${account.availableBalance}" number = account.accountNumber,
) typeLabel = productLabel(account.accountTypeName),
balance = "${account.currencyName} ${account.availableBalance}",
blockedBalance = if (blocked > 0.0) "${account.currencyName} ${account.blockedAmount}" else null
)
}
/** /**
* Returns a display-ready product label for a MIB (Faisanet) account type name. * Returns a display-ready product label for a MIB (Faisanet) account type name.

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