Compare commits
169 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
e978f11343
|
|||
|
d227d468b1
|
|||
|
d0fb88d15a
|
|||
|
b08d983077
|
|||
|
c7c89184c0
|
|||
|
0e5435f0fe
|
|||
|
3bb44f1c32
|
|||
|
5dc1a5dbc9
|
|||
|
982596f2a8
|
|||
|
140b0069bd
|
|||
|
74ec9c383c
|
|||
|
b4f66342af
|
|||
|
f575941141
|
|||
|
ceaad0e313
|
|||
|
528663a330
|
|||
|
a1abbc9843
|
|||
|
ffee918258
|
|||
|
fc7fa420b2
|
|||
|
5f6ec236bf
|
|||
|
890cf15fd0
|
|||
|
98a003727b
|
|||
|
9ca13d3518
|
|||
|
395e2308a0
|
|||
|
ad7c5a4e5b
|
|||
|
0ba2396c2c
|
|||
|
173c02ab8f
|
|||
|
b37b12996f
|
|||
|
21203b39e7
|
|||
|
0be492ca18
|
|||
|
973576cf6a
|
|||
|
4523aed69e
|
|||
|
f90d83b59e
|
|||
|
a03b1b1682
|
|||
|
bc958e2df6
|
|||
|
ae8ad24d13
|
|||
|
a20f2a9ce7
|
|||
|
0795df35a1
|
|||
|
86e1e66a20
|
|||
|
a5124096d7
|
|||
|
1d2cd40b3c
|
|||
|
abc1a43ad6
|
|||
|
c7718f94b3
|
|||
|
57bc488b98
|
|||
|
7f87c9e13f
|
|||
|
cc15ab1c6c
|
|||
|
0efe833e40
|
|||
|
f5f52829c7
|
|||
|
3db077cf9a
|
|||
|
ee5ecdaa18
|
|||
|
2df162c09e
|
|||
|
0f77216d2d
|
|||
|
71e893faf8
|
|||
|
1cd254c134
|
|||
|
87536a339b
|
|||
|
32d23a43b3
|
|||
|
846ce22245
|
|||
|
ed5b456e3b
|
|||
|
9b284cc8d4
|
|||
|
c0b58061c2
|
|||
|
978da26ff1
|
|||
|
7fe2ba5788
|
|||
|
26a0c7b81d
|
|||
|
83fc340e2b
|
|||
|
bfbb649b33
|
|||
|
b780091bb8
|
|||
|
e4468c4a8f
|
|||
|
b4e1f57347
|
|||
|
907757c893
|
|||
|
1ea0355ce6
|
|||
|
c9b8973b65
|
|||
|
7a0e32f4d6
|
|||
|
d68b8aaf0a
|
|||
|
396f778ad4
|
|||
|
dc0f1b96c1
|
|||
|
640dd5de22
|
|||
|
f0a0e7857c
|
|||
|
836f4c493a
|
|||
|
6325f4fd7a
|
|||
|
69aa172eff
|
|||
|
ed2054fb81
|
|||
|
e9583f0580
|
|||
|
a32841a319
|
|||
|
7a66dd836c
|
|||
|
68dd49b90c
|
|||
|
76090525e1
|
|||
|
f7fd06cdf3
|
|||
|
8d09e760a8
|
|||
|
62ccae602d
|
|||
|
9011ef2f5a
|
|||
|
dd620763ec
|
|||
|
86063d600f
|
|||
|
da85a31bc6
|
|||
|
d292e73fd9
|
|||
|
3d632606a0
|
|||
|
6daeb5f72e
|
|||
|
c4d3c1efd4
|
|||
|
0560c53ae3
|
|||
|
a37454de00
|
|||
|
daf9b0475a
|
|||
|
c4ad35e6b9
|
|||
|
3e8ea90701
|
|||
|
ef919aa179
|
|||
|
c98a3e3e89
|
|||
|
0654c711d6
|
|||
|
b67368c94a
|
|||
|
a6e7e61b58
|
|||
|
e974a95708
|
|||
|
de11fbe0d3
|
|||
|
5d8ab76477
|
|||
|
d637877167
|
|||
|
ea227bf3b9
|
|||
|
6b3131069e
|
|||
|
8037ce3f02
|
|||
|
cecf0bedfc
|
|||
|
256f216da4
|
|||
|
0a27de4a34
|
|||
|
a3f8852163
|
|||
|
8e345746ed
|
|||
|
473e051282
|
|||
|
f9c182fe9a
|
|||
|
339dae8a37
|
|||
|
a6a1f28144
|
|||
|
523d1248bd
|
|||
|
ee9f98b720
|
|||
|
219ca9bf00
|
|||
|
e9f0cec698
|
|||
|
268f3dada0
|
|||
|
e0a554c769
|
|||
|
94b280a177
|
|||
|
88c9f153e5
|
|||
|
eb7da01b2e
|
|||
|
27270f1b7a
|
|||
|
fd7fcb41a6
|
|||
|
c9ae614fc7
|
|||
|
b784085605
|
|||
|
01e5c17284
|
|||
|
6d3c7036b5
|
|||
|
804712d22d
|
|||
|
f208ee6ad1
|
|||
|
51dbed94d4
|
|||
|
0b5a452046
|
|||
|
00297da71e
|
|||
|
1602d061c1
|
|||
|
ddd64e8624
|
|||
|
77f367844d
|
|||
|
e2729b1d1a
|
|||
|
105518e147
|
|||
|
38570615dd
|
|||
|
e82218e897
|
|||
|
50150b826f
|
|||
|
2d705457f8
|
|||
|
f03e23062b
|
|||
|
58f1b9fd6f
|
|||
|
240d04ad74
|
|||
|
fe507073b1
|
|||
|
6d48c27391
|
|||
|
e894f81887
|
|||
|
acc1278b34
|
|||
|
bc678d26ad
|
|||
|
bb2a80a5e3
|
|||
|
b107358266
|
|||
|
02a53c8219
|
|||
|
15a02cac1c
|
|||
|
35a1748055
|
|||
|
28682bba41
|
|||
|
25484addfb
|
|||
|
728c7d2aa3
|
|||
|
b24949c117
|
|||
|
28e5878668
|
@@ -1,11 +1,9 @@
|
||||
services:
|
||||
release:
|
||||
# image: git.shihaam.dev/dockerfiles/android-builder
|
||||
image: git.shihaam.dev/dockerfiles/runners/gradle
|
||||
hostname: isodroid
|
||||
network_mode: host
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./release:/release
|
||||
- ../../:/source
|
||||
# - /root/.cache/cache-runners/gradle:/root/.gradle
|
||||
- /root/.cache/cache-runners/gradle:/root/.gradle
|
||||
|
||||
@@ -87,3 +87,24 @@ jobs:
|
||||
--data-binary "@${ASSET_PATH}"
|
||||
|
||||
echo "Uploaded asset: $ASSET_NAME"
|
||||
|
||||
- name: Send APK to Telegram
|
||||
env:
|
||||
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
||||
TG_CHAT_ID: ${{ vars.TG_CHAT_ID }}
|
||||
run: |
|
||||
if [ -z "$TG_BOT_TOKEN" ] || [ -z "$TG_CHAT_ID" ]; then
|
||||
echo "TG_BOT_TOKEN or TG_CHAT_ID not set, skipping Telegram upload."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
APP_NAME="${{ gitea.repository }}"
|
||||
APP_NAME="${APP_NAME##*/}"
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
|
||||
CAPTION="${APP_NAME} ${TAG}"
|
||||
|
||||
curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument" \
|
||||
-F "chat_id=${TG_CHAT_ID}" \
|
||||
-F "document=@${ASSET_PATH}" \
|
||||
-F "caption=${CAPTION}"
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-05-18T20:24:18.550107339Z">
|
||||
<DropdownSelection timestamp="2026-06-03T08:28:30.389803148Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=ce61d84c" />
|
||||
<DeviceId pluginId="Default" identifier="serial=10.0.1.245:5555;connection=d182cf37" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
@@ -15,7 +15,7 @@
|
||||
<targets>
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=ce61d84c" />
|
||||
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=67d022c2" />
|
||||
</handle>
|
||||
</Target>
|
||||
<Target type="DEFAULT_BOOT">
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
|
||||
[](https://sladge.net)
|
||||
[](LICENSE)
|
||||
@@ -8,65 +8,29 @@ A unified Android banking app for Maldivians that combines MIB (Faisanet), BML (
|
||||

|
||||

|
||||
|
||||
## What it does
|
||||
|
||||
- **Multi-bank dashboard** — view balances across all your MIB, BML, and Fahipay accounts in one place, with a combined MVR and USD total
|
||||
- **Transaction history** — paginated, searchable transaction history per account for MIB CASA, BML CASA, BML prepaid cards, and Fahipay wallet
|
||||
- **Transfers** — send money between accounts and to saved contacts; supports MIB-to-MIB, BML-to-BML, and cross-bank (MIB↔BML via FAVARA)
|
||||
- **Contacts** — manage saved beneficiaries across all banks; validates Dhiraagu and Ooredoo numbers and shows the account owner name before you add
|
||||
- **Fahipay** — full wallet support including balance, history with merchant icons, and Fahipay favourites (Raastas, Reload, Ooredoo Bill, Dhiraagu Bill)
|
||||
- **QR payments** — scan PayMV QR codes to pre-fill transfers
|
||||
- **BML foreign limits** — view your foreign currency spending allowances and breakdowns by ATM / POS / ECOM
|
||||
- **MIB financing** — view active financing deals
|
||||
|
||||
## Authentication
|
||||
|
||||
The app requires your existing credentials for each bank — the same username/password/OTP seed you use with the official apps. It stores them encrypted using AES-256-GCM backed by the Android Keystore (hardware secure enclave).
|
||||
|
||||
Each bank's 2FA uses TOTP, so you need to have your OTP seed (the same secret used by your authenticator app).
|
||||
|
||||
## Security
|
||||
|
||||
- All credentials encrypted at rest with **AES-256-GCM** (Android Keystore)
|
||||
- Lock screen protected by **PBKDF2-HMAC-SHA256** (100,000 iterations) with optional biometric unlock
|
||||
- **FLAG_SECURE** on by default — content hidden in app switcher and screenshots blocked
|
||||
- All sensitive data excluded from Android cloud backup
|
||||
- Zero analytics, crash reporters, or third-party SDKs — network traffic goes only to MIB, BML, Fahipay, and the Maldivian telecoms for number validation
|
||||
|
||||
See [`docs/AI_SECURITY_CHECK.md`](docs/AI_SECURITY_CHECK.md) for the full security audit.
|
||||
|
||||
## Supported banks
|
||||
|
||||
| Bank | Login | Accounts | History | Transfers | Contacts |
|
||||
|---|---|---|---|---|---|
|
||||
| MIB (Faisanet) | username + password + TOTP | ✓ | ✓ | ✓ | ✓ |
|
||||
| BML (Bank of Maldives) | username + password + TOTP | ✓ | ✓ | ✓ | ✓ |
|
||||
| Fahipay | national ID + password + TOTP | ✓ | ✓ | — | ✓ (favourites) |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Android 8.0+ (API 26)
|
||||
- Existing accounts with MIB, BML, or Fahipay
|
||||
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
|
||||
|
||||
## Building
|
||||
|
||||
Open in Android Studio and run. No API keys or secrets required — all protocol constants are derived from the official apps and are included in the source.
|
||||
|
||||
The release signing config reads from environment variables (`KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`).
|
||||
|
||||
## How it works
|
||||
|
||||
BasedBank talks directly to each bank's existing mobile API using the same protocol as their official apps, reverse-engineered from the APKs. It does not use any intermediary server — requests go straight from your device to the bank.
|
||||
|
||||
- **MIB**: Blowfish/ECB encrypted JSON over HTTPS with a Diffie-Hellman session key exchange
|
||||
- **BML**: PKCE OAuth 2.0 flow via the BML web login, exchanged for a Bearer token used on the mobile API
|
||||
- **Fahipay**: multipart form login with TOTP, session maintained via `__Secure-sess` cookie and `authid` header
|
||||
## Download APK
|
||||
[Gitea Releases](https://git.shihaam.dev/shihaam/thijooree/releases)
|
||||
[Telegram Channel](https://t.me/s/thijooreeapks)
|
||||
|
||||
## Privacy
|
||||
|
||||
No data ever leaves your device except the API calls to the banking services themselves. See the [security audit](docs/AI_SECURITY_CHECK.md) for a full list of every server the app connects to.
|
||||
No data ever leaves your device except the API calls to the banking services themselves. See the [security audit](docs/thijooree/AI_SECURITY_CHECK.md) for a full list of every server the app connects to.
|
||||
|
||||
## Documentation
|
||||
|
||||
API reverse-engineering notes and app internals are in [`docs/`](docs/README.md).
|
||||
|
||||
## Disclaimer
|
||||
|
||||
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
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "sh.sar.basedbank"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 3
|
||||
versionName = "1.0.4"
|
||||
versionCode = 15
|
||||
versionName = "1.0.16"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -27,6 +27,10 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
}
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
isMinifyEnabled = false
|
||||
@@ -73,6 +77,9 @@ dependencies {
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
|
||||
// ZXing core for QR code generation
|
||||
implementation("com.google.zxing:core:3.5.3")
|
||||
|
||||
// QR scanning — CameraX + zxing-cpp (MIT, same stack as BinaryEye)
|
||||
implementation("androidx.camera:camera-core:1.4.2")
|
||||
implementation("androidx.camera:camera-camera2:1.4.2")
|
||||
@@ -84,6 +91,9 @@ dependencies {
|
||||
// Biometric authentication
|
||||
implementation("androidx.biometric:biometric:1.1.0")
|
||||
|
||||
// Encrypted SharedPreferences (HCE token store)
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_logo_background">#CC0000</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Thijooree Debug</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<shortcut
|
||||
android:shortcutId="transfer"
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_shortcut_transfer"
|
||||
android:shortcutShortLabel="@string/transfer"
|
||||
android:shortcutLongLabel="@string/transfer">
|
||||
<intent
|
||||
android:action="sh.sar.basedbank.OPEN_TRANSFER"
|
||||
android:targetPackage="sh.sar.basedbank.debug"
|
||||
android:targetClass="sh.sar.basedbank.MainActivity" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
|
||||
<shortcut
|
||||
android:shortcutId="scan_qr"
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_shortcut_scan_qr"
|
||||
android:shortcutShortLabel="@string/transfer_scan_qr"
|
||||
android:shortcutLongLabel="@string/transfer_scan_qr">
|
||||
<intent
|
||||
android:action="sh.sar.basedbank.OPEN_SCAN_QR"
|
||||
android:targetPackage="sh.sar.basedbank.debug"
|
||||
android:targetClass="sh.sar.basedbank.MainActivity" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
|
||||
<shortcut
|
||||
android:shortcutId="tap_to_pay"
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_shortcut_pay_card"
|
||||
android:shortcutShortLabel="@string/card_pay_nfc"
|
||||
android:shortcutLongLabel="@string/card_pay_nfc">
|
||||
<intent
|
||||
android:action="sh.sar.basedbank.TAP_TO_PAY"
|
||||
android:targetPackage="sh.sar.basedbank.debug"
|
||||
android:targetClass="sh.sar.basedbank.MainActivity" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
|
||||
</shortcuts>
|
||||
@@ -7,10 +7,14 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
|
||||
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".BasedBankApp"
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -30,6 +34,9 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
@@ -56,6 +63,39 @@
|
||||
android:exported="false"
|
||||
android:screenOrientation="portrait" />
|
||||
|
||||
<activity
|
||||
android:name=".nfc.BmlTapToPayActivity"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/Theme.BasedBank" />
|
||||
|
||||
<service
|
||||
android:name=".nfc.BmlHostCardEmulatorService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_NFC_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.nfc.cardemulation.host_apdu_service"
|
||||
android:resource="@xml/bml_aid_list" />
|
||||
</service>
|
||||
|
||||
<!-- Share-sheet alias: "Scan to Pay" receives shared images and decodes their QR code -->
|
||||
<activity-alias
|
||||
android:name=".ScanToPayActivity"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/transfer_scan_qr"
|
||||
android:icon="@drawable/ic_qr_scan">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 196 KiB |
@@ -0,0 +1 @@
|
||||
visa_bingaa.png
|
||||
@@ -0,0 +1 @@
|
||||
visa_bingaa.png
|
||||
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -4,9 +4,11 @@ import android.app.Application
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.bml.BmlProfile
|
||||
import sh.sar.basedbank.api.bml.BmlSession
|
||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibProfile
|
||||
import sh.sar.basedbank.api.mib.MibSession
|
||||
@@ -14,23 +16,42 @@ import sh.sar.basedbank.util.CredentialStore
|
||||
|
||||
class BasedBankApp : Application() {
|
||||
|
||||
/**
|
||||
* Set to true only after the user passes LockActivity or completes fresh login.
|
||||
* Resets to false on every process restart so direct ADB/root activity launches
|
||||
* cannot reach HomeActivity without re-authenticating.
|
||||
*/
|
||||
var isUnlocked = false
|
||||
|
||||
// Held in memory after successful login; cleared on logout
|
||||
var accounts: List<MibAccount> = emptyList()
|
||||
var accounts: List<BankAccount> = emptyList()
|
||||
var fullName: String = ""
|
||||
|
||||
/** Active MIB sessions keyed by loginId (= MIB username). */
|
||||
val mibSessions: MutableMap<String, MibSession> = mutableMapOf()
|
||||
val mibProfilesMap: MutableMap<String, List<MibProfile>> = mutableMapOf()
|
||||
val mibLoginFlows: MutableMap<String, MibLoginFlow> = mutableMapOf()
|
||||
var mibAccounts: List<MibAccount> = emptyList()
|
||||
/** Active BML sessions keyed by loginId (= BML username). */
|
||||
var mibAccounts: List<BankAccount> = emptyList()
|
||||
|
||||
/**
|
||||
* Active BML sessions keyed by profileId (a globally unique GUID per BML profile).
|
||||
* Use [bmlSessionFor] to look up the session for an account.
|
||||
*/
|
||||
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
|
||||
var bmlAccounts: List<MibAccount> = emptyList()
|
||||
/** BML profiles per loginId (= BML username). */
|
||||
val bmlProfilesMap: MutableMap<String, List<BmlProfile>> = mutableMapOf()
|
||||
/** BML login flows per loginId — hold the web session (cookies) needed for profile activation. */
|
||||
val bmlLoginFlows: MutableMap<String, BmlLoginFlow> = mutableMapOf()
|
||||
var bmlAccounts: List<BankAccount> = emptyList()
|
||||
|
||||
/** Active Fahipay sessions keyed by loginId (= profileId). */
|
||||
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
|
||||
var fahipayAccounts: List<MibAccount> = emptyList()
|
||||
var fahipayAccounts: List<BankAccount> = emptyList()
|
||||
|
||||
// ─── MIB helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the MIB session for the given account (matched via loginTag). */
|
||||
fun mibSessionFor(account: MibAccount): MibSession? =
|
||||
fun mibSessionFor(account: BankAccount): MibSession? =
|
||||
mibSessions[account.loginTag.removePrefix("mib_")]
|
||||
|
||||
/** Returns any available MIB session. */
|
||||
@@ -53,15 +74,40 @@ class BasedBankApp : Application() {
|
||||
/** Returns any available MibLoginFlow. */
|
||||
fun anyMibFlow(): MibLoginFlow? = mibLoginFlows.values.firstOrNull()
|
||||
|
||||
/** Returns the BML session for the given account (matched via loginTag). */
|
||||
fun bmlSessionFor(account: MibAccount): BmlSession? =
|
||||
bmlSessions[account.loginTag.removePrefix("bml_")]
|
||||
// ─── BML helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the BML session for the given account.
|
||||
* Looks up by profileId first (multi-profile), falls back to loginId (legacy single-profile).
|
||||
*/
|
||||
fun bmlSessionFor(account: BankAccount): BmlSession? {
|
||||
val byProfile = if (account.profileId.isNotBlank()) bmlSessions[account.profileId] else null
|
||||
return byProfile ?: bmlSessions[account.loginTag.removePrefix("bml_")]
|
||||
}
|
||||
|
||||
/** Returns any available BML session (for non-account-specific operations). */
|
||||
fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull()
|
||||
|
||||
/**
|
||||
* Returns any active BML session for the given loginId.
|
||||
* Tries all profiles for that login; falls back to legacy loginId key.
|
||||
*/
|
||||
fun anyBmlSessionFor(loginId: String): BmlSession? {
|
||||
val profiles = bmlProfilesMap[loginId]
|
||||
if (!profiles.isNullOrEmpty()) {
|
||||
return profiles.firstNotNullOfOrNull { bmlSessions[it.profileId] }
|
||||
}
|
||||
return bmlSessions[loginId]
|
||||
}
|
||||
|
||||
/** Returns the BmlLoginFlow for a given loginId, creating and caching it if needed. */
|
||||
fun bmlFlowFor(loginId: String): BmlLoginFlow =
|
||||
bmlLoginFlows.getOrPut(loginId) { BmlLoginFlow() }
|
||||
|
||||
// ─── Fahipay helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the Fahipay session for the given account (matched via loginTag = "fahipay_${profileId}"). */
|
||||
fun fahipaySessionFor(account: MibAccount): FahipaySession? =
|
||||
fun fahipaySessionFor(account: BankAccount): FahipaySession? =
|
||||
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
|
||||
|
||||
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
|
||||
@@ -69,7 +115,11 @@ class BasedBankApp : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||
// Only apply wallpaper-based dynamic colors in system theme mode.
|
||||
// Light/dark modes use content-based accent colors applied per-activity via ThemeHelper.
|
||||
DynamicColors.applyToActivitiesIfAvailable(this) { _, _ ->
|
||||
getSharedPreferences("prefs", MODE_PRIVATE).getString("theme", "system") == "system"
|
||||
}
|
||||
|
||||
val theme = getSharedPreferences("prefs", MODE_PRIVATE).getString("theme", "system")
|
||||
AppCompatDelegate.setDefaultNightMode(when (theme) {
|
||||
|
||||
@@ -21,6 +21,8 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.databinding.ActivityLockBinding
|
||||
import sh.sar.basedbank.ui.home.HomeActivity
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.ThemeHelper
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
|
||||
@@ -32,6 +34,8 @@ class LockActivity : AppCompatActivity() {
|
||||
private lateinit var salt: String
|
||||
private lateinit var storedHash: String
|
||||
private var biometricsEnabled = false
|
||||
private var autoUnlockPin = false
|
||||
private var pinLength = 4
|
||||
private var isVerifying = false
|
||||
|
||||
private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE)
|
||||
@@ -43,6 +47,7 @@ class LockActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
ThemeHelper.applyAccent(this)
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
binding = ActivityLockBinding.inflate(layoutInflater)
|
||||
@@ -52,6 +57,9 @@ class LockActivity : AppCompatActivity() {
|
||||
isAppearanceLightStatusBars = isLight
|
||||
isAppearanceLightNavigationBars = isLight
|
||||
}
|
||||
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
|
||||
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
|
||||
ta.recycle()
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets ->
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
|
||||
@@ -61,6 +69,8 @@ class LockActivity : AppCompatActivity() {
|
||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
method = prefs.getString("security_method", "pin") ?: "pin"
|
||||
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
|
||||
autoUnlockPin = prefs.getBoolean("auto_unlock_pin", false)
|
||||
pinLength = prefs.getInt("pin_length", 4)
|
||||
|
||||
val stored = CredentialStore(this).loadSecurityHash() ?: run { finish(); return }
|
||||
salt = stored.first
|
||||
@@ -114,8 +124,17 @@ class LockActivity : AppCompatActivity() {
|
||||
else
|
||||
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
||||
val btn = MaterialButton(this, null, style).apply {
|
||||
text = key
|
||||
textSize = 24f
|
||||
if (key == "⌫" || key == "✓") {
|
||||
text = ""
|
||||
icon = ContextCompat.getDrawable(this@LockActivity,
|
||||
if (key == "⌫") R.drawable.ic_backspace else R.drawable.ic_check)
|
||||
iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START
|
||||
iconPadding = 0
|
||||
iconSize = (28 * dp).toInt()
|
||||
} else {
|
||||
text = key
|
||||
textSize = 24f
|
||||
}
|
||||
insetTop = 0; insetBottom = 0
|
||||
minimumWidth = 0; minimumHeight = 0
|
||||
cornerRadius = btnSize / 2
|
||||
@@ -134,13 +153,18 @@ class LockActivity : AppCompatActivity() {
|
||||
when (key) {
|
||||
"⌫" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() }
|
||||
"✓" -> if (pinDigits.size >= 4) verifyPin()
|
||||
else -> if (pinDigits.size < 8) { pinDigits.add(key.toInt()); updateDots() }
|
||||
else -> if (pinDigits.size < 8) {
|
||||
pinDigits.add(key.toInt())
|
||||
updateDots()
|
||||
if (autoUnlockPin && pinDigits.size == pinLength) verifyPin()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDots() {
|
||||
val n = pinDigits.size
|
||||
binding.tvLockPinDots.text = "●".repeat(n) + "○".repeat(maxOf(4 - n, 0))
|
||||
val total = if (autoUnlockPin) pinLength else maxOf(n, 4)
|
||||
binding.tvLockPinDots.text = "●".repeat(n) + "○".repeat(maxOf(total - n, 0))
|
||||
}
|
||||
|
||||
private fun verifyPin() {
|
||||
@@ -194,15 +218,15 @@ class LockActivity : AppCompatActivity() {
|
||||
if (remaining <= 0) return false
|
||||
val secs = ((remaining + 999L) / 1000L).toInt()
|
||||
val msg = getString(R.string.unlock_locked_out, secs)
|
||||
binding.tvLockPinDots.text = msg
|
||||
binding.root.postDelayed({ updateDots() }, remaining)
|
||||
binding.tvPinHint.text = msg
|
||||
binding.root.postDelayed({ binding.tvPinHint.text = ""; updateDots() }, remaining)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun showFailure() {
|
||||
val msg = failureMessage()
|
||||
binding.tvLockPinDots.text = msg
|
||||
binding.root.postDelayed({ updateDots() }, 1200)
|
||||
binding.tvPinHint.text = msg
|
||||
binding.root.postDelayed({ binding.tvPinHint.text = ""; updateDots() }, 1200)
|
||||
}
|
||||
|
||||
private fun failureMessage(): String {
|
||||
@@ -250,10 +274,27 @@ class LockActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun proceed() {
|
||||
(application as BasedBankApp).isUnlocked = true
|
||||
if (intent.getBooleanExtra(EXTRA_RESUME, false)) {
|
||||
finish()
|
||||
} else {
|
||||
startActivity(Intent(this, HomeActivity::class.java))
|
||||
val store = CredentialStore(this)
|
||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
||||
if (!hasCredentials) {
|
||||
startActivity(Intent(this, sh.sar.basedbank.ui.login.LoginActivity::class.java))
|
||||
finish()
|
||||
return
|
||||
}
|
||||
val navDest = intent.getIntExtra("nav_destination", -1)
|
||||
val autoScan = intent.getBooleanExtra("auto_scan", false)
|
||||
val autoTapMode = intent.getBooleanExtra("auto_tap_mode", false)
|
||||
val shareQrText = intent.getStringExtra("share_qr_text")
|
||||
startActivity(Intent(this, HomeActivity::class.java).apply {
|
||||
if (navDest != -1) putExtra("nav_destination", navDest)
|
||||
if (autoScan) putExtra("auto_scan", true)
|
||||
if (autoTapMode) putExtra("auto_tap_mode", true)
|
||||
if (shareQrText != null) putExtra("share_qr_text", shareQrText)
|
||||
})
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,24 +7,80 @@ import sh.sar.basedbank.ui.home.HomeActivity
|
||||
import sh.sar.basedbank.ui.login.LoginActivity
|
||||
import sh.sar.basedbank.ui.onboarding.OnboardingActivity
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private fun decodeQrFromSharedImage(uri: android.net.Uri): String? {
|
||||
return try {
|
||||
val bitmap = contentResolver.openInputStream(uri)?.use {
|
||||
android.graphics.BitmapFactory.decodeStream(it)
|
||||
} ?: return null
|
||||
val opts = de.markusfisch.android.zxingcpp.ZxingCpp.ReaderOptions(
|
||||
tryHarder = true, tryRotate = true, tryInvert = true,
|
||||
tryDownscale = true, maxNumberOfSymbols = 1,
|
||||
textMode = de.markusfisch.android.zxingcpp.ZxingCpp.TextMode.PLAIN
|
||||
)
|
||||
val result = (de.markusfisch.android.zxingcpp.ZxingCpp.readBitmap(
|
||||
bitmap, 0, 0, bitmap.width, bitmap.height, 0,
|
||||
opts.apply { binarizer = de.markusfisch.android.zxingcpp.ZxingCpp.Binarizer.LOCAL_AVERAGE }
|
||||
) ?: de.markusfisch.android.zxingcpp.ZxingCpp.readBitmap(
|
||||
bitmap, 0, 0, bitmap.width, bitmap.height, 0,
|
||||
opts.apply { binarizer = de.markusfisch.android.zxingcpp.ZxingCpp.Binarizer.GLOBAL_HISTOGRAM }
|
||||
))?.firstOrNull()?.text
|
||||
bitmap.recycle()
|
||||
result
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
val onboardingDone = prefs.getBoolean("onboarding_done", false)
|
||||
val securitySet = prefs.getString("security_method", null) != null
|
||||
val store = CredentialStore(this)
|
||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
||||
|
||||
// Image shared via "Scan to Pay" — decode QR here while we still hold the URI permission
|
||||
val shareQrText: String? = if (intent?.action == Intent.ACTION_SEND &&
|
||||
intent.type?.startsWith("image/") == true) {
|
||||
val uri: android.net.Uri? =
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU)
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM, android.net.Uri::class.java)
|
||||
else
|
||||
@Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_STREAM)
|
||||
if (uri != null) decodeQrFromSharedImage(uri) else null
|
||||
} else null
|
||||
|
||||
val navDestination = when (intent?.action) {
|
||||
"sh.sar.basedbank.OPEN_TRANSFER" -> R.id.nav_transfer
|
||||
"sh.sar.basedbank.OPEN_SCAN_QR" -> R.id.nav_transfer
|
||||
"sh.sar.basedbank.OPEN_PAY_WITH_CARD" -> R.id.nav_pay_with_card
|
||||
"sh.sar.basedbank.TAP_TO_PAY" -> R.id.nav_pay_with_card
|
||||
else -> -1
|
||||
}
|
||||
val autoScan = intent?.action == "sh.sar.basedbank.OPEN_SCAN_QR"
|
||||
val autoTapMode = intent?.action == "sh.sar.basedbank.TAP_TO_PAY"
|
||||
|
||||
val target = when {
|
||||
!onboardingDone -> OnboardingActivity::class.java
|
||||
!hasCredentials -> LoginActivity::class.java
|
||||
securitySet -> LockActivity::class.java // proceed() → HomeActivity
|
||||
else -> HomeActivity::class.java
|
||||
}
|
||||
startActivity(Intent(this, target))
|
||||
// No lock screen configured — mark as unlocked so HomeActivity's guard passes
|
||||
if (target == HomeActivity::class.java) {
|
||||
(application as BasedBankApp).isUnlocked = true
|
||||
}
|
||||
|
||||
startActivity(Intent(this, target).apply {
|
||||
if (navDestination != -1) putExtra("nav_destination", navDestination)
|
||||
if (autoScan) putExtra("auto_scan", true)
|
||||
if (autoTapMode) putExtra("auto_tap_mode", true)
|
||||
if (shareQrText != null) putExtra("share_qr_text", shareQrText)
|
||||
})
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
|
||||
data class BmlUserInfo(
|
||||
val fullName: String,
|
||||
val email: String,
|
||||
val mobile: String,
|
||||
val customerId: String,
|
||||
val idCard: String,
|
||||
val birthdate: String
|
||||
)
|
||||
|
||||
class BmlAccountClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
fun fetchAccounts(
|
||||
session: BmlSession,
|
||||
loginTag: String,
|
||||
profileName: String = "Personal",
|
||||
profileId: String = ""
|
||||
): List<BankAccount> {
|
||||
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/dashboard")).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
if (code in 500..599) throw BankServerException("BML")
|
||||
return parseDashboard(json ?: return emptyList(), loginTag, profileName, profileId)
|
||||
}
|
||||
|
||||
/** Lightweight call to verify the session is alive. Throws [AuthExpiredException] on 401/419. */
|
||||
fun checkProfile(session: BmlSession) {
|
||||
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/profile")).execute()
|
||||
val code = resp.code
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
if (code in 500..599) throw BankServerException("BML")
|
||||
}
|
||||
|
||||
fun fetchUserInfo(session: BmlSession): BmlUserInfo? {
|
||||
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/userinfo")).execute()
|
||||
val json = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return null
|
||||
val user = root.optJSONObject("payload")?.optJSONObject("user") ?: return null
|
||||
BmlUserInfo(
|
||||
fullName = user.optString("fullname").trim(),
|
||||
email = user.optString("email").trim(),
|
||||
mobile = user.optString("mobile_phone").trim(),
|
||||
customerId = user.optString("customer_number").trim(),
|
||||
idCard = user.optString("idcard").trim(),
|
||||
birthdate = user.optString("birthdate").trim()
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun fetchLoanDetail(session: BmlSession, internalId: String): BmlLoanDetail? {
|
||||
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/account/$internalId")).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return null
|
||||
val p = root.optJSONObject("payload") ?: return null
|
||||
BmlLoanDetail(
|
||||
loanAmount = p.optDouble("loanAmount", 0.0),
|
||||
outstandingAmt = p.optDouble("outstandingAmt", 0.0),
|
||||
repayAmount = p.optDouble("repayAmount", 0.0),
|
||||
intRate = p.optDouble("intRate", 0.0),
|
||||
loanStatus = p.optString("loanStatus"),
|
||||
startDate = p.optString("startDate"),
|
||||
endDate = p.optString("endDate"),
|
||||
noOfRepayOverdue = p.optInt("noOfRepayOverdue", 0),
|
||||
overdueAmount = p.optDouble("overdueAmount", 0.0)
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun fetchTransferChannels(session: BmlSession): List<BmlOtpChannel> {
|
||||
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/transfer")).execute()
|
||||
val json = resp.body?.string() ?: run { resp.close(); return emptyList() }
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val arr = root.optJSONObject("payload")
|
||||
?.optJSONObject("transfer")
|
||||
?.optJSONArray("otpChannel") ?: return emptyList()
|
||||
(0 until arr.length()).map { i ->
|
||||
val ch = arr.getJSONObject(i)
|
||||
BmlOtpChannel(
|
||||
channel = ch.optString("channel"),
|
||||
description = ch.optString("description"),
|
||||
masked = ch.optString("masked")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
private fun parseDashboard(
|
||||
json: String,
|
||||
loginTag: String,
|
||||
profileName: String,
|
||||
profileId: String
|
||||
): List<BankAccount> {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
|
||||
|
||||
val casaAccounts = mutableListOf<BankAccount>()
|
||||
val prepaidCards = mutableListOf<BankAccount>()
|
||||
val loanAccounts = mutableListOf<BankAccount>()
|
||||
|
||||
for (i in 0 until dashboard.length()) {
|
||||
val item = dashboard.getJSONObject(i)
|
||||
val currency = item.optString("currency", "MVR")
|
||||
val accountType = item.optString("account_type", "CASA")
|
||||
val product = item.optString("product")
|
||||
val accountNumber = item.optString("account")
|
||||
val status = item.optString("account_status", "Active")
|
||||
val internalId = item.optString("id", "")
|
||||
|
||||
if (accountType == "CASA") {
|
||||
val available = item.optDouble("availableBalance", 0.0)
|
||||
casaAccounts.add(BankAccount(
|
||||
bank = "BML",
|
||||
profileName = profileName,
|
||||
profileType = "BML",
|
||||
accountNumber = accountNumber,
|
||||
accountBriefName = item.optString("alias"),
|
||||
currencyName = currency,
|
||||
accountTypeName = product,
|
||||
availableBalance = "%.2f".format(available),
|
||||
currentBalance = "%.2f".format(item.optDouble("ledgerBalance", 0.0)),
|
||||
blockedAmount = "%.2f".format(item.optDouble("lockedAmount", 0.0)),
|
||||
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
||||
statusDesc = status,
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
profileId = profileId,
|
||||
internalId = internalId
|
||||
))
|
||||
} else if (accountType == "Loan") {
|
||||
val outstanding = Math.abs(item.optDouble("availableBalance", 0.0))
|
||||
loanAccounts.add(BankAccount(
|
||||
bank = "BML",
|
||||
profileName = profileName,
|
||||
profileType = "BML_LOAN",
|
||||
accountNumber = accountNumber,
|
||||
accountBriefName = item.optString("alias"),
|
||||
currencyName = currency,
|
||||
accountTypeName = product,
|
||||
availableBalance = "%.2f".format(outstanding),
|
||||
currentBalance = "%.2f".format(outstanding),
|
||||
blockedAmount = "0.00",
|
||||
mvrBalance = "0.00",
|
||||
statusDesc = status,
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
profileId = profileId,
|
||||
internalId = internalId
|
||||
))
|
||||
} else if (accountType == "Card") {
|
||||
val isPrepaid = item.optBoolean("prepaid_card", false)
|
||||
val productCode = item.optString("product_code", "")
|
||||
val cardBalance = item.optJSONObject("cardBalance")
|
||||
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
|
||||
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
|
||||
val isVisible = item.optBoolean("account_visible", false)
|
||||
val cardProfileType = when {
|
||||
isPrepaid -> "BML_PREPAID"
|
||||
isVisible -> "BML_CREDIT" // non-prepaid, visible = credit card
|
||||
else -> "BML_DEBIT" // non-prepaid, not visible = debit card
|
||||
}
|
||||
prepaidCards.add(BankAccount(
|
||||
bank = "BML",
|
||||
profileName = profileName,
|
||||
profileType = cardProfileType,
|
||||
productCode = productCode,
|
||||
accountNumber = accountNumber,
|
||||
accountBriefName = item.optString("alias").ifBlank { product },
|
||||
currencyName = currency,
|
||||
accountTypeName = product,
|
||||
availableBalance = "%.2f".format(available),
|
||||
currentBalance = "%.2f".format(current),
|
||||
blockedAmount = "0.00",
|
||||
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
||||
statusDesc = status,
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
profileId = profileId,
|
||||
internalId = internalId
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return casaAccounts + prepaidCards + loanAccounts
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import android.os.Build
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
||||
internal val BML_USER_AGENT = "bml-mobile-banking/348 (${Build.MANUFACTURER}; Android ${Build.VERSION.RELEASE}; ${Build.MODEL})"
|
||||
internal const val BML_APP_VERSION = "2.1.44.348"
|
||||
|
||||
internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
internal fun bmlApiRequest(session: BmlSession, url: String): Request =
|
||||
Request.Builder().url(url)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.build()
|
||||
@@ -0,0 +1,95 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
|
||||
class BmlContactsClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
fun fetchContacts(session: BmlSession, loginId: String): List<BankContact> {
|
||||
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/contacts")).execute()
|
||||
val json = resp.body?.string() ?: return emptyList()
|
||||
resp.close()
|
||||
return parseContacts(json, loginId)
|
||||
}
|
||||
|
||||
fun saveContact(
|
||||
session: BmlSession,
|
||||
contactType: String,
|
||||
account: String,
|
||||
alias: String,
|
||||
currency: String? = null,
|
||||
name: String? = null,
|
||||
swift: String? = null
|
||||
): Boolean {
|
||||
val bodyObj = JSONObject().apply {
|
||||
put("contact_type", contactType)
|
||||
put("account", account)
|
||||
put("alias", alias)
|
||||
if (currency != null) put("currency", currency)
|
||||
if (name != null) put("name", name)
|
||||
if (swift != null) put("swift", swift)
|
||||
}
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/contacts")
|
||||
.post(bodyObj.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return false
|
||||
resp.close()
|
||||
return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
fun deleteContact(session: BmlSession, contactId: String): Boolean {
|
||||
val body = """{"_method":"delete"}""".toRequestBody("application/json".toMediaType())
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/contacts/$contactId")
|
||||
.post(body)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val bodyStr = resp.body?.string() ?: return false
|
||||
resp.close()
|
||||
return try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
private fun parseContacts(json: String, loginId: String): List<BankContact> {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList()
|
||||
val result = mutableListOf<BankContact>()
|
||||
for (i in 0 until payload.length()) {
|
||||
val item = payload.getJSONObject(i)
|
||||
val account = item.optString("account", "")
|
||||
if (account.isBlank()) continue
|
||||
result.add(BankContact(
|
||||
benefNo = "bml_${item.optInt("id")}",
|
||||
benefName = item.optString("name"),
|
||||
benefNickName = item.optString("alias", item.optString("name")),
|
||||
benefAccount = account,
|
||||
benefType = "I",
|
||||
bankColor = "#0066A1",
|
||||
benefBankName = "Bank of Maldives",
|
||||
bankCode = "",
|
||||
benefStatus = item.optString("status", "S"),
|
||||
transferCyDesc = item.optString("currency", "MVR"),
|
||||
customerImgHash = null,
|
||||
benefCategoryId = "BML",
|
||||
profileId = loginId
|
||||
))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class BmlForeignLimitsClient {
|
||||
|
||||
// Foreign limits use a different host than the main BML API
|
||||
private val BASE_URL = "https://app.bankofmaldives.com.mv/api/v2"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/foreign-limits")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
return parseForeignLimits(json ?: return emptyList())
|
||||
}
|
||||
|
||||
private fun parseForeignLimits(json: String): List<BmlForeignLimit> {
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload = root.optJSONArray("payload") ?: return emptyList()
|
||||
(0 until payload.length()).map { i ->
|
||||
val item = payload.getJSONObject(i)
|
||||
val usage = item.optJSONObject("usageByCategory") ?: JSONObject()
|
||||
val atm = usage.optJSONObject("ATM") ?: JSONObject()
|
||||
val ecom = usage.optJSONObject("ECOM") ?: JSONObject()
|
||||
val pos = usage.optJSONObject("POS") ?: JSONObject()
|
||||
BmlForeignLimit(
|
||||
type = item.optString("type", "Debit"),
|
||||
used = item.optDouble("used", 0.0),
|
||||
totalLimit = item.optDouble("totalLimit", 0.0),
|
||||
generalCap = item.optDouble("generalCap", 0.0),
|
||||
generalRemaining = item.optDouble("generalRemaining", 0.0),
|
||||
medicalRemaining = item.optDouble("medicalRemaining", 0.0),
|
||||
isAtmEnabled = item.optBoolean("isAtmEnabled", false),
|
||||
isPosEnabled = item.optBoolean("isPosEnabled", false),
|
||||
atmRemaining = atm.optDouble("remaining", 0.0),
|
||||
atmLimit = atm.optDouble("limit", 0.0),
|
||||
ecomRemaining = ecom.optDouble("remaining", 0.0),
|
||||
ecomLimit = ecom.optDouble("limit", 0.0),
|
||||
posRemaining = pos.optDouble("remaining", 0.0),
|
||||
posLimit = pos.optDouble("limit", 0.0)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class BmlHistoryClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
fun fetchAccountHistory(
|
||||
session: BmlSession,
|
||||
accountId: String,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String,
|
||||
page: Int
|
||||
): Pair<List<BankTransaction>, Int> {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/account/$accountId/history/$page")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
if (code in 500..599) throw BankServerException("BML")
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return Pair(emptyList(), 0)
|
||||
val payload = root.optJSONObject("payload") ?: return Pair(emptyList(), 0)
|
||||
val totalPages = payload.optInt("totalPages", 0)
|
||||
val history = payload.optJSONArray("history") ?: return Pair(emptyList(), totalPages)
|
||||
val transactions = (0 until history.length()).map { i ->
|
||||
val item = history.getJSONObject(i)
|
||||
val desc = item.optString("description").trim()
|
||||
val narrative1 = item.optString("narrative1")
|
||||
val date = when (desc) {
|
||||
"Purchase" -> parsePurchaseNarrative1(narrative1) ?: item.optString("bookingDate")
|
||||
"Transfer Debit", "Transfer Credit" -> parseTransferNarrative1(narrative1) ?: item.optString("bookingDate")
|
||||
else -> item.optString("bookingDate")
|
||||
}
|
||||
BankTransaction(
|
||||
id = item.optString("id"),
|
||||
date = date,
|
||||
description = desc,
|
||||
amount = item.optDouble("amount", 0.0),
|
||||
currency = item.optString("currency"),
|
||||
counterpartyName = item.optString("narrative2").takeIf { it.isNotBlank() },
|
||||
reference = item.optString("reference").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML"
|
||||
)
|
||||
}
|
||||
Pair(transactions, totalPages)
|
||||
} catch (_: Exception) { Pair(emptyList(), 0) }
|
||||
}
|
||||
|
||||
fun fetchCardHistory(
|
||||
session: BmlSession,
|
||||
cardId: String,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String,
|
||||
month: String
|
||||
): List<BankTransaction> {
|
||||
val body = """{"card":"$cardId","month":"$month"}"""
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/card/statement").post(body)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return emptyList()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
if (code in 500..599) throw BankServerException("BML")
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload = root.optJSONObject("payload") ?: return emptyList()
|
||||
val result = mutableListOf<BankTransaction>()
|
||||
|
||||
val authDetails = payload.optJSONObject("outstanding")
|
||||
?.optJSONArray("CardOutStdAuthDetails")
|
||||
if (authDetails != null) {
|
||||
for (i in 0 until authDetails.length()) {
|
||||
val item = authDetails.getJSONObject(i)
|
||||
result.add(BankTransaction(
|
||||
id = "auth_${item.optString("TranApprCode")}_$i",
|
||||
date = item.optString("DateTime"),
|
||||
description = item.optString("TranDesc").trim(),
|
||||
amount = item.optDouble("BillingAmount", 0.0),
|
||||
currency = item.optString("BillingCcy", "MVR"),
|
||||
counterpartyName = null,
|
||||
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
val unbilled = payload.optJSONObject("unbilled")
|
||||
?.optJSONArray("CardUnbillTxnDetails")
|
||||
if (unbilled != null) {
|
||||
for (i in 0 until unbilled.length()) {
|
||||
val item = unbilled.getJSONObject(i)
|
||||
result.add(BankTransaction(
|
||||
id = "unbilled_${item.optString("TranApprCode")}_$i",
|
||||
date = item.optString("DateTime"),
|
||||
description = item.optString("TranDesc").trim(),
|
||||
amount = item.optDouble("BillingAmount", 0.0),
|
||||
currency = item.optString("BillingCcy", "MVR"),
|
||||
counterpartyName = null,
|
||||
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
val statement = payload.optJSONArray("cardstatement")
|
||||
if (statement != null) {
|
||||
for (i in 0 until statement.length()) {
|
||||
val item = statement.getJSONObject(i)
|
||||
result.add(BankTransaction(
|
||||
id = "stmt_${item.optString("TranRef", i.toString())}",
|
||||
date = item.optString("TransDate", item.optString("TranDate", "")),
|
||||
description = item.optString("TranDesc", item.optString("Description", "")).trim(),
|
||||
amount = -item.optDouble("TranAmount", 0.0),
|
||||
currency = item.optString("TranCcy", "MVR"),
|
||||
counterpartyName = null,
|
||||
reference = item.optString("TranRef").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
))
|
||||
}
|
||||
}
|
||||
result
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun fetchPendingHistory(
|
||||
session: BmlSession,
|
||||
accountId: String,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String
|
||||
): List<BankTransaction> {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/history/pending/$accountId")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return emptyList()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
if (code in 500..599) throw BankServerException("BML")
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload = root.optJSONArray("payload") ?: return emptyList()
|
||||
(0 until payload.length()).map { i ->
|
||||
val item = payload.getJSONObject(i)
|
||||
BankTransaction(
|
||||
id = item.optString("LockedID"),
|
||||
date = item.optString("FromDate"),
|
||||
description = "Pending",
|
||||
amount = -item.optDouble("LockedAmount", 0.0),
|
||||
currency = "MVR",
|
||||
counterpartyName = item.optString("Description").trim().takeIf { it.isNotBlank() },
|
||||
reference = null,
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML"
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
|
||||
private fun parsePurchaseNarrative1(narrative1: String): String? {
|
||||
return try {
|
||||
val parts = narrative1.split(" ")
|
||||
if (parts.size < 2) null
|
||||
else {
|
||||
val timePart = parts[1].take(4)
|
||||
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
|
||||
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
|
||||
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
|
||||
private fun parseTransferNarrative1(narrative1: String): String? {
|
||||
return try {
|
||||
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
|
||||
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
}
|
||||
@@ -7,19 +7,15 @@ import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.Totp
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Base64
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AuthExpiredException : Exception("Session expired")
|
||||
@@ -29,9 +25,9 @@ class BmlLoginFlow {
|
||||
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
||||
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
|
||||
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
|
||||
private val APP_USER_AGENT = "bml-mobile-banking/345 (POCO; Android 14; 22101320I)"
|
||||
private val APP_VERSION = "2.1.43.345"
|
||||
private val WEB_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
|
||||
private val APP_USER_AGENT = "bml-mobile-banking/348 (${android.os.Build.MANUFACTURER}; Android ${android.os.Build.VERSION.RELEASE}; ${android.os.Build.MODEL})"
|
||||
private val APP_VERSION = "2.1.44.348"
|
||||
private val WEB_USER_AGENT = "Mozilla/5.0 (Android ${android.os.Build.VERSION.RELEASE}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
|
||||
|
||||
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
||||
private val cookieJar = object : CookieJar {
|
||||
@@ -53,14 +49,27 @@ class BmlLoginFlow {
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val apiClient = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
/** PKCE params — generated once per login and reused across all profile activations. */
|
||||
private var codeVerifier: String = ""
|
||||
private var codeChallenge: String = ""
|
||||
private var deviceId: String = ""
|
||||
|
||||
/** Full login: returns a BmlSession and the account list. */
|
||||
fun login(username: String, password: String, otpSeed: String): Pair<BmlSession, List<MibAccount>> {
|
||||
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session cookies
|
||||
/** Profiles returned by the last successful [login] call. */
|
||||
var lastProfiles: List<BmlProfile> = emptyList()
|
||||
private set
|
||||
|
||||
// ─── Login ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Performs web authentication (login + TOTP) and returns the list of available profiles.
|
||||
* Call [activateProfile] for each profile to obtain an access token + accounts.
|
||||
*/
|
||||
fun login(username: String, password: String, otpSeed: String): List<BmlProfile> {
|
||||
codeVerifier = generateCodeVerifier()
|
||||
codeChallenge = generateCodeChallenge(codeVerifier)
|
||||
deviceId = generateDeviceId()
|
||||
|
||||
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session
|
||||
client.newCall(
|
||||
Request.Builder().url("$BASE_URL/web/login")
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
@@ -82,15 +91,14 @@ class BmlLoginFlow {
|
||||
loginResp.close()
|
||||
if (loginResp.code != 302) throw Exception("Login failed — check your username/password")
|
||||
|
||||
// Step 3: GET 2FA page (refreshes blaze_session)
|
||||
// Step 3: GET 2FA page (refreshes session cookies)
|
||||
client.newCall(
|
||||
Request.Builder().url("$BASE_URL/web/login/2fa")
|
||||
.header("X-XSRF-TOKEN", xsrf)
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
).execute().close()
|
||||
val xsrf2 = xsrfToken() ?: xsrf
|
||||
|
||||
// Step 4: POST OTP
|
||||
// Step 4: POST TOTP
|
||||
val otp = Totp.generate(otpSeed)
|
||||
val twoFaBody = JSONObject().apply {
|
||||
put("code", otp)
|
||||
@@ -104,18 +112,161 @@ class BmlLoginFlow {
|
||||
twoFaResp.close()
|
||||
if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed")
|
||||
|
||||
// Step 5: GET /web/profile (sets blaze_identity cookie for profile selection)
|
||||
client.newCall(
|
||||
// Step 5: GET /web/profile — multi-profile accounts return a 200 with a profile picker;
|
||||
// single-profile accounts skip the picker and redirect straight to /web/redirect with
|
||||
// blaze_identity already set in the response cookies.
|
||||
val profileResp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/web/profile")
|
||||
.header("X-XSRF-TOKEN", xsrf2)
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
).execute()
|
||||
val profileCode = profileResp.code
|
||||
val profileLocation = profileResp.header("Location") ?: ""
|
||||
val profileBody = profileResp.body?.string() ?: ""
|
||||
profileResp.close()
|
||||
|
||||
lastProfiles = if (profileCode == 302) {
|
||||
// Any 302 from GET /web/profile means the server auto-activated the sole profile
|
||||
// and blaze_identity is already set — no profile picker shown.
|
||||
// Use username as a stable temporary profileId (unique per login); it will be
|
||||
// replaced by the real BML customer ID after fetchUserInfo in finishBmlLogin().
|
||||
listOf(BmlProfile(profileId = username, name = "Personal", type = "Profile", profileType = "default", autoActivated = true))
|
||||
} else {
|
||||
parseProfiles(profileBody)
|
||||
}
|
||||
return lastProfiles
|
||||
}
|
||||
|
||||
// ─── Profile activation ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Activates a profile in the current web session and returns the result.
|
||||
*
|
||||
* - Personal profiles (profile_type="default") succeed immediately and return [BmlActivationResult.Success].
|
||||
* - Business profiles (profile_type="business") require SMS/email OTP; returns
|
||||
* [BmlActivationResult.NeedsBusinessOtp] with available channels. Follow up with
|
||||
* [requestBusinessOtp] + [submitBusinessOtp].
|
||||
*/
|
||||
fun activateProfile(profile: BmlProfile, loginTag: String): BmlActivationResult {
|
||||
// Single-profile accounts: server already activated during login() and set blaze_identity.
|
||||
// autoActivated=true is the sentinel for this case — skip the profile GET entirely.
|
||||
if (profile.autoActivated) {
|
||||
val (session, accounts) = doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
|
||||
return BmlActivationResult.Success(session, accounts)
|
||||
}
|
||||
|
||||
val xsrf = xsrfToken()
|
||||
val reqBuilder = Request.Builder()
|
||||
.url("$BASE_URL/web/profile/${profile.profileId}")
|
||||
.header("User-Agent", WEB_USER_AGENT)
|
||||
if (xsrf != null) reqBuilder.header("X-XSRF-TOKEN", xsrf)
|
||||
|
||||
val resp = client.newCall(reqBuilder.build()).execute()
|
||||
val code = resp.code
|
||||
val location = resp.header("Location") ?: ""
|
||||
resp.close()
|
||||
|
||||
return when {
|
||||
code == 409 || (code == 302 && "/web/profile/2fa/business" !in location) -> {
|
||||
// Profile activated — blaze_identity cookie set in response headers.
|
||||
// Any 302 that isn't to the business 2FA page means success.
|
||||
val (session, accounts) = doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
|
||||
BmlActivationResult.Success(session, accounts)
|
||||
}
|
||||
code == 302 && "/web/profile/2fa/business" in location -> {
|
||||
// Business profile: server requires SMS/email OTP
|
||||
val channels = fetchBusinessOtpChannels()
|
||||
BmlActivationResult.NeedsBusinessOtp(channels)
|
||||
}
|
||||
else -> throw Exception("Profile activation failed (HTTP $code)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns available OTP channels for the business 2FA page.
|
||||
* Also refreshes cookies so the subsequent POST has a valid XSRF token.
|
||||
*/
|
||||
private fun fetchBusinessOtpChannels(): List<BmlOtpChannel> {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/web/profile/2fa/business")
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
).execute()
|
||||
val body = resp.body?.string() ?: ""
|
||||
resp.close()
|
||||
return parseBusinessOtpChannels(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an OTP to [channel] for business profile activation.
|
||||
* Must be called before [submitBusinessOtp].
|
||||
*/
|
||||
fun requestBusinessOtp(channel: String) {
|
||||
val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
|
||||
val body = JSONObject().apply {
|
||||
put("code", "")
|
||||
put("channel", channel)
|
||||
}.toString().toRequestBody("application/json".toMediaType())
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body)
|
||||
.header("X-XSRF-TOKEN", xsrf)
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
).execute()
|
||||
val respCode = resp.code
|
||||
resp.close()
|
||||
if (respCode != 302) throw Exception("Failed to request OTP (HTTP $respCode)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the OTP and activates the business profile.
|
||||
* Returns a new [BmlSession] and accounts on success.
|
||||
* @throws Exception if the OTP is invalid (retry is allowed).
|
||||
*/
|
||||
fun submitBusinessOtp(
|
||||
channel: String,
|
||||
code: String,
|
||||
profile: BmlProfile,
|
||||
loginTag: String
|
||||
): Pair<BmlSession, List<BankAccount>> {
|
||||
// Refresh XSRF token before submitting
|
||||
client.newCall(
|
||||
Request.Builder().url("$BASE_URL/web/profile/2fa/business")
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
).execute().close()
|
||||
|
||||
// Step 6: PKCE OAuth authorize → extract auth code
|
||||
val codeVerifier = generateCodeVerifier()
|
||||
val codeChallenge = generateCodeChallenge(codeVerifier)
|
||||
val deviceId = generateDeviceId()
|
||||
val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
|
||||
val body = JSONObject().apply {
|
||||
put("code", code)
|
||||
put("channel", channel)
|
||||
}.toString().toRequestBody("application/json".toMediaType())
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body)
|
||||
.header("X-XSRF-TOKEN", xsrf)
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
).execute()
|
||||
val respCode = resp.code
|
||||
val location = resp.header("Location") ?: ""
|
||||
resp.close()
|
||||
|
||||
return when {
|
||||
respCode == 409 || (respCode == 302 && "/web/redirect" in location) ->
|
||||
doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
|
||||
respCode == 302 ->
|
||||
throw Exception("Invalid OTP — please try again")
|
||||
else ->
|
||||
throw Exception("Business OTP verification failed (HTTP $respCode)")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── OAuth + account fetch ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Completes PKCE OAuth for the currently activated profile (blaze_identity cookie set).
|
||||
* Returns a fresh [BmlSession] and the profile's accounts.
|
||||
*/
|
||||
private fun doOAuthAndFetchAccounts(
|
||||
loginTag: String,
|
||||
profileName: String,
|
||||
profileId: String
|
||||
): Pair<BmlSession, List<BankAccount>> {
|
||||
val authorizeUrl = HttpUrl.Builder()
|
||||
.scheme("https").host("www.bankofmaldives.com.mv")
|
||||
.addPathSegments("internetbanking/oauth/authorize")
|
||||
@@ -135,14 +286,12 @@ class BmlLoginFlow {
|
||||
Request.Builder().url(authorizeUrl)
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
).execute()
|
||||
val location = authorizeResp.header("Location")
|
||||
authorizeResp.close()
|
||||
|
||||
val location = authorizeResp.header("Location")
|
||||
?: throw Exception("OAuth authorize did not redirect")
|
||||
val authCode = Uri.parse(location).getQueryParameter("code")
|
||||
?: throw Exception("No auth code in OAuth redirect")
|
||||
val authCode = location?.let { Uri.parse(it).getQueryParameter("code") }
|
||||
?: throw Exception("OAuth authorize did not return auth code")
|
||||
|
||||
// Step 7: Exchange auth code for access token
|
||||
val tokenBody = FormBody.Builder()
|
||||
.add("Device-ID", deviceId)
|
||||
.add("code", authCode)
|
||||
@@ -164,565 +313,95 @@ class BmlLoginFlow {
|
||||
val tokenObj = JSONObject(tokenJson)
|
||||
val accessToken = tokenObj.optString("access_token")
|
||||
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
|
||||
val refreshToken = tokenObj.optString("refresh_token", "")
|
||||
val expiresIn = tokenObj.optLong("expires_in", 0L)
|
||||
val expiresAt = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000L else 0L
|
||||
|
||||
val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
|
||||
val accounts = fetchAccounts(session, "bml_$username")
|
||||
val session = BmlSession(accessToken = accessToken, deviceId = deviceId, refreshToken = refreshToken, expiresAt = expiresAt)
|
||||
val accounts = BmlAccountClient().fetchAccounts(session, loginTag, profileName, profileId)
|
||||
return Pair(session, accounts)
|
||||
}
|
||||
|
||||
fun fetchAccounts(session: BmlSession, loginTag: String): List<MibAccount> {
|
||||
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
return parseDashboard(json ?: return emptyList(), loginTag)
|
||||
}
|
||||
|
||||
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
|
||||
val resp = apiClient.newCall(
|
||||
Request.Builder().url("https://app.bankofmaldives.com.mv/api/v2/foreign-limits")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
return parseForeignLimits(json ?: return emptyList())
|
||||
}
|
||||
|
||||
data class BmlUserInfo(
|
||||
val fullName: String,
|
||||
val email: String,
|
||||
val mobile: String,
|
||||
val customerId: String,
|
||||
val idCard: String,
|
||||
val birthdate: String
|
||||
)
|
||||
|
||||
fun fetchUserInfo(session: BmlSession): BmlUserInfo? {
|
||||
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/userinfo")).execute()
|
||||
val json = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return null
|
||||
val user = root.optJSONObject("payload")?.optJSONObject("user") ?: return null
|
||||
BmlUserInfo(
|
||||
fullName = user.optString("fullname").trim(),
|
||||
email = user.optString("email").trim(),
|
||||
mobile = user.optString("mobile_phone").trim(),
|
||||
customerId = user.optString("customer_number").trim(),
|
||||
idCard = user.optString("idcard").trim(),
|
||||
birthdate = user.optString("birthdate").trim()
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun validateAccount(session: BmlSession, input: String): BmlAccountValidation? {
|
||||
val resp = apiClient.newCall(
|
||||
Request.Builder().url("$BASE_URL/api/mobile/validate/account/$input")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return null
|
||||
val payload = root.optJSONObject("payload") ?: return null
|
||||
val trnType = payload.optString("trnType", "")
|
||||
val validationType = payload.optString("validationType", "")
|
||||
if (validationType == "alias") {
|
||||
val cdtrAcct = payload.optJSONObject("CdtrAcct") ?: return null
|
||||
BmlAccountValidation(
|
||||
trnType = trnType,
|
||||
validationType = validationType,
|
||||
account = cdtrAcct.optString("Acct"),
|
||||
originalInput = input,
|
||||
name = payload.optString("contact_name").trim(),
|
||||
alias = null,
|
||||
currency = payload.optString("currency", "MVR"),
|
||||
agnt = cdtrAcct.optString("FinInstnId").takeIf { it.isNotBlank() }
|
||||
)
|
||||
} else {
|
||||
BmlAccountValidation(
|
||||
trnType = trnType,
|
||||
validationType = validationType,
|
||||
account = payload.optString("account"),
|
||||
originalInput = input,
|
||||
name = payload.optString("name"),
|
||||
alias = payload.optString("alias").takeIf { it.isNotBlank() && it != "null" },
|
||||
currency = payload.optString("currency", "MVR")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun verifyMibAccount(session: BmlSession, account: String): BmlAccountValidation? {
|
||||
val resp = apiClient.newCall(
|
||||
Request.Builder().url("$BASE_URL/api/mobile/favara/account-verification/$account/MIB")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return null
|
||||
BmlAccountValidation(
|
||||
trnType = "DOT",
|
||||
validationType = "MIB",
|
||||
account = root.optString("account"),
|
||||
originalInput = account,
|
||||
name = root.optString("name"),
|
||||
alias = null,
|
||||
currency = "MVR",
|
||||
agnt = root.optString("agnt").takeIf { it.isNotBlank() }
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun saveContact(
|
||||
session: BmlSession,
|
||||
contactType: String,
|
||||
account: String,
|
||||
alias: String,
|
||||
currency: String? = null,
|
||||
name: String? = null,
|
||||
swift: String? = null
|
||||
): Boolean {
|
||||
val bodyObj = JSONObject().apply {
|
||||
put("contact_type", contactType)
|
||||
put("account", account)
|
||||
put("alias", alias)
|
||||
if (currency != null) put("currency", currency)
|
||||
if (name != null) put("name", name)
|
||||
if (swift != null) put("swift", swift)
|
||||
}
|
||||
val resp = apiClient.newCall(
|
||||
Request.Builder().url("$BASE_URL/api/mobile/contacts")
|
||||
.post(bodyObj.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return false
|
||||
resp.close()
|
||||
return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
fun fetchContacts(session: BmlSession, loginId: String): List<MibBeneficiary> {
|
||||
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/contacts")).execute()
|
||||
val json = resp.body?.string() ?: return emptyList()
|
||||
resp.close()
|
||||
return parseContacts(json, loginId)
|
||||
}
|
||||
// ─── Token refresh ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Step 1 of BML transfer: POST without OTP. Returns true if server responds code=22 (OTP ready).
|
||||
* Uses the saved refresh token to obtain a new access token without re-login.
|
||||
* Returns a new [BmlSession] with updated tokens.
|
||||
*/
|
||||
fun initiateTransfer(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
creditAccount: String,
|
||||
amount: Double,
|
||||
transferType: String,
|
||||
currency: String,
|
||||
bank: String? = null
|
||||
): Boolean {
|
||||
val jo = JSONObject().apply {
|
||||
put("debitAccount", debitAccount)
|
||||
put("creditAccount", creditAccount)
|
||||
put("debitAmount", amount)
|
||||
put("transfertype", transferType)
|
||||
put("currency", currency)
|
||||
put("channel", "token")
|
||||
if (bank != null) put("bank", bank)
|
||||
}
|
||||
val body = jo.toString().toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("$BASE_URL/api/mobile/transfer")
|
||||
.post(body)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
fun refreshSession(session: BmlSession): BmlSession {
|
||||
val body = FormBody.Builder()
|
||||
.add("grant_type", "refresh_token")
|
||||
.add("refresh_token", session.refreshToken)
|
||||
.add("client_id", CLIENT_ID)
|
||||
.add("Device-ID", session.deviceId)
|
||||
.add("User-Agent", APP_USER_AGENT)
|
||||
.add("x-app-version", APP_VERSION)
|
||||
.build()
|
||||
return apiClient.newCall(request).execute().use { response ->
|
||||
val bodyStr = response.body?.string() ?: return@use false
|
||||
try {
|
||||
val json = JSONObject(bodyStr)
|
||||
json.optBoolean("success") && json.optInt("code") == 22
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2 of BML transfer: POST with OTP + remarks. Returns BmlTransferResult.
|
||||
*/
|
||||
fun confirmTransfer(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
creditAccount: String,
|
||||
amount: Double,
|
||||
transferType: String,
|
||||
currency: String,
|
||||
otp: String,
|
||||
remarks: String = "",
|
||||
bank: String? = null
|
||||
): BmlTransferResult {
|
||||
val jo = JSONObject().apply {
|
||||
put("debitAccount", debitAccount)
|
||||
put("creditAccount", creditAccount)
|
||||
put("debitAmount", amount)
|
||||
put("transfertype", transferType)
|
||||
put("currency", currency)
|
||||
put("channel", "token")
|
||||
put("otp", otp)
|
||||
if (remarks.isNotBlank()) put("remarks", remarks)
|
||||
if (bank != null) put("bank", bank)
|
||||
}
|
||||
val body = jo.toString().toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("$BASE_URL/api/mobile/transfer")
|
||||
.post(body)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return apiClient.newCall(request).execute().use { response ->
|
||||
val bodyStr = response.body?.string()
|
||||
?: return@use BmlTransferResult(false, errorMessage = "No response")
|
||||
try {
|
||||
val json = JSONObject(bodyStr)
|
||||
if (!json.optBoolean("success")) {
|
||||
BmlTransferResult(false, errorMessage = json.optString("message").ifBlank { "Transfer failed" })
|
||||
} else {
|
||||
val payload = json.optJSONObject("payload")
|
||||
BmlTransferResult(
|
||||
success = true,
|
||||
reference = payload?.optString("reference") ?: "",
|
||||
timestamp = payload?.optString("timestamp") ?: "",
|
||||
message = json.optString("message")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { BmlTransferResult(false, errorMessage = "Parse error") }
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteContact(session: BmlSession, contactId: String): Boolean {
|
||||
val body = """{"_method":"delete"}""".toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("$BASE_URL/api/mobile/contacts/$contactId")
|
||||
.post(body)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return apiClient.newCall(request).execute().use { response ->
|
||||
val bodyStr = response.body?.string() ?: return@use false
|
||||
try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
|
||||
}
|
||||
}
|
||||
|
||||
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
|
||||
private fun parsePurchaseNarrative1(narrative1: String): String? {
|
||||
return try {
|
||||
val parts = narrative1.split(" ")
|
||||
if (parts.size < 2) null
|
||||
else {
|
||||
val timePart = parts[1].take(4)
|
||||
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
|
||||
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
|
||||
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
|
||||
private fun parseTransferNarrative1(narrative1: String): String? {
|
||||
return try {
|
||||
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
|
||||
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches paginated transaction history for a BML CASA account.
|
||||
* @return Pair of (transactions, totalPages)
|
||||
*/
|
||||
fun fetchAccountHistory(
|
||||
session: BmlSession,
|
||||
accountId: String,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String,
|
||||
page: Int
|
||||
): Pair<List<Transaction>, Int> {
|
||||
val resp = apiClient.newCall(
|
||||
Request.Builder().url("$BASE_URL/api/mobile/account/$accountId/history/$page")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.build()
|
||||
val resp = newBmlApiClient().newCall(
|
||||
Request.Builder().url("$BASE_URL/oauth/token").post(body)
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||
val json = resp.body?.string() ?: throw Exception("Empty refresh response")
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return Pair(emptyList(), 0)
|
||||
val payload = root.optJSONObject("payload") ?: return Pair(emptyList(), 0)
|
||||
val totalPages = payload.optInt("totalPages", 0)
|
||||
val history = payload.optJSONArray("history") ?: return Pair(emptyList(), totalPages)
|
||||
val transactions = (0 until history.length()).map { i ->
|
||||
val item = history.getJSONObject(i)
|
||||
val desc = item.optString("description").trim()
|
||||
val narrative1 = item.optString("narrative1")
|
||||
val date = when (desc) {
|
||||
"Purchase" -> parsePurchaseNarrative1(narrative1) ?: item.optString("bookingDate")
|
||||
"Transfer Debit", "Transfer Credit" -> parseTransferNarrative1(narrative1) ?: item.optString("bookingDate")
|
||||
else -> item.optString("bookingDate")
|
||||
}
|
||||
Transaction(
|
||||
id = item.optString("id"),
|
||||
date = date,
|
||||
description = desc,
|
||||
amount = item.optDouble("amount", 0.0),
|
||||
currency = item.optString("currency"),
|
||||
counterpartyName = item.optString("narrative2").takeIf { it.isNotBlank() },
|
||||
reference = item.optString("reference").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML"
|
||||
)
|
||||
}
|
||||
Pair(transactions, totalPages)
|
||||
} catch (_: Exception) { Pair(emptyList(), 0) }
|
||||
val obj = JSONObject(json)
|
||||
val newAccess = obj.optString("access_token").takeIf { it.isNotBlank() }
|
||||
?: throw Exception("Token refresh failed")
|
||||
val newRefresh = obj.optString("refresh_token", "").ifBlank { session.refreshToken }
|
||||
val expiresIn = obj.optLong("expires_in", 0L)
|
||||
val expiresAt = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000L else 0L
|
||||
return BmlSession(accessToken = newAccess, deviceId = session.deviceId, refreshToken = newRefresh, expiresAt = expiresAt)
|
||||
}
|
||||
|
||||
// ─── Parsing ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetches card statement for a BML prepaid card for the given month ("YYYYMM").
|
||||
* Returns combined outstanding authorizations + settled statement entries.
|
||||
* BML web responses are Inertia.js pages — the data is embedded as HTML-escaped JSON
|
||||
* in the `data-page="..."` attribute of the root div. This extracts and unescapes it.
|
||||
*/
|
||||
fun fetchCardHistory(
|
||||
session: BmlSession,
|
||||
cardId: String,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String,
|
||||
month: String
|
||||
): List<Transaction> {
|
||||
val body = """{"card":"$cardId","month":"$month"}"""
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val resp = apiClient.newCall(
|
||||
Request.Builder().url("$BASE_URL/api/mobile/card/statement").post(body)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return emptyList()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload = root.optJSONObject("payload") ?: return emptyList()
|
||||
val result = mutableListOf<Transaction>()
|
||||
|
||||
// Outstanding authorizations
|
||||
val authDetails = payload.optJSONObject("outstanding")
|
||||
?.optJSONArray("CardOutStdAuthDetails")
|
||||
if (authDetails != null) {
|
||||
for (i in 0 until authDetails.length()) {
|
||||
val item = authDetails.getJSONObject(i)
|
||||
result.add(Transaction(
|
||||
id = "auth_${item.optString("TranApprCode")}_$i",
|
||||
date = item.optString("DateTime"),
|
||||
description = item.optString("TranDesc").trim(),
|
||||
amount = item.optDouble("BillingAmount", 0.0),
|
||||
currency = item.optString("BillingCcy", "MVR"),
|
||||
counterpartyName = null,
|
||||
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Settled statement entries
|
||||
val statement = payload.optJSONArray("cardstatement")
|
||||
if (statement != null) {
|
||||
for (i in 0 until statement.length()) {
|
||||
val item = statement.getJSONObject(i)
|
||||
result.add(Transaction(
|
||||
id = "stmt_${item.optString("TranRef", i.toString())}",
|
||||
date = item.optString("TransDate", item.optString("TranDate", "")),
|
||||
description = item.optString("TranDesc", item.optString("Description", "")).trim(),
|
||||
amount = -item.optDouble("TranAmount", 0.0),
|
||||
currency = item.optString("TranCcy", "MVR"),
|
||||
counterpartyName = null,
|
||||
reference = item.optString("TranRef").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
))
|
||||
}
|
||||
}
|
||||
result
|
||||
} catch (_: Exception) { emptyList() }
|
||||
private fun extractInertiaJson(html: String): String? {
|
||||
val match = Regex("""data-page="([^"]+)"""").find(html) ?: return null
|
||||
return match.groupValues[1]
|
||||
.replace(""", "\"")
|
||||
.replace("&", "&")
|
||||
.replace("'", "'")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
}
|
||||
|
||||
private fun apiRequest(session: BmlSession, url: String) =
|
||||
Request.Builder().url(url)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.build()
|
||||
|
||||
private fun parseDashboard(json: String, loginTag: String): List<MibAccount> {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
|
||||
|
||||
val casaAccounts = mutableListOf<MibAccount>()
|
||||
val prepaidCards = mutableListOf<MibAccount>()
|
||||
|
||||
for (i in 0 until dashboard.length()) {
|
||||
val item = dashboard.getJSONObject(i)
|
||||
val currency = item.optString("currency", "MVR")
|
||||
val accountType = item.optString("account_type", "CASA")
|
||||
val product = item.optString("product")
|
||||
val accountNumber = item.optString("account")
|
||||
val status = item.optString("account_status", "Active")
|
||||
|
||||
val internalId = item.optString("id", "")
|
||||
|
||||
if (accountType == "CASA") {
|
||||
val available = item.optDouble("availableBalance", 0.0)
|
||||
casaAccounts.add(MibAccount(
|
||||
bank = "BML",
|
||||
profileName = "Personal",
|
||||
profileType = "BML",
|
||||
accountNumber = accountNumber,
|
||||
accountBriefName = item.optString("alias"),
|
||||
currencyName = currency,
|
||||
accountTypeName = product,
|
||||
availableBalance = "%.2f".format(available),
|
||||
currentBalance = "%.2f".format(item.optDouble("ledgerBalance", 0.0)),
|
||||
blockedAmount = "%.2f".format(item.optDouble("lockedAmount", 0.0)),
|
||||
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
||||
statusDesc = status,
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
internalId = internalId
|
||||
))
|
||||
} else if (accountType == "Card") {
|
||||
val isVisible = item.optBoolean("account_visible", false)
|
||||
if (!isVisible) continue // debit cards and other hidden cards — skip
|
||||
val isPrepaid = item.optBoolean("prepaid_card", false)
|
||||
val cardBalance = item.optJSONObject("cardBalance")
|
||||
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
|
||||
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
|
||||
prepaidCards.add(MibAccount(
|
||||
bank = "BML",
|
||||
profileName = "Personal",
|
||||
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
|
||||
accountNumber = accountNumber,
|
||||
accountBriefName = item.optString("alias").ifBlank { product },
|
||||
currencyName = currency,
|
||||
accountTypeName = product,
|
||||
availableBalance = "%.2f".format(available),
|
||||
currentBalance = "%.2f".format(current),
|
||||
blockedAmount = "0.00",
|
||||
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
||||
statusDesc = status,
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
internalId = internalId
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return casaAccounts + prepaidCards
|
||||
}
|
||||
|
||||
private fun parseForeignLimits(json: String): List<BmlForeignLimit> {
|
||||
private fun parseProfiles(html: String): List<BmlProfile> {
|
||||
return try {
|
||||
val json = extractInertiaJson(html) ?: html
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload = root.optJSONArray("payload") ?: return emptyList()
|
||||
(0 until payload.length()).map { i ->
|
||||
val item = payload.getJSONObject(i)
|
||||
val usage = item.optJSONObject("usageByCategory") ?: JSONObject()
|
||||
val atm = usage.optJSONObject("ATM") ?: JSONObject()
|
||||
val ecom = usage.optJSONObject("ECOM") ?: JSONObject()
|
||||
val pos = usage.optJSONObject("POS") ?: JSONObject()
|
||||
BmlForeignLimit(
|
||||
type = item.optString("type", "Debit"),
|
||||
used = item.optDouble("used", 0.0),
|
||||
totalLimit = item.optDouble("totalLimit", 0.0),
|
||||
generalCap = item.optDouble("generalCap", 0.0),
|
||||
generalRemaining = item.optDouble("generalRemaining", 0.0),
|
||||
medicalRemaining = item.optDouble("medicalRemaining", 0.0),
|
||||
isAtmEnabled = item.optBoolean("isAtmEnabled", false),
|
||||
isPosEnabled = item.optBoolean("isPosEnabled", false),
|
||||
atmRemaining = atm.optDouble("remaining", 0.0),
|
||||
atmLimit = atm.optDouble("limit", 0.0),
|
||||
ecomRemaining = ecom.optDouble("remaining", 0.0),
|
||||
ecomLimit = ecom.optDouble("limit", 0.0),
|
||||
posRemaining = pos.optDouble("remaining", 0.0),
|
||||
posLimit = pos.optDouble("limit", 0.0)
|
||||
val props = root.optJSONObject("props") ?: return emptyList()
|
||||
val profiles = props.optJSONArray("profiles") ?: return emptyList()
|
||||
(0 until profiles.length()).mapNotNull { i ->
|
||||
val p = profiles.getJSONObject(i)
|
||||
val profileObj = p.optJSONObject("profile") ?: return@mapNotNull null
|
||||
BmlProfile(
|
||||
profileId = p.optString("profile_id"),
|
||||
name = p.optString("name"),
|
||||
type = p.optString("type"),
|
||||
profileType = profileObj.optString("profile_type", "default")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
private fun parseContacts(json: String, loginId: String = ""): List<MibBeneficiary> {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList()
|
||||
val result = mutableListOf<MibBeneficiary>()
|
||||
for (i in 0 until payload.length()) {
|
||||
val item = payload.getJSONObject(i)
|
||||
val account = item.optString("account", "")
|
||||
if (account.isBlank()) continue
|
||||
result.add(MibBeneficiary(
|
||||
benefNo = "bml_${item.optInt("id")}",
|
||||
benefName = item.optString("name"),
|
||||
benefNickName = item.optString("alias", item.optString("name")),
|
||||
benefAccount = account,
|
||||
benefType = "I",
|
||||
bankColor = "#0066A1",
|
||||
benefBankName = "Bank of Maldives",
|
||||
bankCode = "",
|
||||
benefStatus = item.optString("status", "S"),
|
||||
transferCyDesc = item.optString("currency", "MVR"),
|
||||
customerImgHash = null,
|
||||
benefCategoryId = "BML",
|
||||
profileId = loginId
|
||||
))
|
||||
}
|
||||
return result
|
||||
private fun parseBusinessOtpChannels(html: String): List<BmlOtpChannel> {
|
||||
return try {
|
||||
val json = extractInertiaJson(html) ?: html
|
||||
val root = JSONObject(json)
|
||||
val props = root.optJSONObject("props") ?: return emptyList()
|
||||
val channels = props.optJSONArray("channels") ?: return emptyList()
|
||||
(0 until channels.length()).map { i ->
|
||||
val c = channels.getJSONObject(i)
|
||||
BmlOtpChannel(
|
||||
channel = c.optString("channel"),
|
||||
description = c.optString("description"),
|
||||
masked = c.optString("masked")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
private fun xsrfToken(): String? =
|
||||
cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value
|
||||
|
||||
@@ -748,4 +427,5 @@ class BmlLoginFlow {
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,10 +1,40 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
|
||||
data class BmlSession(
|
||||
val accessToken: String,
|
||||
val deviceId: String
|
||||
val deviceId: String,
|
||||
val refreshToken: String = "",
|
||||
val expiresAt: Long = 0L // Unix millis; 0 = unknown
|
||||
) {
|
||||
fun isExpired() = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
|
||||
}
|
||||
|
||||
data class BmlProfile(
|
||||
val profileId: String,
|
||||
val name: String,
|
||||
val type: String, // "Profile" (personal) or "Business"
|
||||
val profileType: String, // "default" or "business"
|
||||
val autoActivated: Boolean = false // true for single-profile accounts where server skips the picker
|
||||
)
|
||||
|
||||
data class BmlOtpChannel(
|
||||
val channel: String,
|
||||
val description: String,
|
||||
val masked: String
|
||||
)
|
||||
|
||||
sealed class BmlActivationResult {
|
||||
data class Success(
|
||||
val session: BmlSession,
|
||||
val accounts: List<BankAccount>
|
||||
) : BmlActivationResult()
|
||||
data class NeedsBusinessOtp(
|
||||
val channels: List<BmlOtpChannel>
|
||||
) : BmlActivationResult()
|
||||
}
|
||||
|
||||
data class BmlAccountValidation(
|
||||
val trnType: String, // IAT, QTR, DOT
|
||||
val validationType: String, // BML, alias, MIB
|
||||
@@ -24,6 +54,43 @@ data class BmlTransferResult(
|
||||
val errorMessage: String = ""
|
||||
)
|
||||
|
||||
data class BmlLoanDetail(
|
||||
val loanAmount: Double,
|
||||
val outstandingAmt: Double, // negative as returned by API
|
||||
val repayAmount: Double,
|
||||
val intRate: Double,
|
||||
val loanStatus: String,
|
||||
val startDate: String, // ISO8601 e.g. "2023-10-26T00:00:00+05:00"
|
||||
val endDate: String,
|
||||
val noOfRepayOverdue: Int,
|
||||
val overdueAmount: Double
|
||||
)
|
||||
|
||||
data class BmlQrPayInfo(
|
||||
val requestId: String, // base64-encoded full QR URL (trxn_hash)
|
||||
val merchantName: String, // narrative1
|
||||
val merchantAddress: String, // narrative2 + narrative3
|
||||
val amount: Double, // 0.0 for static QR
|
||||
val currency: String
|
||||
)
|
||||
|
||||
data class BmlQrPayResult(
|
||||
val success: Boolean,
|
||||
val merchant: String = "",
|
||||
val amount: String = "",
|
||||
val currency: String = "",
|
||||
val errorMessage: String = ""
|
||||
)
|
||||
|
||||
data class BmlWalletToken(
|
||||
val token: String,
|
||||
val expiry: String,
|
||||
val appCode: String, // AID hex, e.g. "A0000000031010"
|
||||
val serviceCode: String,
|
||||
val data: String,
|
||||
val validUntil: String // "YYYY-MM-DD HH:mm:ss.SSS"
|
||||
)
|
||||
|
||||
data class BmlForeignLimit(
|
||||
val type: String,
|
||||
val used: Double,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.ui.home.AppNotification
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
private const val BML_NOTIF_BASE = "https://app.bankofmaldives.com.mv"
|
||||
|
||||
class BmlNotificationsClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
private val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
|
||||
|
||||
data class FetchResult(
|
||||
val items: List<AppNotification>,
|
||||
val total: Int
|
||||
)
|
||||
|
||||
fun fetchNotifications(
|
||||
session: BmlSession,
|
||||
loginId: String,
|
||||
group: String = "ALL",
|
||||
page: Int = 1
|
||||
): FetchResult {
|
||||
val url = "$BML_NOTIF_BASE/api/v2/notifications?group=$group&page=$page"
|
||||
return try {
|
||||
val resp = client.newCall(bmlApiRequest(session, url)).execute()
|
||||
if (!resp.isSuccessful) { resp.close(); return FetchResult(emptyList(), 0) }
|
||||
val body = resp.body?.string().also { resp.close() } ?: return FetchResult(emptyList(), 0)
|
||||
parseResponse(body, loginId)
|
||||
} catch (_: Exception) { FetchResult(emptyList(), 0) }
|
||||
}
|
||||
|
||||
fun markAllRead(session: BmlSession): Boolean {
|
||||
val url = "$BML_NOTIF_BASE/api/v2/notifications/read"
|
||||
val reqBody = """{"all":true}""".toRequestBody("application/json".toMediaType())
|
||||
val req = Request.Builder().url(url)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.put(reqBody)
|
||||
.build()
|
||||
return try {
|
||||
val resp = client.newCall(req).execute()
|
||||
val ok = resp.isSuccessful
|
||||
resp.close()
|
||||
ok
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
private fun parseResponse(body: String, loginId: String): FetchResult {
|
||||
val json = JSONObject(body)
|
||||
if (!json.optBoolean("success")) return FetchResult(emptyList(), 0)
|
||||
val total = json.optInt("total", 0)
|
||||
val payload = json.optJSONArray("payload") ?: return FetchResult(emptyList(), total)
|
||||
|
||||
val items = (0 until payload.length()).map { i ->
|
||||
val obj = payload.getJSONObject(i)
|
||||
val dataObj = obj.optJSONObject("data")
|
||||
val detailFields = mutableListOf<Pair<String, String>>()
|
||||
detailFields.add("Bank" to "BML")
|
||||
detailFields.add("Group" to obj.optString("group"))
|
||||
detailFields.add("Type" to obj.optString("type"))
|
||||
if (dataObj != null) {
|
||||
dataObj.keys().forEach { key ->
|
||||
val v = dataObj.opt(key)?.toString()?.takeIf { it.isNotBlank() } ?: return@forEach
|
||||
detailFields.add(formatKey(key) to v)
|
||||
}
|
||||
}
|
||||
val createdAt = obj.optString("created_at")
|
||||
val tsMs = try { sdf.parse(createdAt)?.time ?: System.currentTimeMillis() }
|
||||
catch (_: Exception) { System.currentTimeMillis() }
|
||||
AppNotification(
|
||||
id = obj.optString("id"),
|
||||
bank = "BML",
|
||||
loginId = loginId,
|
||||
group = obj.optString("group", "ALERTS"),
|
||||
title = obj.optString("title"),
|
||||
message = obj.optString("message"),
|
||||
timestampMs = tsMs,
|
||||
isRead = obj.optBoolean("is_read", true),
|
||||
detailFields = detailFields
|
||||
)
|
||||
}
|
||||
return FetchResult(items, total)
|
||||
}
|
||||
|
||||
private fun formatKey(key: String): String =
|
||||
key.replace('_', ' ').split(' ').joinToString(" ") { it.replaceFirstChar(Char::uppercase) }
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
|
||||
class BmlQrPayClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
/**
|
||||
* Resolves a BML QR URL to merchant details.
|
||||
* [base64Url] is the full QR URL Base64-encoded (standard, with padding).
|
||||
*/
|
||||
fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo {
|
||||
val request = bmlApiRequest(session,
|
||||
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url")
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string() ?: throw Exception("No response")
|
||||
val json = JSONObject(body)
|
||||
if (!json.optBoolean("success"))
|
||||
throw Exception(json.optString("message").ifBlank { "Lookup failed" })
|
||||
val payload = json.getJSONObject("payload")
|
||||
val addr2 = payload.optString("narrative2").trim()
|
||||
val addr3 = payload.optString("narrative3").trim()
|
||||
val address = listOf(addr2, addr3).filter { it.isNotBlank() }.joinToString(", ")
|
||||
BmlQrPayInfo(
|
||||
requestId = payload.optString("trxn_hash"),
|
||||
merchantName = payload.optString("narrative1").trim(),
|
||||
merchantAddress = address,
|
||||
amount = payload.optString("amount").toDoubleOrNull() ?: 0.0,
|
||||
currency = payload.optString("currency").ifBlank { "MVR" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-initiate step required for gateway QR (pay.bml.com.mv).
|
||||
* POST without channel — expects code 99 (OTP channel selection required).
|
||||
*/
|
||||
fun preInitiatePayment(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
requestId: String,
|
||||
amount: Double,
|
||||
currency: String
|
||||
): Boolean {
|
||||
val jo = JSONObject().apply {
|
||||
put("action", "approve")
|
||||
put("debitAccount", debitAccount)
|
||||
put("requestId", requestId)
|
||||
put("amount", amount)
|
||||
put("currency", currency)
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
|
||||
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string() ?: return@use false
|
||||
val json = try { JSONObject(body) } catch (_: Exception) { return@use false }
|
||||
json.optBoolean("success") && json.optInt("code") == 99
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 — initiate: POST with channel but no OTP.
|
||||
* Returns true when server responds with code 22 (OTP generated).
|
||||
*/
|
||||
fun initiatePayment(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
requestId: String,
|
||||
amount: Double,
|
||||
currency: String
|
||||
): Boolean {
|
||||
val jo = JSONObject().apply {
|
||||
put("action", "approve")
|
||||
put("debitAccount", debitAccount)
|
||||
put("requestId", requestId)
|
||||
put("amount", amount)
|
||||
put("currency", currency)
|
||||
put("channel", "token")
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
|
||||
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string() ?: return@use false
|
||||
val json = try { JSONObject(body) } catch (_: Exception) { return@use false }
|
||||
json.optBoolean("success") && json.optInt("code") == 22
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2 — confirm: POST with channel + OTP.
|
||||
* Returns [BmlQrPayResult] with success/error.
|
||||
*/
|
||||
fun confirmPayment(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
requestId: String,
|
||||
amount: Double,
|
||||
currency: String,
|
||||
otp: String
|
||||
): BmlQrPayResult {
|
||||
val jo = JSONObject().apply {
|
||||
put("action", "approve")
|
||||
put("debitAccount", debitAccount)
|
||||
put("requestId", requestId)
|
||||
put("amount", amount)
|
||||
put("currency", currency)
|
||||
put("channel", "token")
|
||||
put("otp", otp)
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
|
||||
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string()
|
||||
?: return@use BmlQrPayResult(false, errorMessage = "No response")
|
||||
val json = try { JSONObject(body) } catch (_: Exception) {
|
||||
return@use BmlQrPayResult(false, errorMessage = "Parse error")
|
||||
}
|
||||
if (!json.optBoolean("success")) {
|
||||
BmlQrPayResult(false, errorMessage = json.optString("message").ifBlank { "Payment failed" })
|
||||
} else {
|
||||
val payload = json.optJSONObject("payload")
|
||||
BmlQrPayResult(
|
||||
success = true,
|
||||
merchant = payload?.optString("merchant") ?: "",
|
||||
amount = payload?.optString("amount") ?: "",
|
||||
currency = payload?.optString("currency") ?: currency
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class BmlTapToPayClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
/**
|
||||
* Fetches up to [quantity] single-use payment tokens for [cardId].
|
||||
* [otp] is a TOTP code generated from the stored BML OTP seed.
|
||||
*
|
||||
* Flow:
|
||||
* 1. POST → code 99 (OTP required) or 0 (direct, unlikely)
|
||||
* 2. POST with channel=token → code 22 (OTP generated on BML side, but we use TOTP)
|
||||
* 3. POST with otp=TOTP → code 0, payload = token list
|
||||
*/
|
||||
fun fetchTokens(
|
||||
session: BmlSession,
|
||||
cardId: String,
|
||||
otp: String,
|
||||
quantity: Int = 3
|
||||
): List<BmlWalletToken> {
|
||||
val url = "$BML_BASE_URL/api/mobile/walletpayments/gettoken"
|
||||
|
||||
// Step 1: initiate
|
||||
val base = JSONObject().apply {
|
||||
put("type", "track2")
|
||||
put("cardid", cardId)
|
||||
put("quantity", quantity)
|
||||
}
|
||||
val step1 = post(session, url, base)
|
||||
if (step1.optInt("code") == 0) return parseTokens(step1.optJSONArray("payload"))
|
||||
if (step1.optInt("code") != 99) throw Exception(step1.optString("message", "Token request failed"))
|
||||
|
||||
// Step 2: request OTP channel (triggers BML to validate we can use TOTP)
|
||||
val body2 = JSONObject(base.toString()).apply { put("channel", "token") }
|
||||
val step2 = post(session, url, body2)
|
||||
if (step2.optInt("code") != 22) throw Exception(step2.optString("message", "OTP channel request failed"))
|
||||
|
||||
// Step 3: submit TOTP
|
||||
val body3 = JSONObject(body2.toString()).apply { put("otp", otp) }
|
||||
val step3 = post(session, url, body3)
|
||||
if (step3.optInt("code") != 0) throw Exception(step3.optString("message", "Token fetch failed"))
|
||||
|
||||
return parseTokens(step3.optJSONArray("payload"))
|
||||
}
|
||||
|
||||
private fun post(session: BmlSession, url: String, body: JSONObject): JSONObject {
|
||||
val req = okhttp3.Request.Builder()
|
||||
.url(url)
|
||||
.post(body.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.build()
|
||||
return client.newCall(req).execute().use { resp ->
|
||||
JSONObject(resp.body?.string() ?: throw Exception("Empty response"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTokens(arr: JSONArray?): List<BmlWalletToken> {
|
||||
arr ?: return emptyList()
|
||||
return (0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
BmlWalletToken(
|
||||
token = o.getString("token"),
|
||||
expiry = o.getString("expiry"),
|
||||
appCode = o.getString("app_code"),
|
||||
serviceCode = o.getString("service_code"),
|
||||
data = o.optString("data", ""),
|
||||
validUntil = o.optString("valid_until", "")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
|
||||
class BmlTransferClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
/** Step 1: initiate the transfer (triggers OTP). Returns true if the server accepted it. */
|
||||
fun initiateTransfer(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
creditAccount: String,
|
||||
amount: Double,
|
||||
transferType: String,
|
||||
currency: String,
|
||||
bank: String? = null,
|
||||
channel: String = "token"
|
||||
): Boolean {
|
||||
val jo = JSONObject().apply {
|
||||
put("debitAccount", debitAccount)
|
||||
put("creditAccount", creditAccount)
|
||||
put("debitAmount", amount)
|
||||
put("transfertype", transferType)
|
||||
put("currency", currency)
|
||||
put("channel", channel)
|
||||
if (bank != null) put("bank", bank)
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$BML_BASE_URL/api/mobile/transfer")
|
||||
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val bodyStr = response.body?.string() ?: return@use false
|
||||
try {
|
||||
val json = JSONObject(bodyStr)
|
||||
json.optBoolean("success") && json.optInt("code") == 22
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
}
|
||||
|
||||
/** Step 2: confirm with OTP. Returns a [BmlTransferResult] with success/reference/error. */
|
||||
fun confirmTransfer(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
creditAccount: String,
|
||||
amount: Double,
|
||||
transferType: String,
|
||||
currency: String,
|
||||
otp: String,
|
||||
remarks: String = "",
|
||||
bank: String? = null,
|
||||
channel: String = "token"
|
||||
): BmlTransferResult {
|
||||
val jo = JSONObject().apply {
|
||||
put("debitAccount", debitAccount)
|
||||
put("creditAccount", creditAccount)
|
||||
put("debitAmount", amount)
|
||||
put("transfertype", transferType)
|
||||
put("currency", currency)
|
||||
put("channel", channel)
|
||||
put("otp", otp)
|
||||
if (remarks.isNotBlank()) put("remarks", remarks)
|
||||
if (bank != null) put("bank", bank)
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$BML_BASE_URL/api/mobile/transfer")
|
||||
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val bodyStr = response.body?.string()
|
||||
?: return@use BmlTransferResult(false, errorMessage = "No response")
|
||||
try {
|
||||
val json = JSONObject(bodyStr)
|
||||
if (!json.optBoolean("success")) {
|
||||
val payloadStr = json.optString("payload").takeIf { it.isNotBlank() && it != "null" }
|
||||
BmlTransferResult(false, errorMessage = payloadStr ?: json.optString("message").ifBlank { "Transfer failed" })
|
||||
} else {
|
||||
val payload = json.optJSONObject("payload")
|
||||
BmlTransferResult(
|
||||
success = true,
|
||||
reference = payload?.optString("reference") ?: "",
|
||||
timestamp = payload?.optString("timestamp") ?: "",
|
||||
message = json.optString("message")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { BmlTransferResult(false, errorMessage = "Parse error") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
|
||||
class BmlValidateClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
fun validateAccount(session: BmlSession, input: String): BmlAccountValidation? {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/validate/account/$input")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return null
|
||||
val payload = root.optJSONObject("payload") ?: return null
|
||||
val trnType = payload.optString("trnType", "")
|
||||
val validationType = payload.optString("validationType", "")
|
||||
if (validationType == "alias") {
|
||||
val cdtrAcct = payload.optJSONObject("CdtrAcct") ?: return null
|
||||
BmlAccountValidation(
|
||||
trnType = trnType,
|
||||
validationType = validationType,
|
||||
account = cdtrAcct.optString("Acct"),
|
||||
originalInput = input,
|
||||
name = payload.optString("contact_name").trim(),
|
||||
alias = null,
|
||||
currency = payload.optString("currency", "MVR"),
|
||||
agnt = cdtrAcct.optString("FinInstnId").takeIf { it.isNotBlank() }
|
||||
)
|
||||
} else {
|
||||
BmlAccountValidation(
|
||||
trnType = trnType,
|
||||
validationType = validationType,
|
||||
account = payload.optString("account"),
|
||||
originalInput = input,
|
||||
name = payload.optString("name"),
|
||||
alias = payload.optString("alias").takeIf { it.isNotBlank() && it != "null" },
|
||||
currency = payload.optString("currency", "MVR")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun verifyMibAccount(session: BmlSession, account: String): BmlAccountValidation? {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/favara/account-verification/$account/MIB")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return null
|
||||
BmlAccountValidation(
|
||||
trnType = "DOT",
|
||||
validationType = "MIB",
|
||||
account = root.optString("account"),
|
||||
originalInput = account,
|
||||
name = root.optString("name"),
|
||||
alias = null,
|
||||
currency = "MVR",
|
||||
agnt = root.optString("agnt").takeIf { it.isNotBlank() }
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package sh.sar.basedbank.api.fahipay
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FahipayAccountClient {
|
||||
|
||||
private val BASE_URL = "https://fahipay.mv"
|
||||
private val UA = "okhttp/4.12.0"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private fun Request.Builder.auth(session: FahipaySession): Request.Builder = this
|
||||
.header("authid", session.authId)
|
||||
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
|
||||
.header("content-type", "multipart/form-data")
|
||||
.header("User-Agent", UA)
|
||||
|
||||
fun fetchProfile(session: FahipaySession): FahipayUserProfile {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en")
|
||||
.auth(session).build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: throw Exception("Empty profile response")
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Fahipay")
|
||||
val obj = JSONObject(json)
|
||||
val props = obj.optJSONObject("props") ?: JSONObject()
|
||||
return FahipayUserProfile(
|
||||
fullName = obj.optString("fullname").trim(),
|
||||
email = obj.optString("email").trim(),
|
||||
mobile = obj.optString("mobile").trim(),
|
||||
nid = obj.optString("nid").trim(),
|
||||
profileId = obj.optString("profileID").trim(),
|
||||
walletAccount = props.optString("acc", ""),
|
||||
linkedAccounts = props.optJSONObject("accs")?.toString() ?: "{}"
|
||||
)
|
||||
}
|
||||
|
||||
fun fetchBalance(session: FahipaySession): Double {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en")
|
||||
.auth(session).build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return 0.0
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Fahipay")
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0)
|
||||
} catch (_: Exception) { 0.0 }
|
||||
}
|
||||
|
||||
fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): BankAccount =
|
||||
BankAccount(
|
||||
bank = "FAHIPAY",
|
||||
profileName = profile.fullName.ifBlank { "Fahipay" },
|
||||
profileType = "FAHIPAY",
|
||||
accountNumber = profile.walletAccount,
|
||||
accountBriefName = "Fahipay Wallet",
|
||||
currencyName = "MVR",
|
||||
accountTypeName = "Digital Wallet",
|
||||
availableBalance = "%.2f".format(balance),
|
||||
currentBalance = "%.2f".format(balance),
|
||||
blockedAmount = "0.00",
|
||||
mvrBalance = "%.2f".format(balance),
|
||||
statusDesc = "Active",
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
internalId = profile.profileId
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package sh.sar.basedbank.api.fahipay
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.util.AccountInputParser
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FahipayContactsClient {
|
||||
|
||||
private val BASE_URL = "https://fahipay.mv"
|
||||
private val UA = "okhttp/4.12.0"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
fun fetchContacts(session: FahipaySession): List<FahipayContactGroup> {
|
||||
val endpoints = listOf(
|
||||
Triple("FAHIPAY_RAASTAS", "Raastas", "ooredooraastas"),
|
||||
Triple("FAHIPAY_RELOAD", "Reload", "dhiraagureload"),
|
||||
Triple("FAHIPAY_OOREDOO_BILL", "Ooredoo Bill", "ooredoobillpay"),
|
||||
Triple("FAHIPAY_DHIRAAGU_BILL", "Dhiraagu Bill", "dhiraagubillpay")
|
||||
)
|
||||
val result = mutableListOf<FahipayContactGroup>()
|
||||
for ((catId, label, page) in endpoints) {
|
||||
try {
|
||||
val resp = client.newCall(
|
||||
Request.Builder()
|
||||
.url("$BASE_URL/api/app/favs/?page=$page&lang=en")
|
||||
.header("authid", session.authId)
|
||||
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
|
||||
.header("User-Agent", UA)
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: continue
|
||||
resp.close()
|
||||
val obj = JSONObject(json)
|
||||
val groupObj = obj.optJSONObject(page) ?: continue
|
||||
val contacts = mutableListOf<BankContact>()
|
||||
for (key in groupObj.keys()) {
|
||||
val entry = groupObj.getJSONObject(key)
|
||||
val number = entry.optString("number")
|
||||
val name = entry.optString("name").trim().ifBlank { number }
|
||||
if (AccountInputParser.detect(number) != AccountInputParser.InputType.PHONE) continue
|
||||
contacts.add(BankContact(
|
||||
benefNo = "fp_${page}_$number",
|
||||
benefName = "",
|
||||
benefNickName = name,
|
||||
benefAccount = number,
|
||||
benefType = "FAHIPAY",
|
||||
bankColor = "#FF6B00",
|
||||
benefBankName = label,
|
||||
bankCode = "",
|
||||
benefStatus = "",
|
||||
transferCyDesc = "",
|
||||
customerImgHash = null,
|
||||
benefCategoryId = catId,
|
||||
profileId = ""
|
||||
))
|
||||
}
|
||||
if (contacts.isNotEmpty()) result.add(FahipayContactGroup(catId, label, contacts))
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package sh.sar.basedbank.api.fahipay
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FahipayHistoryClient {
|
||||
|
||||
private val BASE_URL = "https://fahipay.mv"
|
||||
private val UA = "okhttp/4.12.0"
|
||||
private val PAGE_SIZE = 15
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
fun fetchHistory(
|
||||
session: FahipaySession,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String,
|
||||
start: Int
|
||||
): Pair<List<BankTransaction>, Int> {
|
||||
val resp = client.newCall(
|
||||
Request.Builder()
|
||||
.url("$BASE_URL/actions/activity/?s=$start&l=$PAGE_SIZE&lang=en")
|
||||
.header("authid", session.authId)
|
||||
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
|
||||
.header("content-type", "multipart/form-data")
|
||||
.header("User-Agent", UA)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Fahipay")
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
val total = obj.optInt("total", 0)
|
||||
val entries = obj.optJSONArray("entries") ?: return Pair(emptyList(), total)
|
||||
val list = (0 until entries.length()).map { i ->
|
||||
val e = entries.getJSONObject(i)
|
||||
BankTransaction(
|
||||
id = e.optString("transaction"),
|
||||
date = e.optString("date"),
|
||||
description = e.optString("name").trim(),
|
||||
amount = e.optDouble("amount", 0.0),
|
||||
currency = "MVR",
|
||||
counterpartyName = e.optString("details").takeIf { it.isNotBlank() },
|
||||
reference = e.optString("transaction").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "FAHIPAY",
|
||||
iconUrl = e.optString("icon").takeIf { it.isNotBlank() }
|
||||
)
|
||||
}
|
||||
Pair(list, total)
|
||||
} catch (_: Exception) { Pair(emptyList(), 0) }
|
||||
}
|
||||
}
|
||||
@@ -10,19 +10,13 @@ import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okio.Buffer
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.util.AccountInputParser
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FahipayLoginFlow {
|
||||
|
||||
private val BASE_URL = "https://fahipay.mv"
|
||||
private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android 14; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
private val UA_OKHTTP = "okhttp/4.12.0"
|
||||
private val PAGE_SIZE = 15
|
||||
private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
|
||||
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
||||
private val cookieJar = object : CookieJar {
|
||||
@@ -144,165 +138,6 @@ class FahipayLoginFlow {
|
||||
?: throw Exception("No authID in OTP response")
|
||||
}
|
||||
|
||||
fun fetchProfile(session: FahipaySession): FahipayUserProfile {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en")
|
||||
.header("authid", session.authId)
|
||||
.header("content-type", "multipart/form-data")
|
||||
.header("User-Agent", UA_OKHTTP)
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: throw Exception("Empty profile response")
|
||||
resp.close()
|
||||
|
||||
val obj = JSONObject(json)
|
||||
val props = obj.optJSONObject("props") ?: JSONObject()
|
||||
return FahipayUserProfile(
|
||||
fullName = obj.optString("fullname").trim(),
|
||||
email = obj.optString("email").trim(),
|
||||
mobile = obj.optString("mobile").trim(),
|
||||
nid = obj.optString("nid").trim(),
|
||||
profileId = obj.optString("profileID").trim(),
|
||||
walletAccount = props.optString("acc", ""),
|
||||
linkedAccounts = props.optJSONObject("accs")?.toString() ?: "{}"
|
||||
)
|
||||
}
|
||||
|
||||
fun fetchBalance(session: FahipaySession): Double {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en")
|
||||
.header("authid", session.authId)
|
||||
.header("content-type", "multipart/form-data")
|
||||
.header("User-Agent", UA_OKHTTP)
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return 0.0
|
||||
resp.close()
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0)
|
||||
} catch (_: Exception) { 0.0 }
|
||||
}
|
||||
|
||||
fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): MibAccount =
|
||||
MibAccount(
|
||||
bank = "FAHIPAY",
|
||||
profileName = profile.fullName.ifBlank { "Fahipay" },
|
||||
profileType = "FAHIPAY",
|
||||
accountNumber = profile.walletAccount,
|
||||
accountBriefName = "Fahipay Wallet",
|
||||
currencyName = "MVR",
|
||||
accountTypeName = "Digital Wallet",
|
||||
availableBalance = "%.2f".format(balance),
|
||||
currentBalance = "%.2f".format(balance),
|
||||
blockedAmount = "0.00",
|
||||
mvrBalance = "%.2f".format(balance),
|
||||
statusDesc = "Active",
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
internalId = profile.profileId
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetches paginated activity history.
|
||||
* @param start offset (0-based)
|
||||
* @return Pair of (transactions, total count)
|
||||
*/
|
||||
fun fetchHistory(
|
||||
session: FahipaySession,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String,
|
||||
start: Int
|
||||
): Pair<List<Transaction>, Int> {
|
||||
val resp = client.newCall(
|
||||
Request.Builder()
|
||||
.url("$BASE_URL/actions/activity/?s=$start&l=$PAGE_SIZE&lang=en")
|
||||
.header("authid", session.authId)
|
||||
.header("content-type", "multipart/form-data")
|
||||
.header("User-Agent", UA_OKHTTP)
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||
resp.close()
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
val total = obj.optInt("total", 0)
|
||||
val entries = obj.optJSONArray("entries") ?: return Pair(emptyList(), total)
|
||||
val list = (0 until entries.length()).map { i ->
|
||||
val e = entries.getJSONObject(i)
|
||||
Transaction(
|
||||
id = e.optString("transaction"),
|
||||
date = e.optString("date"),
|
||||
description = e.optString("name").trim(),
|
||||
amount = e.optDouble("amount", 0.0),
|
||||
currency = "MVR",
|
||||
counterpartyName = e.optString("details").takeIf { it.isNotBlank() },
|
||||
reference = e.optString("transaction").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "FAHIPAY",
|
||||
iconUrl = e.optString("icon").takeIf { it.isNotBlank() }
|
||||
)
|
||||
}
|
||||
Pair(list, total)
|
||||
} catch (_: Exception) { Pair(emptyList(), 0) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches Fahipay saved favourites for the 4 service groups.
|
||||
* Only includes entries whose number is a valid 7-digit Maldivian phone number (starts with 7 or 9).
|
||||
* Groups with no valid entries are omitted.
|
||||
*/
|
||||
fun fetchContacts(session: FahipaySession): List<FahipayContactGroup> {
|
||||
val endpoints = listOf(
|
||||
Triple("FAHIPAY_RAASTAS", "Raastas", "ooredooraastas"),
|
||||
Triple("FAHIPAY_RELOAD", "Reload", "dhiraagureload"),
|
||||
Triple("FAHIPAY_OOREDOO_BILL", "Ooredoo Bill", "ooredoobillpay"),
|
||||
Triple("FAHIPAY_DHIRAAGU_BILL", "Dhiraagu Bill", "dhiraagubillpay")
|
||||
)
|
||||
val result = mutableListOf<FahipayContactGroup>()
|
||||
for ((catId, label, page) in endpoints) {
|
||||
try {
|
||||
val resp = client.newCall(
|
||||
Request.Builder()
|
||||
.url("$BASE_URL/api/app/favs/?page=$page&lang=en")
|
||||
.header("authid", session.authId)
|
||||
.header("User-Agent", UA_OKHTTP)
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: continue
|
||||
resp.close()
|
||||
val obj = JSONObject(json)
|
||||
// Empty group comes back as a JSON array [], not an object — optJSONObject returns null
|
||||
val groupObj = obj.optJSONObject(page) ?: continue
|
||||
val contacts = mutableListOf<MibBeneficiary>()
|
||||
for (key in groupObj.keys()) {
|
||||
val entry = groupObj.getJSONObject(key)
|
||||
val number = entry.optString("number")
|
||||
val name = entry.optString("name").trim().ifBlank { number }
|
||||
if (AccountInputParser.detect(number) != AccountInputParser.InputType.PHONE) continue
|
||||
contacts.add(MibBeneficiary(
|
||||
benefNo = "fp_${page}_$number",
|
||||
benefName = "",
|
||||
benefNickName = name,
|
||||
benefAccount = number,
|
||||
benefType = "FAHIPAY",
|
||||
bankColor = "#FF6B00",
|
||||
benefBankName = label,
|
||||
bankCode = "",
|
||||
benefStatus = "",
|
||||
transferCyDesc = "",
|
||||
customerImgHash = null,
|
||||
benefCategoryId = catId,
|
||||
profileId = ""
|
||||
))
|
||||
}
|
||||
if (contacts.isNotEmpty()) result.add(FahipayContactGroup(catId, label, contacts))
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
|
||||
"device[available]" to "true",
|
||||
"device[platform]" to "Android",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package sh.sar.basedbank.api.fahipay
|
||||
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
|
||||
data class FahipaySession(
|
||||
val authId: String,
|
||||
val sessionCookie: String
|
||||
@@ -23,5 +25,5 @@ data class FahipayLoginStep(
|
||||
data class FahipayContactGroup(
|
||||
val categoryId: String,
|
||||
val label: String,
|
||||
val contacts: List<sh.sar.basedbank.api.mib.MibBeneficiary>
|
||||
val contacts: List<BankContact>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import android.os.Build
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.ui.home.AppNotification
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val SKIP_TYPES = setOf("Switch Profile")
|
||||
private const val MIB_WV_URL = "https://faisamobilex-wv.mib.com.mv"
|
||||
|
||||
class MibActivityHistoryClient {
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val sdf = SimpleDateFormat("dd MMM yyyy HH:mm", Locale.US)
|
||||
|
||||
data class FetchResult(
|
||||
val items: List<AppNotification>, // already filtered (no Switch Profile)
|
||||
val rawCount: Int, // raw items returned by API before filtering
|
||||
val totalCount: Int,
|
||||
val nextStart: Int
|
||||
)
|
||||
|
||||
fun fetchActivity(
|
||||
session: MibSession,
|
||||
loginId: String,
|
||||
start: Int,
|
||||
end: Int
|
||||
): FetchResult {
|
||||
val cookieHeader = "mbmodel=IOS-1.0; " +
|
||||
"xxid=${session.xxid}; " +
|
||||
"IBSID=${session.xxid}; " +
|
||||
"mbnonce=${session.nonceGenerator}; " +
|
||||
"time-tracker=597"
|
||||
|
||||
val formBody = FormBody.Builder()
|
||||
.add("start", start.toString())
|
||||
.add("end", end.toString())
|
||||
.add("includeCount", "1")
|
||||
.build()
|
||||
|
||||
val req = Request.Builder()
|
||||
.url("$MIB_WV_URL/aProfile/getPagedActivityHistory")
|
||||
.header("Cookie", cookieHeader)
|
||||
.header("User-Agent", "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36")
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.post(formBody)
|
||||
.build()
|
||||
|
||||
val body = try {
|
||||
val resp = client.newCall(req).execute()
|
||||
if (!resp.isSuccessful) { resp.close(); return FetchResult(emptyList(), 0, 0, end + 1) }
|
||||
resp.body?.string().also { resp.close() } ?: return FetchResult(emptyList(), 0, 0, end + 1)
|
||||
} catch (_: Exception) { return FetchResult(emptyList(), 0, 0, end + 1) }
|
||||
|
||||
return try {
|
||||
val json = JSONObject(body)
|
||||
if (!json.optBoolean("success")) return FetchResult(emptyList(), 0, 0, end + 1)
|
||||
val totalCount = json.optString("total_count", "0").toIntOrNull() ?: 0
|
||||
val dataArr = json.optJSONArray("data") ?: return FetchResult(emptyList(), 0, totalCount, end + 1)
|
||||
|
||||
val items = mutableListOf<AppNotification>()
|
||||
val rawCount = dataArr.length()
|
||||
for (i in 0 until rawCount) {
|
||||
val obj = dataArr.getJSONObject(i)
|
||||
val activityType = obj.optString("activityType")
|
||||
if (activityType in SKIP_TYPES) continue
|
||||
|
||||
val pa = obj.optString("pa")
|
||||
val activity = obj.optString("activity")
|
||||
val pb = obj.optString("pb")
|
||||
val dateStr = obj.optString("date")
|
||||
|
||||
val message = buildString {
|
||||
append(pa)
|
||||
if (activity.isNotBlank()) { append(" "); append(activity) }
|
||||
if (pb.isNotBlank()) { append(" "); append(pb) }
|
||||
}
|
||||
|
||||
val tsMs = try { sdf.parse(dateStr)?.time ?: System.currentTimeMillis() }
|
||||
catch (_: Exception) { System.currentTimeMillis() }
|
||||
|
||||
val detailFields = mutableListOf<Pair<String, String>>().apply {
|
||||
add("Bank" to "MIB")
|
||||
add("Type" to activityType)
|
||||
if (pa.isNotBlank()) add("By" to pa)
|
||||
if (activity.isNotBlank() && pb.isNotBlank()) add("Action" to "$activity $pb")
|
||||
if (dateStr.isNotBlank()) add("Date" to dateStr)
|
||||
}
|
||||
|
||||
items.add(AppNotification(
|
||||
id = obj.optString("aid"),
|
||||
bank = "MIB",
|
||||
loginId = loginId,
|
||||
group = "ALERTS",
|
||||
title = activityType,
|
||||
message = message,
|
||||
timestampMs = tsMs,
|
||||
isRead = false, // resolved from cache in the sheet
|
||||
detailFields = detailFields
|
||||
))
|
||||
}
|
||||
FetchResult(items, rawCount, totalCount, end + 1)
|
||||
} catch (_: Exception) { FetchResult(emptyList(), 0, 0, end + 1) }
|
||||
}
|
||||
|
||||
// Keeps fetching pages until at least `minCount` non-Switch-Profile items found or all pages exhausted.
|
||||
fun fetchUntilEnough(
|
||||
session: MibSession,
|
||||
loginId: String,
|
||||
minCount: Int = 5,
|
||||
pageSize: Int = 30
|
||||
): FetchResult {
|
||||
val accumulated = mutableListOf<AppNotification>()
|
||||
var start = 1
|
||||
var totalCount = 0
|
||||
|
||||
while (accumulated.size < minCount) {
|
||||
val result = fetchActivity(session, loginId, start, start + pageSize - 1)
|
||||
totalCount = result.totalCount
|
||||
accumulated.addAll(result.items)
|
||||
if (result.rawCount == 0 || start + pageSize - 1 >= totalCount) break
|
||||
start = result.nextStart
|
||||
}
|
||||
return FetchResult(accumulated, accumulated.size, totalCount, start)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import android.os.Build
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MibCardsClient {
|
||||
|
||||
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private fun cookieHeader(session: MibSession) =
|
||||
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
|
||||
"mbnonce=${session.nonceGenerator}; time-tracker=597"
|
||||
|
||||
fun fetchCards(session: MibSession, loginTag: String): List<MibCard> {
|
||||
val body = FormBody.Builder()
|
||||
.add("name", "")
|
||||
.add("start", "1")
|
||||
.add("end", "50")
|
||||
.add("includeCount", "1")
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("$BASE_WV_URL/ajaxDebitCard/fetchCardInfos")
|
||||
.post(body)
|
||||
.header("Cookie", cookieHeader(session))
|
||||
.header("User-Agent", "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36")
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("Accept", "*/*")
|
||||
.header("Origin", BASE_WV_URL)
|
||||
.header("Referer", "$BASE_WV_URL//debitCards?dashurl=1")
|
||||
.build()
|
||||
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val bodyStr = response.body?.string() ?: return emptyList()
|
||||
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return emptyList() }
|
||||
if (!json.optBoolean("success")) return emptyList()
|
||||
val data = json.optJSONArray("data") ?: return emptyList()
|
||||
(0 until data.length()).map { i ->
|
||||
val item = data.getJSONObject(i)
|
||||
MibCard(
|
||||
cardId = item.optString("cardId"),
|
||||
maskedCardNumber = item.optString("maskedCardNumber"),
|
||||
cardStatus = item.optString("cardStatus"),
|
||||
cardType = item.optString("cardType"),
|
||||
cardTypeDesc = item.optString("cardTypeDesc"),
|
||||
customerId = item.optString("customerId"),
|
||||
phoneNumber = item.optString("phoneNumber"),
|
||||
cardHolderName = item.optString("cardHolderName"),
|
||||
loginTag = loginTag
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import android.os.Build
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@@ -24,7 +25,7 @@ class MibContactsClient {
|
||||
.header("Cookie", cookieHeader(session))
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
)
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("Accept", "*/*")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import android.os.Build
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -27,7 +28,7 @@ class MibFinancingClient {
|
||||
.header("Cookie", cookieHeader)
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
)
|
||||
.header("X-Requested-With", "mv.com.mib.faisamobilex")
|
||||
.get()
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import android.os.Build
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MibHistoryClient {
|
||||
@@ -50,7 +52,7 @@ class MibHistoryClient {
|
||||
.header("Cookie", cookieHeader(session))
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
)
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("Accept", "*/*")
|
||||
@@ -59,6 +61,7 @@ class MibHistoryClient {
|
||||
.build()
|
||||
|
||||
return client.newCall(request).execute().use { response ->
|
||||
if (response.code in 500..599) throw BankServerException("MIB")
|
||||
val bodyStr = response.body?.string() ?: return Pair(emptyList(), 0)
|
||||
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return Pair(emptyList(), 0) }
|
||||
if (!json.optBoolean("success")) return Pair(emptyList(), 0)
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.json.JSONObject
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.abs
|
||||
import kotlin.random.Random
|
||||
|
||||
class SessionExpiredException : Exception("MIB session expired")
|
||||
@@ -39,7 +40,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.addInterceptor { chain ->
|
||||
.addNetworkInterceptor { chain ->
|
||||
val req = chain.request().newBuilder()
|
||||
.header("User-Agent", "android/1.0")
|
||||
.header("Accept", "application/json")
|
||||
@@ -161,14 +162,14 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
bank = "MIB",
|
||||
profileName = profile.name,
|
||||
profileType = profile.profileType,
|
||||
cifType = profile.cifType,
|
||||
productCode = profile.cifType,
|
||||
accountNumber = a.optString("accountNumber"),
|
||||
accountBriefName = a.optString("accountBriefName"),
|
||||
currencyName = a.optString("currencyName"),
|
||||
accountTypeName = a.optString("accountTypeName"),
|
||||
availableBalance = a.optString("availableBalance"),
|
||||
currentBalance = a.optString("currentBalance"),
|
||||
blockedAmount = a.optString("blockedAmount"),
|
||||
blockedAmount = absBlockedAmount(a.optString("blockedAmount")),
|
||||
mvrBalance = a.optString("mvrBalance"),
|
||||
statusDesc = a.optString("statusDesc"),
|
||||
profileImageHash = profile.customerImage,
|
||||
@@ -188,6 +189,13 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** MIB returns blockedAmount as a signed decimal where negative = funds held.
|
||||
* Normalize to a positive magnitude so downstream code can treat it uniformly. */
|
||||
private fun absBlockedAmount(raw: String): String {
|
||||
val v = raw.toDoubleOrNull() ?: return raw
|
||||
return "%.2f".format(abs(v))
|
||||
}
|
||||
|
||||
private fun initialKeyExchange(
|
||||
appId: String, encKey: String, sfunc: String, key2: String? = null
|
||||
): Pair<MibSession, String> {
|
||||
@@ -318,14 +326,14 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
bank = "MIB",
|
||||
profileName = profile.name,
|
||||
profileType = profile.profileType,
|
||||
cifType = profile.cifType,
|
||||
productCode = profile.cifType,
|
||||
accountNumber = a.optString("accountNumber"),
|
||||
accountBriefName = a.optString("accountBriefName"),
|
||||
currencyName = a.optString("currencyName"),
|
||||
accountTypeName = a.optString("accountTypeName"),
|
||||
availableBalance = a.optString("availableBalance"),
|
||||
currentBalance = a.optString("currentBalance"),
|
||||
blockedAmount = a.optString("blockedAmount"),
|
||||
blockedAmount = absBlockedAmount(a.optString("blockedAmount")),
|
||||
mvrBalance = a.optString("mvrBalance"),
|
||||
statusDesc = a.optString("statusDesc"),
|
||||
profileImageHash = profile.customerImage,
|
||||
@@ -356,44 +364,6 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
}
|
||||
}
|
||||
|
||||
data class MibPersonalProfile(
|
||||
val fullName: String,
|
||||
val username: String,
|
||||
val email: String,
|
||||
val mobile: String,
|
||||
val enrolled: String
|
||||
)
|
||||
|
||||
/** Fetches the customer's profile info from the Faisanet personal profile page. */
|
||||
fun fetchPersonalProfile(session: MibSession): MibPersonalProfile? {
|
||||
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
|
||||
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
|
||||
val request = Request.Builder()
|
||||
.url("https://faisamobilex-wv.mib.com.mv/personalProfile")
|
||||
.get()
|
||||
.header("Cookie", cookieHeader)
|
||||
.build()
|
||||
return try {
|
||||
val resp = client.newCall(request).execute()
|
||||
val html = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
fun scrape(label: String): String {
|
||||
val r = Regex("""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
|
||||
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE))
|
||||
return r.find(html)?.groupValues?.get(1)?.trim() ?: ""
|
||||
}
|
||||
val nameRegex = Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
|
||||
val fullName = nameRegex.find(html)?.groupValues?.get(1)?.trim() ?: return null
|
||||
MibPersonalProfile(
|
||||
fullName = fullName,
|
||||
username = scrape("Username:"),
|
||||
email = scrape("Email:"),
|
||||
mobile = scrape("Mobile no:"),
|
||||
enrolled = scrape("Enrolled:")
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
/** Fetches a profile image via P41. Returns base64 JPEG string, or null if not found. */
|
||||
fun fetchProfileImage(session: MibSession, imageHash: String): String? {
|
||||
val payload = baseData(session, "P41").apply {
|
||||
@@ -404,6 +374,34 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
return resp.optString("profileImage").takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a profile image via P40.
|
||||
* [imageBase64] is a base64-encoded JPEG string.
|
||||
* Returns the new imageHash on success, or null on failure.
|
||||
*/
|
||||
fun uploadProfileImage(session: MibSession, profile: MibProfile, imageBase64: String): String? {
|
||||
val payload = baseData(session, "P40").apply {
|
||||
put("profileId", profile.profileId)
|
||||
put("profileImage", imageBase64)
|
||||
}
|
||||
val resp = doRequest(session, payload, "n")
|
||||
if (!resp.optBoolean("success", false)) return null
|
||||
return resp.optString("imageHash").takeIf { it.isNotBlank() }
|
||||
?: resp.optString("customerImage").takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the profile image via P42.
|
||||
* Returns true on success.
|
||||
*/
|
||||
fun deleteProfileImage(session: MibSession, profile: MibProfile): Boolean {
|
||||
val payload = baseData(session, "P42").apply {
|
||||
put("profileId", profile.profileId)
|
||||
}
|
||||
val resp = doRequest(session, payload, "n")
|
||||
return resp.optBoolean("success", false)
|
||||
}
|
||||
|
||||
private fun post(body: FormBody): String {
|
||||
val request = Request.Builder()
|
||||
.url(BASE_URL)
|
||||
@@ -411,6 +409,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
.build()
|
||||
val response = client.newCall(request).execute()
|
||||
if (response.code == 419) throw SessionExpiredException()
|
||||
if (response.code in 500..599) throw sh.sar.basedbank.api.models.BankServerException("MIB")
|
||||
return response.body?.string() ?: throw IllegalStateException("Empty response body")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.api.models.BankContactCategory
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
|
||||
// Kept for source compatibility within the mib package
|
||||
typealias MibAccount = BankAccount
|
||||
typealias MibBeneficiary = BankContact
|
||||
typealias Transaction = BankTransaction
|
||||
typealias MibBeneficiaryCategory = BankContactCategory
|
||||
|
||||
data class MibSession(
|
||||
val appId: String,
|
||||
val xxid: String,
|
||||
@@ -19,25 +30,6 @@ data class MibProfile(
|
||||
val customerImage: String?
|
||||
)
|
||||
|
||||
data class MibAccount(
|
||||
val bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow
|
||||
val profileName: String,
|
||||
val profileType: String,
|
||||
val cifType: String = "", // MIB: human-readable profile category (e.g. "Individual", "Sole Propr"); empty for other banks
|
||||
val accountNumber: String,
|
||||
val accountBriefName: String,
|
||||
val currencyName: String,
|
||||
val accountTypeName: String,
|
||||
val availableBalance: String,
|
||||
val currentBalance: String,
|
||||
val blockedAmount: String,
|
||||
val mvrBalance: String,
|
||||
val statusDesc: String,
|
||||
val profileImageHash: String?,
|
||||
val loginTag: String = "",
|
||||
val profileId: String = "", // MIB profile ID; empty for BML accounts
|
||||
val internalId: String = "" // BML internal UUID; empty for MIB accounts
|
||||
)
|
||||
|
||||
data class MibTransferResult(
|
||||
val success: Boolean,
|
||||
@@ -46,27 +38,6 @@ data class MibTransferResult(
|
||||
val errorMessage: String = ""
|
||||
)
|
||||
|
||||
data class MibBeneficiaryCategory(
|
||||
val id: String,
|
||||
val categoryName: String,
|
||||
val numBenef: Int
|
||||
)
|
||||
|
||||
data class MibBeneficiary(
|
||||
val benefNo: String,
|
||||
val benefName: String,
|
||||
val benefNickName: String,
|
||||
val benefAccount: String,
|
||||
val benefType: String, // L=Local, I=Internal(MIB), S=Swift
|
||||
val bankColor: String,
|
||||
val benefBankName: String,
|
||||
val bankCode: String,
|
||||
val benefStatus: String,
|
||||
val transferCyDesc: String,
|
||||
val customerImgHash: String?,
|
||||
val benefCategoryId: String, // "0" = uncategorized
|
||||
val profileId: String = "" // MIB profile ID; empty for BML contacts
|
||||
)
|
||||
|
||||
data class MibIpsAccountInfo(
|
||||
val accountName: String,
|
||||
@@ -74,18 +45,17 @@ data class MibIpsAccountInfo(
|
||||
val bankId: String
|
||||
)
|
||||
|
||||
data class Transaction(
|
||||
val id: String,
|
||||
val date: String, // "YYYY-MM-DD HH:mm:ss" for MIB, ISO8601 for BML
|
||||
val description: String,
|
||||
val amount: Double, // negative = debit, positive = credit
|
||||
val currency: String,
|
||||
val counterpartyName: String?,
|
||||
val reference: String?,
|
||||
val accountNumber: String,
|
||||
val accountDisplayName: String,
|
||||
val source: String, // "MIB", "BML", "BML_CARD", "FAHIPAY"
|
||||
val iconUrl: String? = null // merchant icon URL (Fahipay only)
|
||||
|
||||
data class MibCard(
|
||||
val cardId: String,
|
||||
val maskedCardNumber: String,
|
||||
val cardStatus: String,
|
||||
val cardType: String,
|
||||
val cardTypeDesc: String,
|
||||
val customerId: String,
|
||||
val phoneNumber: String,
|
||||
val cardHolderName: String,
|
||||
val loginTag: String
|
||||
)
|
||||
|
||||
data class MibFinanceDeal(
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class MibPersonalProfile(
|
||||
val fullName: String,
|
||||
val username: String,
|
||||
val email: String,
|
||||
val mobile: String,
|
||||
val enrolled: String
|
||||
)
|
||||
|
||||
class MibProfileClient {
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
fun fetchPersonalProfile(session: MibSession): MibPersonalProfile? {
|
||||
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
|
||||
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
|
||||
val request = Request.Builder()
|
||||
.url("https://faisamobilex-wv.mib.com.mv/personalProfile")
|
||||
.get()
|
||||
.header("Cookie", cookieHeader)
|
||||
.build()
|
||||
return try {
|
||||
val resp = client.newCall(request).execute()
|
||||
val html = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
fun scrape(label: String): String {
|
||||
val r = Regex("""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
|
||||
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE))
|
||||
return r.find(html)?.groupValues?.get(1)?.trim() ?: ""
|
||||
}
|
||||
val nameRegex = Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
|
||||
val fullName = nameRegex.find(html)?.groupValues?.get(1)?.trim() ?: return null
|
||||
MibPersonalProfile(
|
||||
fullName = fullName,
|
||||
username = scrape("Username:"),
|
||||
email = scrape("Email:"),
|
||||
mobile = scrape("Mobile no:"),
|
||||
enrolled = scrape("Enrolled:")
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import android.os.Build
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@@ -26,7 +27,7 @@ class MibTransferClient {
|
||||
.header("Cookie", cookieHeader(session))
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
)
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("Accept", "*/*")
|
||||
@@ -68,6 +69,7 @@ class MibTransferClient {
|
||||
.withWvHeaders(session)
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
if (response.code == 419) throw SessionExpiredException()
|
||||
val bodyStr = response.body?.string() ?: ""
|
||||
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
|
||||
if (json == null || !json.optBoolean("success")) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package sh.sar.basedbank.api.models
|
||||
|
||||
/** Thrown by a bank API client when the server returns an HTTP 5xx response. */
|
||||
class BankServerException(val bankName: String) : Exception("Server error from $bankName")
|
||||
|
||||
/**
|
||||
* Unified account model used across all banks (MIB, BML, Fahipay, ...).
|
||||
* The [bank] field identifies which bank owns this account.
|
||||
*/
|
||||
data class BankAccount(
|
||||
val bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow
|
||||
val profileName: String,
|
||||
val profileType: String,
|
||||
val productCode: String = "", // bank-specific product/subtype code: MIB: CIF type label ("Individual", "Sole Propr"); BML: card product code ("C8201", "C1007")
|
||||
val accountNumber: String,
|
||||
val accountBriefName: String,
|
||||
val currencyName: String,
|
||||
val accountTypeName: String,
|
||||
val availableBalance: String,
|
||||
val currentBalance: String,
|
||||
val blockedAmount: String,
|
||||
val mvrBalance: String,
|
||||
val statusDesc: String,
|
||||
val profileImageHash: String?,
|
||||
val loginTag: String = "",
|
||||
val profileId: String = "", // profile ID used by the bank; empty if not applicable
|
||||
val internalId: String = "" // bank-internal UUID or ID; empty if not applicable
|
||||
)
|
||||
|
||||
/**
|
||||
* Unified contact/beneficiary model used across all banks.
|
||||
* Each bank may interpret fields differently; see per-bank notes below.
|
||||
*/
|
||||
data class BankContact(
|
||||
val benefNo: String,
|
||||
val benefName: String,
|
||||
val benefNickName: String,
|
||||
val benefAccount: String,
|
||||
val benefType: String, // MIB: L=Local, I=Internal, S=Swift; BML: "I"; Fahipay: "FAHIPAY"
|
||||
val bankColor: String,
|
||||
val benefBankName: String,
|
||||
val bankCode: String,
|
||||
val benefStatus: String,
|
||||
val transferCyDesc: String,
|
||||
val customerImgHash: String?,
|
||||
val benefCategoryId: String, // MIB: numeric category ID or "0"; BML: "BML"; Fahipay: "FAHIPAY"
|
||||
val profileId: String = "" // owning profile ID; empty where not applicable
|
||||
)
|
||||
|
||||
/**
|
||||
* Contact category (group) used across MIB and Fahipay.
|
||||
*/
|
||||
data class BankContactCategory(
|
||||
val id: String,
|
||||
val categoryName: String,
|
||||
val numBenef: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Unified transaction model used across all banks.
|
||||
* [source] identifies the originating bank/account type.
|
||||
*/
|
||||
data class BankTransaction(
|
||||
val id: String,
|
||||
val date: String, // "YYYY-MM-DD HH:mm:ss" (MIB/BML normalised) or ISO8601
|
||||
val description: String,
|
||||
val amount: Double, // negative = debit, positive = credit
|
||||
val currency: String,
|
||||
val counterpartyName: String?,
|
||||
val reference: String?,
|
||||
val accountNumber: String,
|
||||
val accountDisplayName: String,
|
||||
val source: String, // "MIB", "BML", "BML_CARD", "FAHIPAY"
|
||||
val iconUrl: String? = null // merchant icon URL (Fahipay only)
|
||||
)
|
||||
@@ -0,0 +1,185 @@
|
||||
package sh.sar.basedbank.nfc
|
||||
|
||||
import android.content.Intent
|
||||
import android.nfc.cardemulation.HostApduService
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import sh.sar.basedbank.api.bml.BmlWalletToken
|
||||
|
||||
/**
|
||||
* HCE service that emulates a BML contactless payment card.
|
||||
*
|
||||
* Implements the minimal EMV mag-stripe contactless flow:
|
||||
* SELECT PPSE → SELECT AID → GET PROCESSING OPTIONS → READ RECORD
|
||||
*
|
||||
* Each BmlWalletToken is single-use and is set via [setToken] before tapping.
|
||||
* After READ RECORD is sent the [onTransactionComplete] callback fires.
|
||||
*/
|
||||
class BmlHostCardEmulatorService : HostApduService() {
|
||||
|
||||
private var gpoSent = false
|
||||
|
||||
override fun processCommandApdu(commandApdu: ByteArray?, extras: Bundle?): ByteArray {
|
||||
if (commandApdu == null) return SW_UNKNOWN_ERROR
|
||||
val apdu = Apdu(commandApdu)
|
||||
if (apdu.isError) return apdu.errorResponse()
|
||||
|
||||
return when (apdu.ins) {
|
||||
INS_SELECT -> handleSelect(apdu)
|
||||
INS_GPO -> handleGpo()
|
||||
INS_READ -> handleReadRecord()
|
||||
else -> SW_INS_NOT_SUPPORTED
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDeactivated(reason: Int) {
|
||||
if (!gpoSent) onTransactionComplete?.invoke(false)
|
||||
gpoSent = false
|
||||
}
|
||||
|
||||
// ── APDU handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
private fun handleSelect(apdu: Apdu): ByteArray {
|
||||
val data = apdu.data ?: return SW_UNKNOWN_ERROR
|
||||
|
||||
if (data.contentEquals(PPSE_BYTES)) {
|
||||
val token = activeToken ?: run { launchPromptActivity(); return SW_UNKNOWN_ERROR }
|
||||
return hexToBytes(buildSelectPpseResponse(token.appCode, applicationLabel(token.appCode), "01"))
|
||||
}
|
||||
|
||||
val token = activeToken ?: return SW_UNKNOWN_ERROR
|
||||
return if (data.contentEquals(hexToBytes(token.appCode))) {
|
||||
hexToBytes(buildSelectAidResponse(token.appCode, applicationLabel(token.appCode)))
|
||||
} else {
|
||||
SW_UNKNOWN_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchPromptActivity() {
|
||||
val intent = Intent(applicationContext, BmlTapToPayActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun handleGpo(): ByteArray {
|
||||
gpoSent = true
|
||||
// AIP=0080 (mag-stripe mode), AFL=08010100 (SFI=1, record 1-1, offline 0)
|
||||
val miscData = "008008010100"
|
||||
val body = tlv("80", miscData)
|
||||
return hexToBytes(body + SW_OK_HEX)
|
||||
}
|
||||
|
||||
private fun handleReadRecord(): ByteArray {
|
||||
val token = activeToken ?: return SW_UNKNOWN_ERROR
|
||||
val track2 = buildTrack2(token)
|
||||
val body = tlv("70", tlv("57", track2))
|
||||
val response = hexToBytes(body + SW_OK_HEX)
|
||||
onTransactionComplete?.invoke(true)
|
||||
return response
|
||||
}
|
||||
|
||||
// ── TLV / APDU response builders ───────────────────────────────────────────
|
||||
|
||||
private fun buildSelectPpseResponse(aid: String, label: String, priority: String): String {
|
||||
val priorityTlv = tlv("87", priority) // tag 87
|
||||
val aidTlv = tlv("4F", aid) // tag 4F (ADF Name)
|
||||
val appEntry = tlv("61", aidTlv + priorityTlv) // tag 61
|
||||
val ppseTlv = tlv("84", PPSE_HEX) // tag 84 (DF Name)
|
||||
val inner = tlv("BF0C", appEntry) // tag BF0C
|
||||
val propTemplate = tlv("A5", inner) // tag A5
|
||||
val fci = tlv("6F", ppseTlv + propTemplate) // tag 6F
|
||||
return fci + SW_OK_HEX
|
||||
}
|
||||
|
||||
private fun buildSelectAidResponse(aid: String, label: String): String {
|
||||
val aidTlv = tlv("84", aid) // tag 84
|
||||
val labelTlv = tlv("50", asciiToHex(label)) // tag 50
|
||||
val pdolTlv = tlv("9F38", "9F6602") // PDOL: TTQ 2 bytes
|
||||
val propTemplate = tlv("A5", labelTlv + pdolTlv) // tag A5
|
||||
val fci = tlv("6F", aidTlv + propTemplate) // tag 6F
|
||||
return fci + SW_OK_HEX
|
||||
}
|
||||
|
||||
private fun buildTrack2(token: BmlWalletToken): String {
|
||||
var t2 = "${token.token}D${token.expiry}${token.serviceCode}${token.data}"
|
||||
if (t2.length % 2 != 0) t2 += "F"
|
||||
return t2
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Build BER-TLV: tag (hex string, 1 or 2 bytes) + DER length + data (hex string). */
|
||||
private fun tlv(tagHex: String, dataHex: String): String {
|
||||
val lenBytes = dataHex.length / 2
|
||||
val lenHex = when {
|
||||
lenBytes <= 0x7F -> lenBytes.toHexByte()
|
||||
lenBytes <= 0xFF -> "81" + lenBytes.toHexByte()
|
||||
else -> "82" + (lenBytes shr 8).toHexByte() + (lenBytes and 0xFF).toHexByte()
|
||||
}
|
||||
return tagHex + lenHex + dataHex
|
||||
}
|
||||
|
||||
private fun Int.toHexByte(): String = toString(16).padStart(2, '0').uppercase()
|
||||
|
||||
private fun asciiToHex(s: String): String =
|
||||
s.toByteArray(Charsets.US_ASCII).joinToString("") { "%02X".format(it) }
|
||||
|
||||
private fun hexToBytes(hex: String): ByteArray {
|
||||
val s = hex.uppercase()
|
||||
return ByteArray(s.length / 2) { i ->
|
||||
s.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||
}
|
||||
}
|
||||
|
||||
// ── APDU parser ─────────────────────────────────────────────────────────────
|
||||
|
||||
private inner class Apdu(raw: ByteArray) {
|
||||
val isError: Boolean
|
||||
val ins: Int
|
||||
val data: ByteArray?
|
||||
|
||||
init {
|
||||
if (raw.size < 5) {
|
||||
isError = true; ins = -1; data = null
|
||||
} else {
|
||||
isError = false
|
||||
ins = raw[1].toInt() and 0xFF
|
||||
val lc = if (raw.size > 4) raw[4].toInt() and 0xFF else 0
|
||||
data = if (lc > 0 && raw.size >= 5 + lc) raw.copyOfRange(5, 5 + lc) else null
|
||||
}
|
||||
}
|
||||
|
||||
fun errorResponse() = SW_UNKNOWN_ERROR
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BmlHCE"
|
||||
|
||||
private const val INS_SELECT = 0xA4
|
||||
private const val INS_GPO = 0xA8
|
||||
private const val INS_READ = 0xB2
|
||||
|
||||
private val PPSE_HEX = "325041592E5359532E4444463031" // "2PAY.SYS.DDF01"
|
||||
private val PPSE_BYTES = byteArrayOf(
|
||||
0x32,0x50,0x41,0x59,0x2E,0x53,0x59,0x53,0x2E,0x44,0x44,0x46,0x30,0x31
|
||||
)
|
||||
|
||||
private const val SW_OK_HEX = "9000"
|
||||
private val SW_UNKNOWN_ERROR = byteArrayOf(0x6F.toByte(), 0x00.toByte())
|
||||
private val SW_INS_NOT_SUPPORTED = byteArrayOf(0x6D.toByte(), 0x00.toByte())
|
||||
|
||||
@Volatile var activeToken: BmlWalletToken? = null
|
||||
@Volatile var onTransactionComplete: ((success: Boolean) -> Unit)? = null
|
||||
|
||||
fun setToken(token: BmlWalletToken) { activeToken = token }
|
||||
fun clearToken() { activeToken = null }
|
||||
|
||||
fun applicationLabel(aidHex: String): String = when {
|
||||
aidHex.startsWith("A0000000031010", ignoreCase = true) -> "VISA"
|
||||
aidHex.startsWith("A0000000041010", ignoreCase = true) -> "MASTERCARD"
|
||||
aidHex.startsWith("A000000025", ignoreCase = true) -> "AMEX"
|
||||
else -> "BML"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package sh.sar.basedbank.nfc
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import sh.sar.basedbank.MainActivity
|
||||
|
||||
/**
|
||||
* Fallback entry point — redirects to MainActivity which routes to the in-app tap-to-pay screen.
|
||||
*/
|
||||
class BmlTapToPayActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
startActivity(Intent(this, MainActivity::class.java).apply {
|
||||
action = "sh.sar.basedbank.TAP_TO_PAY"
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
})
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import sh.sar.basedbank.util.AccountHistoryDisplay
|
||||
import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding
|
||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||
@@ -21,28 +21,47 @@ import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class AccountHistoryAdapter(
|
||||
private val account: MibAccount,
|
||||
private val account: BankAccount,
|
||||
private val display: AccountHistoryDisplay
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private sealed class Item {
|
||||
data class DateHeader(val label: String) : Item()
|
||||
data class Trx(val transaction: Transaction) : Item()
|
||||
data class Trx(val transaction: BankTransaction, val showDate: Boolean = false) : Item()
|
||||
}
|
||||
|
||||
private val pendingItems = mutableListOf<Item>()
|
||||
private val displayItems = mutableListOf<Item>()
|
||||
private var lastInsertedDateKey = ""
|
||||
private val imageCache = mutableMapOf<String, Bitmap>()
|
||||
private val iconUrlCache = mutableMapOf<String, Bitmap>()
|
||||
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
||||
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
||||
var onTransferClick: ((MibAccount) -> Unit)? = null
|
||||
var onTransferClick: ((BankAccount) -> Unit)? = null
|
||||
var onDefaultToggle: ((Boolean) -> Unit)? = null
|
||||
private var hideAmounts: Boolean = false
|
||||
var showDefaultToggle: Boolean = false
|
||||
set(value) { if (field == value) return; field = value; notifyItemChanged(0) }
|
||||
var isDefaultAccount: Boolean = false
|
||||
set(value) { if (field == value) return; field = value; notifyItemChanged(0) }
|
||||
|
||||
fun setHideAmounts(hide: Boolean) {
|
||||
if (hideAmounts == hide) return
|
||||
hideAmounts = hide
|
||||
notifyItemChanged(0) // refresh header card
|
||||
for (i in pendingItems.indices) {
|
||||
if (pendingItems[i] is Item.Trx) notifyItemChanged(i + 1)
|
||||
}
|
||||
for (i in displayItems.indices) {
|
||||
if (displayItems[i] is Item.Trx) notifyItemChanged(1 + pendingItems.size + i)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
||||
imageCache[counterpartyName] = bitmap
|
||||
displayItems.forEachIndexed { i, item ->
|
||||
if (item is Item.Trx && item.transaction.counterpartyName == counterpartyName)
|
||||
notifyItemChanged(i + 1) // +1 for account header at position 0
|
||||
notifyItemChanged(1 + pendingItems.size + i)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,10 +69,19 @@ class AccountHistoryAdapter(
|
||||
iconUrlCache[url] = bitmap
|
||||
displayItems.forEachIndexed { i, item ->
|
||||
if (item is Item.Trx && item.transaction.iconUrl == url)
|
||||
notifyItemChanged(i + 1) // +1 for account header at position 0
|
||||
notifyItemChanged(1 + pendingItems.size + i)
|
||||
}
|
||||
}
|
||||
|
||||
fun setPendingTransactions(transactions: List<BankTransaction>) {
|
||||
pendingItems.clear()
|
||||
if (transactions.isNotEmpty()) {
|
||||
pendingItems.add(Item.DateHeader("Pending"))
|
||||
for (trx in transactions) pendingItems.add(Item.Trx(trx, showDate = true))
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private var _showLoadingFooter = false
|
||||
var showLoadingFooter: Boolean
|
||||
get() = _showLoadingFooter
|
||||
@@ -73,7 +101,7 @@ class AccountHistoryAdapter(
|
||||
* Display the given (already sorted + filtered) list with date group headers.
|
||||
* Silently resets the loading footer so notifyDataSetChanged covers everything.
|
||||
*/
|
||||
fun setTransactions(transactions: List<Transaction>) {
|
||||
fun setTransactions(transactions: List<BankTransaction>) {
|
||||
_showLoadingFooter = false
|
||||
displayItems.clear()
|
||||
lastInsertedDateKey = ""
|
||||
@@ -94,7 +122,7 @@ class AccountHistoryAdapter(
|
||||
* Appends [newTransactions] (assumed to be older than all existing items) using incremental
|
||||
* notifications, so the RecyclerView doesn't reset scroll position.
|
||||
*/
|
||||
fun appendTransactions(newTransactions: List<Transaction>) {
|
||||
fun appendTransactions(newTransactions: List<BankTransaction>) {
|
||||
if (newTransactions.isEmpty()) return
|
||||
if (_showLoadingFooter) {
|
||||
val pos = itemCount - 1
|
||||
@@ -111,18 +139,24 @@ class AccountHistoryAdapter(
|
||||
displayItems.add(Item.Trx(trx))
|
||||
}
|
||||
val added = displayItems.size - oldCount
|
||||
if (added > 0) notifyItemRangeInserted(1 + oldCount, added) // +1 for account header
|
||||
if (added > 0) notifyItemRangeInserted(1 + pendingItems.size + oldCount, added)
|
||||
}
|
||||
|
||||
// Position 0 = account header card
|
||||
// Positions 1..displayItems.size = date headers + transactions
|
||||
// Positions 1..pendingItems.size = pending header + pending transactions
|
||||
// Positions 1+pendingItems.size..1+pendingItems.size+displayItems.size = date headers + transactions
|
||||
// Last position = loading footer when showLoadingFooter = true
|
||||
override fun getItemCount() = 1 + displayItems.size + if (_showLoadingFooter) 1 else 0
|
||||
override fun getItemCount() = 1 + pendingItems.size + displayItems.size + if (_showLoadingFooter) 1 else 0
|
||||
|
||||
private fun itemAt(position: Int): Item {
|
||||
val idx = position - 1
|
||||
return if (idx < pendingItems.size) pendingItems[idx] else displayItems[idx - pendingItems.size]
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int) = when {
|
||||
position == 0 -> TYPE_HEADER
|
||||
_showLoadingFooter && position == itemCount - 1 -> TYPE_LOADING
|
||||
else -> when (displayItems[position - 1]) {
|
||||
else -> when (itemAt(position)) {
|
||||
is Item.DateHeader -> TYPE_DATE_HEADER
|
||||
is Item.Trx -> TYPE_TRANSACTION
|
||||
}
|
||||
@@ -141,8 +175,11 @@ class AccountHistoryAdapter(
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is HeaderVH -> holder.bind(display)
|
||||
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label)
|
||||
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction)
|
||||
is DateHeaderVH -> holder.bind((itemAt(position) as Item.DateHeader).label)
|
||||
is TransactionVH -> {
|
||||
val item = itemAt(position) as Item.Trx
|
||||
holder.bind(item.transaction, item.showDate)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
@@ -154,15 +191,29 @@ class AccountHistoryAdapter(
|
||||
b.tvHeaderAccountNumber.text = d.number
|
||||
b.tvHeaderPillBank.text = d.bankPill
|
||||
b.tvHeaderPillType.text = d.typeLabel
|
||||
b.tvHeaderAvailable.text = d.availableBalance
|
||||
b.tvHeaderBalance.text = d.workingBalance
|
||||
b.tvHeaderAvailable.text = if (hideAmounts) maskAmount(d.availableBalance) else d.availableBalance
|
||||
b.tvHeaderBalance.text = if (hideAmounts) maskAmount(d.workingBalance) else d.workingBalance
|
||||
if (d.blockedBalance != null) {
|
||||
b.tvHeaderBlocked.text = d.blockedBalance
|
||||
b.tvHeaderBlocked.text = if (hideAmounts) maskAmount(d.blockedBalance) else d.blockedBalance
|
||||
b.llHeaderBlocked.visibility = View.VISIBLE
|
||||
} else {
|
||||
b.llHeaderBlocked.visibility = View.GONE
|
||||
}
|
||||
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||
|
||||
if (showDefaultToggle) {
|
||||
b.dividerDefaultAccount.visibility = View.VISIBLE
|
||||
b.llDefaultAccountRow.visibility = View.VISIBLE
|
||||
b.switchDefaultAccount.setOnCheckedChangeListener(null)
|
||||
b.switchDefaultAccount.isChecked = isDefaultAccount
|
||||
b.switchDefaultAccount.setOnCheckedChangeListener { _, checked ->
|
||||
isDefaultAccount = checked
|
||||
onDefaultToggle?.invoke(checked)
|
||||
}
|
||||
} else {
|
||||
b.dividerDefaultAccount.visibility = View.GONE
|
||||
b.llDefaultAccountRow.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +224,7 @@ class AccountHistoryAdapter(
|
||||
|
||||
inner class TransactionVH(private val b: ItemTransactionBinding) :
|
||||
RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(trx: Transaction) {
|
||||
fun bind(trx: BankTransaction, showDate: Boolean = false) {
|
||||
val isCredit = trx.amount >= 0
|
||||
val color = sourceColor(trx.source)
|
||||
val name = trx.counterpartyName ?: trx.description
|
||||
@@ -209,19 +260,24 @@ class AccountHistoryAdapter(
|
||||
b.tvCounterparty.visibility = View.GONE
|
||||
}
|
||||
|
||||
b.tvDate.text = formatTime(trx.date)
|
||||
b.tvDate.text = if (showDate) formatDateOnly(trx.date) else formatTime(trx.date)
|
||||
|
||||
val sign = if (isCredit) "+" else "-"
|
||||
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
||||
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
||||
b.tvAmount.setTextColor(
|
||||
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
||||
)
|
||||
if (hideAmounts) {
|
||||
b.tvAmount.text = "${trx.currency} ••••••"
|
||||
b.tvAmount.setTextColor(Color.parseColor("#888888"))
|
||||
} else {
|
||||
val sign = if (isCredit) "+" else "-"
|
||||
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
||||
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
||||
b.tvAmount.setTextColor(
|
||||
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
||||
)
|
||||
}
|
||||
|
||||
b.root.setOnClickListener { showDetail(trx) }
|
||||
}
|
||||
|
||||
private fun showDetail(trx: Transaction) {
|
||||
private fun showDetail(trx: BankTransaction) {
|
||||
val ctx = b.root.context
|
||||
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
|
||||
val details = buildString {
|
||||
@@ -251,6 +307,7 @@ class AccountHistoryAdapter(
|
||||
private val MIB_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
private val BML_FMT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US)
|
||||
private val DATE_HEADER_FMT = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault())
|
||||
private val DATE_ONLY_FMT = SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
|
||||
private val TIME_FMT = SimpleDateFormat("h:mm a", Locale.getDefault())
|
||||
private val FULL_DATE_FMT = SimpleDateFormat("MMM d, yyyy · h:mm a", Locale.getDefault())
|
||||
|
||||
@@ -272,6 +329,11 @@ class AccountHistoryAdapter(
|
||||
return DATE_HEADER_FMT.format(date)
|
||||
}
|
||||
|
||||
fun formatDateOnly(raw: String): String {
|
||||
val date = parseDate(raw) ?: return raw.take(10)
|
||||
return DATE_ONLY_FMT.format(date)
|
||||
}
|
||||
|
||||
fun formatTime(raw: String): String {
|
||||
val date = parseDate(raw) ?: return ""
|
||||
return TIME_FMT.format(date)
|
||||
@@ -282,6 +344,11 @@ class AccountHistoryAdapter(
|
||||
return FULL_DATE_FMT.format(date)
|
||||
}
|
||||
|
||||
fun maskAmount(formatted: String): String {
|
||||
val currency = formatted.substringBefore(' ', formatted)
|
||||
return "$currency ••••••"
|
||||
}
|
||||
|
||||
fun sourceColor(source: String) = when (source) {
|
||||
"MIB" -> "#FE860E"
|
||||
"BML", "BML_CARD" -> "#0066A1"
|
||||
|
||||
@@ -22,13 +22,17 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import sh.sar.basedbank.api.mib.TransactionCache
|
||||
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
|
||||
import sh.sar.basedbank.util.AccountHistoryParser
|
||||
import sh.sar.basedbank.util.AccountListParser
|
||||
import sh.sar.basedbank.util.ContactImageCache
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.HistoryFetcher
|
||||
import sh.sar.basedbank.util.MerchantIconCache
|
||||
|
||||
@@ -39,10 +43,10 @@ class AccountHistoryFragment : Fragment() {
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
private lateinit var adapter: AccountHistoryAdapter
|
||||
private lateinit var account: MibAccount
|
||||
private lateinit var account: BankAccount
|
||||
private lateinit var fetcher: HistoryFetcher
|
||||
|
||||
private val allTransactions = mutableListOf<Transaction>()
|
||||
private val allTransactions = mutableListOf<BankTransaction>()
|
||||
private var searchQuery = ""
|
||||
private var firstPageDone = false
|
||||
private val pendingImageNames = mutableSetOf<String>()
|
||||
@@ -53,7 +57,7 @@ class AccountHistoryFragment : Fragment() {
|
||||
companion object {
|
||||
private const val ARG_ACCOUNT_NUMBER = "account_number"
|
||||
|
||||
fun newInstance(account: MibAccount) = AccountHistoryFragment().apply {
|
||||
fun newInstance(account: BankAccount) = AccountHistoryFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putString(ARG_ACCOUNT_NUMBER, account.accountNumber)
|
||||
}
|
||||
@@ -77,6 +81,25 @@ class AccountHistoryFragment : Fragment() {
|
||||
adapter.onTransferClick = { acc ->
|
||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(acc))
|
||||
}
|
||||
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||
|
||||
// Show default account toggle only for non-card accounts
|
||||
val isCard = AccountListParser.from(account)?.isCard ?: false
|
||||
if (!isCard) {
|
||||
val store = CredentialStore(requireContext())
|
||||
adapter.showDefaultToggle = true
|
||||
adapter.isDefaultAccount = store.getDefaultAccountNumber() == account.accountNumber
|
||||
adapter.onDefaultToggle = { isChecked ->
|
||||
if (isChecked) {
|
||||
store.setDefaultAccountNumber(account.accountNumber)
|
||||
} else {
|
||||
if (store.getDefaultAccountNumber() == account.accountNumber) {
|
||||
store.setDefaultAccountNumber(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
@@ -116,11 +139,25 @@ class AccountHistoryFragment : Fragment() {
|
||||
}
|
||||
(activity as? HomeActivity)?.setRefreshing(true)
|
||||
loadNextPage()
|
||||
loadPendingTransactions()
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
if (isLoading) {
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
} else {
|
||||
resetAndReload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (::account.isInitialized) requireActivity().title = account.accountBriefName
|
||||
if (::account.isInitialized) {
|
||||
requireActivity().title = account.accountBriefName
|
||||
if (adapter.showDefaultToggle) {
|
||||
adapter.isDefaultAccount = CredentialStore(requireContext()).getDefaultAccountNumber() == account.accountNumber
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterAndDisplay() {
|
||||
@@ -133,6 +170,25 @@ class AccountHistoryFragment : Fragment() {
|
||||
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun resetAndReload() {
|
||||
allTransactions.clear()
|
||||
pendingImageNames.clear()
|
||||
pendingIconUrls.clear()
|
||||
firstPageDone = false
|
||||
fetcher = HistoryFetcher(account)
|
||||
// Restore cache immediately so data stays visible while refreshing
|
||||
val cached = TransactionCache.load(requireContext(), account.accountNumber)
|
||||
if (cached.isNotEmpty()) {
|
||||
allTransactions.addAll(cached)
|
||||
filterAndDisplay()
|
||||
} else {
|
||||
adapter.setTransactions(emptyList())
|
||||
binding.emptyView.visibility = View.GONE
|
||||
}
|
||||
loadNextPage()
|
||||
loadPendingTransactions()
|
||||
}
|
||||
|
||||
private fun loadNextPage() {
|
||||
if (isLoading || !fetcher.hasMore()) return
|
||||
isLoading = true
|
||||
@@ -144,15 +200,34 @@ class AccountHistoryFragment : Fragment() {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
|
||||
lifecycleScope.launch {
|
||||
val transactions = fetcher.fetchNextPage(app, pageSize)
|
||||
val transactions = try {
|
||||
fetcher.fetchNextPage(app, pageSize)
|
||||
} catch (e: java.io.IOException) {
|
||||
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
|
||||
null
|
||||
} catch (e: BankServerException) {
|
||||
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_server_error, e.bankName))
|
||||
null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
if (_binding == null) return@launch
|
||||
|
||||
if (!firstPageDone) {
|
||||
firstPageDone = true
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
|
||||
if (transactions == null) {
|
||||
adapter.showLoadingFooter = false
|
||||
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
|
||||
return@launch
|
||||
}
|
||||
(activity as? HomeActivity)?.hideConnectivityBanner()
|
||||
|
||||
if (transactions.isNotEmpty()) {
|
||||
val existingIds = allTransactions.map { it.id }.toHashSet()
|
||||
val newOnes = transactions.filter { it.id !in existingIds }
|
||||
@@ -178,6 +253,26 @@ class AccountHistoryFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPendingTransactions() {
|
||||
if (account.bank != "BML" || account.profileType != "BML") return
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val session = app.bmlSessionFor(account) ?: return
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val pending = withContext(Dispatchers.IO) {
|
||||
BmlHistoryClient().fetchPendingHistory(
|
||||
session = session,
|
||||
accountId = account.internalId,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
accountNumber = account.accountNumber
|
||||
)
|
||||
}
|
||||
if (_binding == null) return@launch
|
||||
adapter.setPendingTransactions(pending)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadContactImage(name: String) {
|
||||
if (!pendingImageNames.add(name)) return
|
||||
val contact = viewModel.contacts.value?.firstOrNull { it.benefNickName == name } ?: return
|
||||
|
||||
@@ -3,12 +3,14 @@ package sh.sar.basedbank.ui.home
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.databinding.ItemAccountBinding
|
||||
import sh.sar.basedbank.databinding.ItemCardBinding
|
||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||
@@ -16,32 +18,43 @@ import sh.sar.basedbank.util.AccountListDisplay
|
||||
import sh.sar.basedbank.util.AccountListParser
|
||||
|
||||
class AccountsAdapter(
|
||||
accounts: List<MibAccount>,
|
||||
private val onAccountClick: (MibAccount) -> Unit = {}
|
||||
accounts: List<BankAccount>,
|
||||
private val onAccountClick: (BankAccount) -> Unit = {},
|
||||
/** Optional loader for MIB per-profile images: (hash, onLoaded) */
|
||||
private val profileImageLoader: ((String, (Bitmap) -> Unit) -> Unit)? = null,
|
||||
/** Optional loader for local (BML/Fahipay) profile images: (loginTag, profileId, onLoaded) */
|
||||
private val localProfileImageLoader: ((String, String, (Bitmap) -> Unit) -> Unit)? = null
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
var onTransferClick: ((MibAccount) -> Unit)? = null
|
||||
var onTransferClick: ((BankAccount) -> Unit)? = null
|
||||
private var hideAmounts: Boolean = false
|
||||
|
||||
private sealed class Item {
|
||||
data class SectionTitle(val label: String) : Item()
|
||||
data class Account(val account: MibAccount, val display: AccountListDisplay) : Item()
|
||||
data class Card(val account: MibAccount, val display: AccountListDisplay) : Item()
|
||||
data class Account(val account: BankAccount, val display: AccountListDisplay) : Item()
|
||||
data class Card(val account: BankAccount, val display: AccountListDisplay) : Item()
|
||||
}
|
||||
|
||||
private val items: MutableList<Item> = buildItems(accounts).toMutableList()
|
||||
|
||||
fun updateAccounts(accounts: List<MibAccount>) {
|
||||
fun updateAccounts(accounts: List<BankAccount>) {
|
||||
items.clear()
|
||||
items.addAll(buildItems(accounts))
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
|
||||
fun setHideAmounts(hide: Boolean) {
|
||||
if (hideAmounts == hide) return
|
||||
hideAmounts = hide
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private fun buildItems(accounts: List<BankAccount>): List<Item> = buildList {
|
||||
val displayed = accounts.mapNotNull { acc -> AccountListParser.from(acc)?.let { acc to it } }
|
||||
val nonCards = displayed.filter { !it.second.isCard }
|
||||
val cards = displayed.filter { it.second.isCard }
|
||||
|
||||
val groups = LinkedHashMap<String, MutableList<Pair<MibAccount, AccountListDisplay>>>()
|
||||
val groups = LinkedHashMap<String, MutableList<Pair<BankAccount, AccountListDisplay>>>()
|
||||
for ((acc, display) in nonCards) {
|
||||
val title = sectionTitle(acc)
|
||||
groups.getOrPut(title) { mutableListOf() }.add(acc to display)
|
||||
@@ -57,7 +70,7 @@ class AccountsAdapter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun sectionTitle(account: MibAccount): String {
|
||||
private fun sectionTitle(account: BankAccount): String {
|
||||
val bankName = when (account.bank) {
|
||||
"BML" -> "Bank of Maldives"
|
||||
"FAHIPAY" -> "Fahipay"
|
||||
@@ -65,7 +78,7 @@ class AccountsAdapter(
|
||||
else -> account.bank
|
||||
}
|
||||
val profileLabel = when (account.bank) {
|
||||
"MIB" -> account.cifType.ifBlank { account.profileName }
|
||||
"MIB" -> account.productCode.ifBlank { account.profileName }
|
||||
else -> account.profileName
|
||||
}
|
||||
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
|
||||
@@ -105,29 +118,63 @@ class AccountsAdapter(
|
||||
|
||||
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(account: MibAccount, display: AccountListDisplay) {
|
||||
private var boundHash: String? = null
|
||||
|
||||
fun bind(account: BankAccount, display: AccountListDisplay) {
|
||||
binding.tvAccountName.text = display.name
|
||||
binding.tvAccountNumber.text = display.number
|
||||
binding.tvAccountType.text = display.typeLabel
|
||||
binding.tvBalance.text = 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.root.setOnClickListener { onAccountClick(account) }
|
||||
binding.root.setOnLongClickListener {
|
||||
copyToClipboard(it.context, display.number)
|
||||
true
|
||||
}
|
||||
|
||||
val staticLogo = when (account.bank) {
|
||||
"BML" -> R.drawable.bml_logo_vector
|
||||
"FAHIPAY" -> R.drawable.fahipay_logo
|
||||
"MIB" -> R.drawable.mib_logo
|
||||
else -> null
|
||||
}
|
||||
if (staticLogo != null) binding.ivBankLogo.setImageResource(staticLogo)
|
||||
else binding.ivBankLogo.setImageDrawable(null)
|
||||
|
||||
val hash = account.profileImageHash
|
||||
boundHash = hash
|
||||
when {
|
||||
account.bank == "MIB" && hash != null && profileImageLoader != null -> {
|
||||
profileImageLoader.invoke(hash) { bitmap ->
|
||||
if (boundHash == hash) binding.ivBankLogo.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
(account.bank == "BML" || account.bank == "FAHIPAY") && localProfileImageLoader != null -> {
|
||||
localProfileImageLoader.invoke(account.loginTag, account.profileId) { bitmap ->
|
||||
if (boundHash == hash) binding.ivBankLogo.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class CardViewHolder(private val binding: ItemCardBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(account: MibAccount, display: AccountListDisplay) {
|
||||
fun bind(account: BankAccount, display: AccountListDisplay) {
|
||||
binding.ivCardBrand.setImageResource(display.cardBrandIcon)
|
||||
binding.tvCardName.text = display.name
|
||||
binding.tvCardNumber.text = display.number
|
||||
binding.tvCardProduct.text = display.typeLabel
|
||||
binding.layoutCardBalance.visibility = View.VISIBLE
|
||||
binding.tvCardBalance.text = display.balance
|
||||
binding.tvCardBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
|
||||
if (display.statusLabel != null) {
|
||||
binding.tvCardStatus.text = display.statusLabel
|
||||
binding.tvCardStatus.visibility = View.VISIBLE
|
||||
@@ -146,6 +193,11 @@ class AccountsAdapter(
|
||||
private const val TYPE_ACCOUNT = 1
|
||||
private const val TYPE_CARD = 2
|
||||
|
||||
fun maskAmount(formatted: String): String {
|
||||
val currency = formatted.substringBefore(' ', formatted)
|
||||
return "$currency ••••••"
|
||||
}
|
||||
|
||||
private fun copyToClipboard(context: Context, accountNumber: String) {
|
||||
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber))
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -8,9 +11,15 @@ import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentAccountsBinding
|
||||
import sh.sar.basedbank.util.ProfileImageStore
|
||||
|
||||
class AccountsFragment : Fragment() {
|
||||
|
||||
@@ -18,6 +27,7 @@ class AccountsFragment : Fragment() {
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
private lateinit var adapter: AccountsAdapter
|
||||
private val profileImageCache = mutableMapOf<String, Bitmap>()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentAccountsBinding.inflate(inflater, container, false)
|
||||
@@ -25,9 +35,49 @@ class AccountsFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
adapter = AccountsAdapter(emptyList()) { account ->
|
||||
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
|
||||
}
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
adapter = AccountsAdapter(
|
||||
accounts = emptyList(),
|
||||
onAccountClick = { account ->
|
||||
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
|
||||
},
|
||||
profileImageLoader = { hash, onLoaded ->
|
||||
profileImageCache[hash]?.let { onLoaded(it); return@AccountsAdapter }
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val session = app.anyMibSession() ?: return@withContext null
|
||||
val b64 = app.anyMibFlow()?.fetchProfileImage(session, hash) ?: return@withContext null
|
||||
val bytes = Base64.decode(b64, Base64.DEFAULT)
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
if (bitmap != null) {
|
||||
profileImageCache[hash] = bitmap
|
||||
onLoaded(bitmap)
|
||||
}
|
||||
}
|
||||
},
|
||||
localProfileImageLoader = { loginTag, profileId, onLoaded ->
|
||||
val cacheKey = "$loginTag|$profileId"
|
||||
profileImageCache[cacheKey]?.let { onLoaded(it); return@AccountsAdapter }
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
val ctx = requireContext()
|
||||
if (loginTag.startsWith("bml_") && profileId.isNotBlank()) {
|
||||
ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(profileId))
|
||||
} else if (loginTag.startsWith("fahipay_")) {
|
||||
val loginId = ProfileImageStore.loginIdFromTag(loginTag)
|
||||
ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
|
||||
} else null
|
||||
}
|
||||
if (bitmap != null) {
|
||||
profileImageCache[cacheKey] = bitmap
|
||||
onLoaded(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
adapter.onTransferClick = { account ->
|
||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(account))
|
||||
}
|
||||
@@ -43,7 +93,16 @@ class AccountsFragment : Fragment() {
|
||||
insets
|
||||
}
|
||||
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
|
||||
viewModel.accounts.observe(viewLifecycleOwner) {
|
||||
adapter.updateAccounts(it)
|
||||
binding.emptyView.visibility = if (it.isEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||
import sh.sar.basedbank.databinding.ItemTransactionBinding
|
||||
import sh.sar.basedbank.util.ReceiptStore
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class ActivitiesAdapter(
|
||||
private val onItemClick: (ReceiptStore.Entry) -> Unit
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private sealed class Item {
|
||||
data class DateHeader(val label: String) : Item()
|
||||
data class ReceiptItem(val entry: ReceiptStore.Entry) : Item()
|
||||
}
|
||||
|
||||
private val displayItems = mutableListOf<Item>()
|
||||
|
||||
fun setEntries(entries: List<ReceiptStore.Entry>) {
|
||||
displayItems.clear()
|
||||
var lastDateKey = ""
|
||||
for (entry in entries) {
|
||||
val dateKey = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date(entry.savedAt))
|
||||
if (dateKey != lastDateKey) {
|
||||
displayItems.add(Item.DateHeader(formatDateHeader(entry.savedAt)))
|
||||
lastDateKey = dateKey
|
||||
}
|
||||
displayItems.add(Item.ReceiptItem(entry))
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getItemCount() = displayItems.size
|
||||
|
||||
override fun getItemViewType(position: Int) =
|
||||
if (displayItems[position] is Item.DateHeader) TYPE_DATE_HEADER else TYPE_RECEIPT
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
return if (viewType == TYPE_DATE_HEADER)
|
||||
DateHeaderVH(ItemDateHeaderBinding.inflate(inflater, parent, false))
|
||||
else
|
||||
ReceiptVH(ItemTransactionBinding.inflate(inflater, parent, false))
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is DateHeaderVH -> holder.bind((displayItems[position] as Item.DateHeader).label)
|
||||
is ReceiptVH -> holder.bind((displayItems[position] as Item.ReceiptItem).entry)
|
||||
}
|
||||
}
|
||||
|
||||
inner class DateHeaderVH(private val b: ItemDateHeaderBinding) :
|
||||
RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(label: String) { b.tvDateHeader.text = label }
|
||||
}
|
||||
|
||||
inner class ReceiptVH(private val b: ItemTransactionBinding) :
|
||||
RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(entry: ReceiptStore.Entry) {
|
||||
val d = entry.data
|
||||
val colorHex = d.fromColorHex.takeIf { it.isNotBlank() } ?: "#607D8B"
|
||||
val initial = d.toLabel.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
|
||||
b.fvAvatar.background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.OVAL
|
||||
setColor(try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY })
|
||||
}
|
||||
b.tvInitial.visibility = android.view.View.VISIBLE
|
||||
b.tvInitial.text = initial
|
||||
|
||||
b.tvCounterparty.text = d.toLabel
|
||||
b.tvCounterparty.visibility = android.view.View.VISIBLE
|
||||
b.tvDescription.text = buildString {
|
||||
append(d.fromLabel)
|
||||
if (d.toBank.isNotBlank()) append(" · ${d.toBank}")
|
||||
}
|
||||
b.tvDate.text = formatTime(entry.savedAt)
|
||||
|
||||
b.tvAmount.text = "- ${d.currency} ${d.amount}"
|
||||
b.tvAmount.setTextColor(Color.parseColor("#FF7043"))
|
||||
|
||||
b.root.setOnClickListener { onItemClick(entry) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDateHeader(millis: Long): String {
|
||||
val sdf = SimpleDateFormat("EEEE, d MMMM yyyy", Locale.US)
|
||||
return sdf.format(Date(millis))
|
||||
}
|
||||
|
||||
private fun formatTime(millis: Long): String {
|
||||
val sdf = SimpleDateFormat("HH:mm", Locale.US)
|
||||
return sdf.format(Date(millis))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TYPE_DATE_HEADER = 0
|
||||
private const val TYPE_RECEIPT = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentActivitiesBinding
|
||||
import sh.sar.basedbank.util.ReceiptStore
|
||||
|
||||
class ActivitiesFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentActivitiesBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private lateinit var adapter: ActivitiesAdapter
|
||||
private val allEntries = mutableListOf<ReceiptStore.Entry>()
|
||||
private var searchQuery = ""
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentActivitiesBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
adapter = ActivitiesAdapter { entry ->
|
||||
(activity as? HomeActivity)?.showWithBackStack(
|
||||
TransferReceiptFragment.newInstance(entry.data, null)
|
||||
)
|
||||
}
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||
val isBottomNav = requireContext()
|
||||
.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
.getBoolean("bottom_nav", false)
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||
insets
|
||||
}
|
||||
|
||||
binding.etSearch.addTextChangedListener(object : TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
searchQuery = s?.toString()?.trim() ?: ""
|
||||
filterAndDisplay()
|
||||
}
|
||||
})
|
||||
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = getString(R.string.nav_activities)
|
||||
// Reload in case a new receipt was added while we were away
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
private fun loadEntries() {
|
||||
allEntries.clear()
|
||||
allEntries.addAll(ReceiptStore.loadAll(requireContext()))
|
||||
filterAndDisplay()
|
||||
}
|
||||
|
||||
private fun filterAndDisplay() {
|
||||
val filtered = if (searchQuery.isBlank()) allEntries
|
||||
else allEntries.filter { entry ->
|
||||
entry.data.toLabel.contains(searchQuery, ignoreCase = true) ||
|
||||
entry.data.fromLabel.contains(searchQuery, ignoreCase = true) ||
|
||||
entry.data.toAccount.contains(searchQuery, ignoreCase = true) ||
|
||||
entry.data.toBank.contains(searchQuery, ignoreCase = true) ||
|
||||
entry.data.mibReferenceNo.contains(searchQuery, ignoreCase = true) ||
|
||||
entry.data.bmlReference.contains(searchQuery, ignoreCase = true)
|
||||
}
|
||||
adapter.setEntries(filtered)
|
||||
binding.emptyView.visibility = if (filtered.isEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -30,8 +30,9 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlAccountValidation
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
|
||||
import sh.sar.basedbank.api.bml.BmlContactsClient
|
||||
import sh.sar.basedbank.api.bml.BmlValidateClient
|
||||
import sh.sar.basedbank.api.models.BankContactCategory
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.MibProfile
|
||||
import sh.sar.basedbank.api.mib.MibTransferClient
|
||||
@@ -64,7 +65,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
private var selectedImageBase64: String = ""
|
||||
private var selectedCategoryId: String = "0"
|
||||
private var categories: List<MibBeneficiaryCategory> = emptyList()
|
||||
private var categories: List<BankContactCategory> = emptyList()
|
||||
|
||||
private val imagePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||
uri ?: return@registerForActivityResult
|
||||
@@ -98,10 +99,13 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
}
|
||||
}
|
||||
val store = CredentialStore(requireContext())
|
||||
for ((loginId, _) in app.bmlSessions) {
|
||||
val ownerName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() } ?: loginId
|
||||
val profileName = app.bmlAccounts.firstOrNull { it.loginTag == "bml_$loginId" }?.profileName ?: ""
|
||||
list.add(DestinationOption("BML · $ownerName", isBml = true, bmlLoginId = loginId, subtitle = profileName))
|
||||
for ((loginId, profiles) in app.bmlProfilesMap) {
|
||||
val fullName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
|
||||
for (profile in profiles) {
|
||||
if (app.bmlSessions.containsKey(profile.profileId)) {
|
||||
list.add(DestinationOption("BML · ${fullName ?: profile.name}", isBml = true, bmlLoginId = profile.profileId, subtitle = profile.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
@@ -241,14 +245,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
private fun lookupForBml(input: String): BmlAccountValidation? {
|
||||
val loginId = selectedDest?.bmlLoginId ?: return null
|
||||
val bmlSess = app.bmlSessions[loginId] ?: return null
|
||||
val bmlFlow = BmlLoginFlow()
|
||||
|
||||
// 1) Try BML validate
|
||||
val validated = try { bmlFlow.validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||
val validated = try { BmlValidateClient().validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||
if (validated != null) return validated
|
||||
|
||||
// 2) Try BML MIB verify
|
||||
val mibVerified = try { bmlFlow.verifyMibAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||
val mibVerified = try { BmlValidateClient().verifyMibAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||
if (mibVerified != null) return mibVerified
|
||||
|
||||
// 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML)
|
||||
@@ -292,7 +294,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
// MIB lookup failed (e.g. BML USD account) — fall back to BML validate
|
||||
val bmlSess = app.anyBmlSession() ?: return null
|
||||
return try { BmlLoginFlow().validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||
return try { BmlValidateClient().validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
private fun showLookupResult(validation: BmlAccountValidation, input: String) {
|
||||
@@ -418,15 +420,14 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
val loginId = selectedDest?.bmlLoginId ?: return false
|
||||
val bmlSess = app.bmlSessions[loginId] ?: return false
|
||||
val lookup = bmlLookup ?: return false
|
||||
val bmlFlow = BmlLoginFlow()
|
||||
val account = lookup.account
|
||||
return when {
|
||||
account.matches(Regex("^7\\d{12}$")) ->
|
||||
// BML account → IAT
|
||||
bmlFlow.saveContact(bmlSess, "IAT", account, alias)
|
||||
BmlContactsClient().saveContact(bmlSess, "IAT", account, alias)
|
||||
account.matches(Regex("^9\\d{16}$")) ->
|
||||
// MIB internal → DOT; swift is BML's internal UUID for MIB bank
|
||||
bmlFlow.saveContact(bmlSess, "DOT", account, alias,
|
||||
BmlContactsClient().saveContact(bmlSess, "DOT", account, alias,
|
||||
currency = lookup.currency, name = lookup.name, swift = MIB_SWIFT_ON_BML)
|
||||
else -> false
|
||||
}
|
||||
@@ -486,7 +487,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
if (dest.isBml) {
|
||||
val loginId = dest.bmlLoginId ?: return@launch
|
||||
val bmlSess = app.bmlSessions[loginId] ?: return@launch
|
||||
val fresh = BmlLoginFlow().fetchContacts(bmlSess, loginId)
|
||||
val fresh = BmlContactsClient().fetchContacts(bmlSess, loginId)
|
||||
val existing = viewModel.contacts.value ?: emptyList()
|
||||
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
|
||||
viewModel.contacts.postValue(merged)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
data class AppNotification(
|
||||
val id: String,
|
||||
val bank: String, // "BML" or "MIB"
|
||||
val loginId: String, // key in bmlSessions / mibSessions
|
||||
val group: String, // "ALERTS" or "INFORMATION"
|
||||
val title: String,
|
||||
val message: String,
|
||||
val timestampMs: Long,
|
||||
val isRead: Boolean,
|
||||
val detailFields: List<Pair<String, String>> = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,404 @@
|
||||
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.RecentPick
|
||||
import sh.sar.basedbank.util.RecentsCache
|
||||
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
|
||||
if (info.amount == 0.0) {
|
||||
val qrUrl = arguments?.getString(ARG_QR_URL)
|
||||
if (qrUrl != null) {
|
||||
RecentsCache.save(requireContext(), RecentPick(
|
||||
accountNumber = "bmlqr:$qrUrl",
|
||||
displayName = info.merchantName,
|
||||
subtitle = info.merchantAddress.ifBlank { "BML Merchant" },
|
||||
colorHex = "#0066A1",
|
||||
imageHash = null,
|
||||
isProfileImage = false
|
||||
))
|
||||
}
|
||||
}
|
||||
populateMerchant(info)
|
||||
}
|
||||
}
|
||||
|
||||
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) { _, _ ->
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
|
||||
val sizePx = (resources.displayMetrics.density * 40).toInt()
|
||||
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
|
||||
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bm)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
paint.color = bgColor
|
||||
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
||||
paint.color = Color.WHITE
|
||||
paint.textSize = sizePx * 0.42f
|
||||
paint.textAlign = Paint.Align.CENTER
|
||||
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
val metrics = paint.fontMetrics
|
||||
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
||||
return bm
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = "BML QR Pay"
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private inner class BmlAccountAdapter(private val accounts: List<BankAccount>) :
|
||||
BaseAdapter(), Filterable {
|
||||
|
||||
override fun getCount() = accounts.size
|
||||
override fun getItem(position: Int) = accounts[position]
|
||||
override fun getItemId(position: Int) = position.toLong()
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup) =
|
||||
getDropDownView(position, convertView, parent)
|
||||
|
||||
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val acc = accounts[position]
|
||||
val b = if (convertView?.tag is ItemAccountDropdownBinding) {
|
||||
convertView.tag as ItemAccountDropdownBinding
|
||||
} else {
|
||||
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
|
||||
.also { it.root.tag = it }
|
||||
}
|
||||
val ownerPrefix = if (acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
||||
b.tvDropdownAccountNumber.text = acc.accountNumber
|
||||
b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}"
|
||||
b.root.alpha = 1f
|
||||
return b.root
|
||||
}
|
||||
|
||||
override fun getFilter() = object : Filter() {
|
||||
override fun performFiltering(c: CharSequence?) =
|
||||
FilterResults().apply { values = accounts; count = accounts.size }
|
||||
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
|
||||
override fun convertResultToString(r: Any?) =
|
||||
(r as? BankAccount)?.let {
|
||||
val prefix = if (it.profileName.isNotBlank()) "${it.profileName} · " else ""
|
||||
"$prefix${it.accountBriefName}"
|
||||
} ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
// Merged into CardsFragment
|
||||
@@ -0,0 +1,561 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import android.os.Bundle
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.*
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.view.animation.LinearInterpolator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.Animator
|
||||
import android.widget.FrameLayout
|
||||
import android.graphics.Typeface
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import sh.sar.basedbank.R
|
||||
import kotlin.math.*
|
||||
|
||||
class CircularNavFragment : Fragment() {
|
||||
|
||||
private var wheelView: CircularWheelView? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val ctx = requireContext()
|
||||
val colorPrimary = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorPrimary, Color.RED)
|
||||
val colorSurface = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSurface, Color.WHITE)
|
||||
val colorOnSurface = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, Color.DKGRAY)
|
||||
|
||||
fun dp(v: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, ctx.resources.displayMetrics)
|
||||
|
||||
val root = android.widget.LinearLayout(ctx).apply {
|
||||
orientation = android.widget.LinearLayout.VERTICAL
|
||||
setBackgroundColor(colorSurface)
|
||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
}
|
||||
|
||||
// Wheel area (weight 1, fills remaining space)
|
||||
val wheelContainer = FrameLayout(ctx).apply {
|
||||
layoutParams = android.widget.LinearLayout.LayoutParams(
|
||||
android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f
|
||||
)
|
||||
}
|
||||
val prefs = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
wheelView = CircularWheelView(ctx).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
|
||||
wheelAngle = prefs.getFloat("circular_wheel_angle", 0f)
|
||||
val savedSlots = NavCustomization.getCircularSlots(prefs).map { id ->
|
||||
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == id }!!
|
||||
CircularWheelView.WheelItem(def.id, def.iconRes, ctx.getString(def.titleRes))
|
||||
}
|
||||
items = listOf(
|
||||
savedSlots[3], // 4 o'clock (strip slot 3)
|
||||
CircularWheelView.WheelItem(R.id.nav_dashboard, R.drawable.ic_nav_dashboard, ctx.getString(R.string.nav_dashboard)), // 6 o'clock
|
||||
CircularWheelView.WheelItem(R.id.nav_more, R.drawable.ic_nav_more, ctx.getString(R.string.nav_more)), // 8 o'clock
|
||||
savedSlots[0], // 10 o'clock (strip slot 0 — first in strip)
|
||||
savedSlots[1], // 12 o'clock (strip slot 1)
|
||||
savedSlots[2], // 2 o'clock (strip slot 2)
|
||||
)
|
||||
accentColor = colorPrimary
|
||||
surfaceColor = colorSurface
|
||||
labelColor = colorOnSurface
|
||||
onItemClick = { navId -> (activity as? HomeActivity)?.navigateTo(navId) }
|
||||
onCenterClick = { /* unused: tap on unlocked center locks the wheel */ }
|
||||
onWheelCenterLockedTap = { (activity as? HomeActivity)?.notifyWheelLockTap() }
|
||||
}
|
||||
wheelContainer.addView(wheelView)
|
||||
|
||||
// App icon centered at the bottom
|
||||
val iconSz = dp(48f).toInt()
|
||||
val footerIcon = android.widget.ImageView(ctx).apply {
|
||||
setImageDrawable(ctx.packageManager.getApplicationIcon(ctx.packageName))
|
||||
layoutParams = android.widget.LinearLayout.LayoutParams(iconSz, iconSz).also {
|
||||
it.gravity = Gravity.CENTER_HORIZONTAL
|
||||
it.topMargin = dp(12f).toInt()
|
||||
it.bottomMargin = dp(16f).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
root.addView(wheelContainer)
|
||||
root.addView(footerIcon)
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets ->
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
(footerIcon.layoutParams as android.widget.LinearLayout.LayoutParams).bottomMargin = dp(16f).toInt() + bars.bottom
|
||||
footerIcon.requestLayout()
|
||||
insets
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().invalidateOptionsMenu()
|
||||
val ctx = requireContext()
|
||||
val toolbar = requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar)
|
||||
requireActivity().title = ""
|
||||
|
||||
val textColor = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.DKGRAY)
|
||||
|
||||
val container = android.widget.TextView(ctx).apply {
|
||||
text = getString(R.string.app_name)
|
||||
setTextColor(textColor)
|
||||
textSize = 20f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
tag = "wheel_title"
|
||||
}
|
||||
|
||||
toolbar.addView(container, Toolbar.LayoutParams(
|
||||
Toolbar.LayoutParams.WRAP_CONTENT,
|
||||
Toolbar.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.CENTER
|
||||
))
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
wheelView?.let { wv ->
|
||||
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
.edit().putFloat("circular_wheel_angle", wv.wheelAngle).apply()
|
||||
}
|
||||
val toolbar = requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar)
|
||||
toolbar.findViewWithTag<android.view.View>("wheel_title")?.let { toolbar.removeView(it) }
|
||||
requireActivity().invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
fun unlockWheelLock() {
|
||||
wheelView?.unlockWheel()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom wheel view
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class CircularWheelView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : View(context, attrs) {
|
||||
|
||||
data class WheelItem(
|
||||
val navId: Int,
|
||||
@DrawableRes val iconRes: Int,
|
||||
val label: String
|
||||
)
|
||||
|
||||
// ---- public properties ------------------------------------------------
|
||||
|
||||
var items: List<WheelItem> = emptyList()
|
||||
set(value) {
|
||||
field = value
|
||||
iconBitmaps = arrayOfNulls(value.size)
|
||||
if (cx > 0f) reloadBitmaps()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
var accentColor: Int = Color.RED
|
||||
set(value) { field = value; if (cx > 0f) reloadBitmaps(); invalidate() }
|
||||
|
||||
var surfaceColor: Int = Color.WHITE
|
||||
set(value) { field = value; invalidate() }
|
||||
|
||||
var labelColor: Int = Color.DKGRAY
|
||||
set(value) { field = value; invalidate() }
|
||||
|
||||
var isWheelLocked = false
|
||||
set(value) { field = value; invalidate() }
|
||||
|
||||
var onItemClick: ((Int) -> Unit)? = null
|
||||
var onCenterClick: (() -> Unit)? = null
|
||||
var onWheelCenterLockedTap: (() -> Unit)? = null
|
||||
|
||||
// ---- geometry ---------------------------------------------------------
|
||||
|
||||
private var cx = 0f
|
||||
private var cy = 0f
|
||||
private var outerRadius = 0f
|
||||
private var innerRadius = 0f
|
||||
|
||||
// ---- paint ------------------------------------------------------------
|
||||
|
||||
private val discPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val accentRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
private val accentRing2Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
private val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
}
|
||||
private val centerFillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val centerRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
private val bitmapPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
private var iconBitmaps: Array<Bitmap?> = emptyArray()
|
||||
private var centerBitmap: Bitmap? = null
|
||||
private var centerUnlockedBitmap: Bitmap? = null
|
||||
private val grayFilter = ColorMatrixColorFilter(ColorMatrix().apply { setSaturation(0f) })
|
||||
private var lockShakeAngle = 0f
|
||||
private var shakeAnimator: ValueAnimator? = null
|
||||
|
||||
// ---- touch & fling ----------------------------------------------------
|
||||
|
||||
var wheelAngle = 0f
|
||||
private var isDragging = false
|
||||
private var snapAnimator: ValueAnimator? = null
|
||||
|
||||
// Incremental drag state
|
||||
private var prevTouchAngle = 0f
|
||||
private var touchDownX = 0f
|
||||
private var touchDownY = 0f
|
||||
|
||||
// Velocity buffer: stores (cumulative wheel angle, timestamp) for last N samples
|
||||
private val VEL_SAMPLES = 6
|
||||
private val velAngles = FloatArray(VEL_SAMPLES)
|
||||
private val velTimes = LongArray(VEL_SAMPLES)
|
||||
private var velIdx = 0
|
||||
private var velCount = 0
|
||||
|
||||
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop.toFloat()
|
||||
|
||||
// ---- helpers ----------------------------------------------------------
|
||||
|
||||
private fun dp(v: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, resources.displayMetrics)
|
||||
|
||||
// ---- sizing -----------------------------------------------------------
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
cx = w / 2f
|
||||
cy = h / 2f
|
||||
val size = minOf(w, h)
|
||||
outerRadius = size / 2f * 0.80f
|
||||
innerRadius = outerRadius * 0.26f
|
||||
|
||||
textPaint.textSize = size * 0.034f
|
||||
dividerPaint.strokeWidth = dp(0.7f)
|
||||
accentRingPaint.strokeWidth = dp(5f)
|
||||
accentRing2Paint.strokeWidth = dp(3f)
|
||||
centerRingPaint.strokeWidth = dp(4f)
|
||||
|
||||
reloadBitmaps()
|
||||
}
|
||||
|
||||
private fun reloadBitmaps() {
|
||||
val iconPx = (outerRadius * 0.24f).toInt().coerceAtLeast(1)
|
||||
items.forEachIndexed { i, item ->
|
||||
iconBitmaps[i] = tintedBitmap(item.iconRes, accentColor, iconPx)
|
||||
}
|
||||
val centerPx = (innerRadius * 0.64f).toInt().coerceAtLeast(1)
|
||||
centerBitmap = tintedBitmap(R.drawable.ic_lock, accentColor, centerPx)
|
||||
centerUnlockedBitmap = tintedBitmap(R.drawable.ic_lock_open, accentColor, centerPx)
|
||||
}
|
||||
|
||||
private fun tintedBitmap(@DrawableRes resId: Int, tint: Int, sizePx: Int): Bitmap? {
|
||||
if (sizePx <= 0) return null
|
||||
return try {
|
||||
val d = AppCompatResources.getDrawable(context, resId)!!.mutate()
|
||||
DrawableCompat.setTint(DrawableCompat.wrap(d), tint)
|
||||
val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||
Canvas(bmp).also { d.setBounds(0, 0, sizePx, sizePx); d.draw(it) }
|
||||
bmp
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
// ---- drawing ----------------------------------------------------------
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
if (items.isEmpty()) return
|
||||
|
||||
val segCount = items.size
|
||||
val segDeg = 360f / segCount
|
||||
|
||||
// Wheel disc
|
||||
discPaint.color = surfaceColor
|
||||
canvas.drawCircle(cx, cy, outerRadius, discPaint)
|
||||
|
||||
// Accent ring around wheel
|
||||
accentRingPaint.color = accentColor
|
||||
canvas.drawCircle(cx, cy, outerRadius + dp(20f), accentRingPaint)
|
||||
|
||||
// Rotatable layer
|
||||
canvas.save()
|
||||
canvas.rotate(wheelAngle, cx, cy)
|
||||
|
||||
// Divider lines between segments
|
||||
dividerPaint.color = (labelColor and 0x00FFFFFF) or (100 shl 24)
|
||||
for (i in 0 until segCount) {
|
||||
val rad = Math.toRadians((i * segDeg).toDouble())
|
||||
val cos = cos(rad).toFloat()
|
||||
val sin = sin(rad).toFloat()
|
||||
canvas.drawLine(
|
||||
cx + cos * (innerRadius + dp(6f)), cy + sin * (innerRadius + dp(6f)),
|
||||
cx + cos * (outerRadius - dp(12f)), cy + sin * (outerRadius - dp(12f)),
|
||||
dividerPaint
|
||||
)
|
||||
}
|
||||
|
||||
// Segment content
|
||||
for (i in 0 until segCount) {
|
||||
val midDeg = i * segDeg + segDeg / 2f
|
||||
drawSegment(canvas, i, midDeg)
|
||||
}
|
||||
|
||||
canvas.restore()
|
||||
|
||||
// Center button — always upright
|
||||
centerRingPaint.color = accentColor
|
||||
canvas.drawCircle(cx, cy, innerRadius + dp(3f), centerRingPaint)
|
||||
centerFillPaint.color = surfaceColor
|
||||
canvas.drawCircle(cx, cy, innerRadius, centerFillPaint)
|
||||
val activeCenterBitmap = if (isWheelLocked) centerBitmap else centerUnlockedBitmap
|
||||
activeCenterBitmap?.let {
|
||||
canvas.save()
|
||||
// Shake pivots around the bottom-centre of the icon
|
||||
if (lockShakeAngle != 0f) canvas.rotate(lockShakeAngle, cx, cy + it.height / 2f)
|
||||
canvas.drawBitmap(it, cx - it.width / 2f, cy - it.height / 2f, bitmapPaint)
|
||||
canvas.restore()
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawSegment(canvas: Canvas, index: Int, midDeg: Float) {
|
||||
val rad = Math.toRadians(midDeg.toDouble())
|
||||
val cosA = cos(rad).toFloat()
|
||||
val sinA = sin(rad).toFloat()
|
||||
|
||||
val iconX = cx + cosA * (outerRadius * 0.63f)
|
||||
val iconY = cy + sinA * (outerRadius * 0.63f)
|
||||
|
||||
// Icon — radially oriented; top items are naturally upside-down
|
||||
iconBitmaps.getOrNull(index)?.let { bmp ->
|
||||
canvas.save()
|
||||
canvas.translate(iconX, iconY)
|
||||
canvas.rotate(midDeg - 90f)
|
||||
if (isWheelLocked) {
|
||||
bitmapPaint.colorFilter = grayFilter
|
||||
bitmapPaint.alpha = 100
|
||||
}
|
||||
canvas.drawBitmap(bmp, -bmp.width / 2f, -bmp.height / 2f, bitmapPaint)
|
||||
if (isWheelLocked) {
|
||||
bitmapPaint.colorFilter = null
|
||||
bitmapPaint.alpha = 255
|
||||
}
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
// Curved label — same radial orientation as icons.
|
||||
// In the local rotated frame the wheel arc is a circle of radius `labelRadius`
|
||||
// with its centre directly "above" at (0, -labelRadius). A CCW arc through (0,0)
|
||||
// flows rightward at that point, matching the natural reading direction at 6 o'clock.
|
||||
val labelRadius = outerRadius * 0.84f
|
||||
val textX = cx + cosA * labelRadius
|
||||
val textY = cy + sinA * labelRadius
|
||||
val label = items[index].label
|
||||
textPaint.color = if (isWheelLocked) (labelColor and 0x00FFFFFF) or (80 shl 24) else labelColor
|
||||
textPaint.textAlign = Paint.Align.LEFT
|
||||
val halfAngleDeg = Math.toDegrees((textPaint.measureText(label) / 2.0) / labelRadius).toFloat()
|
||||
val localArcRect = RectF(-labelRadius, -2f * labelRadius, labelRadius, 0f)
|
||||
val arcPath = Path().apply { addArc(localArcRect, 90f + halfAngleDeg, -(halfAngleDeg * 2f)) }
|
||||
canvas.save()
|
||||
canvas.translate(textX, textY)
|
||||
canvas.rotate(midDeg - 90f)
|
||||
canvas.drawTextOnPath(label, arcPath, 0f, textPaint.textSize * 0.36f, textPaint)
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
// ---- touch ------------------------------------------------------------
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
snapAnimator?.cancel()
|
||||
prevTouchAngle = angleAt(event.x, event.y)
|
||||
touchDownX = event.x
|
||||
touchDownY = event.y
|
||||
isDragging = false
|
||||
velIdx = 0
|
||||
velCount = 0
|
||||
recordVelSample()
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val curr = angleAt(event.x, event.y)
|
||||
// Incremental delta — normalised to [-180, 180] to survive the ±180° wrap
|
||||
var dA = curr - prevTouchAngle
|
||||
if (dA > 180f) dA -= 360f
|
||||
if (dA < -180f) dA += 360f
|
||||
prevTouchAngle = curr
|
||||
|
||||
val moved = hypot(event.x - touchDownX, event.y - touchDownY)
|
||||
if (moved > touchSlop || isDragging) {
|
||||
isDragging = true
|
||||
wheelAngle += dA
|
||||
recordVelSample()
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
if (!isDragging) {
|
||||
val dist = hypot(event.x - cx, event.y - cy)
|
||||
when {
|
||||
dist <= innerRadius -> {
|
||||
if (isWheelLocked) {
|
||||
onWheelCenterLockedTap?.invoke()
|
||||
} else {
|
||||
isWheelLocked = true
|
||||
}
|
||||
}
|
||||
dist <= outerRadius -> {
|
||||
if (isWheelLocked) {
|
||||
val idx = segmentAt(event.x, event.y)
|
||||
if (idx in items.indices) animateToSixOClock(idx) {
|
||||
vibrateDevice()
|
||||
shakeLock()
|
||||
}
|
||||
} else {
|
||||
val idx = segmentAt(event.x, event.y)
|
||||
if (idx in items.indices) animateToSixOClock(idx) { onItemClick?.invoke(items[idx].navId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val vel = computeVelocity()
|
||||
if (abs(vel) > 0.05f) fling(vel) else snapToNearest()
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
if (isDragging) {
|
||||
val vel = computeVelocity()
|
||||
if (abs(vel) > 0.05f) fling(vel) else snapToNearest()
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun recordVelSample() {
|
||||
val slot = velIdx % VEL_SAMPLES
|
||||
velAngles[slot] = wheelAngle
|
||||
velTimes[slot] = System.currentTimeMillis()
|
||||
velIdx++
|
||||
if (velCount < VEL_SAMPLES) velCount++
|
||||
}
|
||||
|
||||
/** Returns angular velocity in degrees per millisecond, using the oldest available sample. */
|
||||
private fun computeVelocity(): Float {
|
||||
if (velCount < 2) return 0f
|
||||
val newest = (velIdx - 1 + VEL_SAMPLES) % VEL_SAMPLES
|
||||
// Use the sample that is ~100 ms old for a stable estimate
|
||||
val oldest = (velIdx - velCount + VEL_SAMPLES) % VEL_SAMPLES
|
||||
val dt = velTimes[newest] - velTimes[oldest]
|
||||
if (dt <= 0L) return 0f
|
||||
return (velAngles[newest] - velAngles[oldest]) / dt
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick off a physics-based fling: uniform deceleration from [initialVel] to zero,
|
||||
* then snap to the nearest segment.
|
||||
* Formula: total_rotation = v0² / (2 * DECEL), duration = v0 / DECEL
|
||||
* With DecelerateInterpolator(1) the initial animation velocity matches v0.
|
||||
*/
|
||||
private fun fling(initialVel: Float) {
|
||||
val DECEL = 0.0008f // deg / ms² — tune for feel
|
||||
val duration = (abs(initialVel) / DECEL).toLong().coerceIn(200, 3500)
|
||||
val sign = if (initialVel >= 0f) 1f else -1f
|
||||
val totalRot = sign * initialVel * initialVel / (2f * DECEL)
|
||||
val startAngle = wheelAngle
|
||||
val endAngle = startAngle + totalRot
|
||||
|
||||
snapAnimator = ValueAnimator.ofFloat(startAngle, endAngle).apply {
|
||||
this.duration = duration
|
||||
interpolator = DecelerateInterpolator() // matches v0 at t=0
|
||||
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
|
||||
addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(a: Animator) { snapToNearest() }
|
||||
})
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun angleAt(x: Float, y: Float): Float =
|
||||
Math.toDegrees(atan2((y - cy).toDouble(), (x - cx).toDouble())).toFloat()
|
||||
|
||||
private fun segmentAt(x: Float, y: Float): Int {
|
||||
var a = angleAt(x, y) - wheelAngle
|
||||
a = (a % 360f + 360f) % 360f
|
||||
return (a / (360f / items.size)).toInt() % items.size
|
||||
}
|
||||
|
||||
private fun animateToSixOClock(index: Int, onDone: () -> Unit) {
|
||||
val segDeg = 360f / items.size.coerceAtLeast(1)
|
||||
val midDeg = index * segDeg + segDeg / 2f
|
||||
// delta needed so this segment's midpoint lands at 90° (6 o'clock in math coords)
|
||||
var delta = (90f - midDeg) - wheelAngle
|
||||
// normalise to shortest path [-180, 180]
|
||||
delta = ((delta % 360f) + 360f) % 360f
|
||||
if (delta > 180f) delta -= 360f
|
||||
val endAngle = wheelAngle + delta
|
||||
|
||||
snapAnimator?.cancel()
|
||||
snapAnimator = ValueAnimator.ofFloat(wheelAngle, endAngle).apply {
|
||||
duration = 350
|
||||
interpolator = DecelerateInterpolator()
|
||||
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
|
||||
addListener(object : AnimatorListenerAdapter() {
|
||||
private var cancelled = false
|
||||
override fun onAnimationCancel(a: Animator) { cancelled = true }
|
||||
override fun onAnimationEnd(a: Animator) { if (!cancelled) onDone() }
|
||||
})
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapToNearest() {
|
||||
val segDeg = 360f / items.size.coerceAtLeast(1)
|
||||
val target = (wheelAngle / segDeg).roundToInt() * segDeg
|
||||
snapAnimator = ValueAnimator.ofFloat(wheelAngle, target).apply {
|
||||
duration = 300
|
||||
interpolator = DecelerateInterpolator()
|
||||
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun vibrateDevice() {
|
||||
val v = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||
v.vibrate(VibrationEffect.createOneShot(60, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||
}
|
||||
|
||||
fun shakeLock() {
|
||||
shakeAnimator?.cancel()
|
||||
shakeAnimator = ValueAnimator.ofFloat(0f, -18f, 18f, -12f, 12f, -6f, 6f, 0f).apply {
|
||||
duration = 500
|
||||
interpolator = LinearInterpolator()
|
||||
addUpdateListener { lockShakeAngle = it.animatedValue as Float; invalidate() }
|
||||
addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(a: Animator) { lockShakeAngle = 0f; invalidate() }
|
||||
})
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
fun unlockWheel() {
|
||||
isWheelLocked = false
|
||||
lockShakeAngle = 0f
|
||||
shakeAnimator?.cancel()
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,9 @@ class ContactPickerAdapter(
|
||||
val isSameAsFrom: Boolean = false,
|
||||
val isManualEntry: Boolean = false,
|
||||
val imageHash: String? = null,
|
||||
val inactiveReason: String? = null
|
||||
val inactiveReason: String? = null,
|
||||
val balance: String? = null,
|
||||
val bankLogoRes: Int? = null
|
||||
) : PickerItem()
|
||||
}
|
||||
|
||||
@@ -89,14 +91,31 @@ class ContactPickerAdapter(
|
||||
binding.tvPrimary.text = item.displayName
|
||||
binding.tvSecondary.text = item.subtitle
|
||||
|
||||
val cached = item.imageHash?.let { imageCache[it] }
|
||||
if (cached != null) {
|
||||
binding.ivIcon.setImageBitmap(cached)
|
||||
if (item.balance != null) {
|
||||
binding.tvBalance.text = item.balance
|
||||
binding.tvBalance.visibility = android.view.View.VISIBLE
|
||||
} else {
|
||||
val iconChar = if (item.isManualEntry) "→" else item.displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
val iconColor = if (item.isManualEntry) "#546E7A" else item.colorHex
|
||||
binding.ivIcon.setImageBitmap(makeInitialsBitmap(iconChar, iconColor, binding.ivIcon.context))
|
||||
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
|
||||
binding.tvBalance.visibility = android.view.View.GONE
|
||||
}
|
||||
|
||||
val cached = item.imageHash?.let { imageCache[it] }
|
||||
when {
|
||||
cached != null -> {
|
||||
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
binding.ivIcon.setImageBitmap(cached)
|
||||
}
|
||||
item.bankLogoRes != null -> {
|
||||
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivIcon.setImageResource(item.bankLogoRes)
|
||||
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
|
||||
}
|
||||
else -> {
|
||||
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
val iconChar = if (item.isManualEntry) "→" else item.displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
val iconColor = if (item.isManualEntry) "#546E7A" else item.colorHex
|
||||
binding.ivIcon.setImageBitmap(makeInitialsBitmap(iconChar, iconColor, binding.ivIcon.context))
|
||||
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
|
||||
}
|
||||
}
|
||||
|
||||
binding.root.alpha = if (item.isSameAsFrom || item.inactiveReason != null) 0.4f else 1.0f
|
||||
|
||||
@@ -24,7 +24,11 @@ import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.databinding.SheetContactPickerBinding
|
||||
import sh.sar.basedbank.util.AccountListParser
|
||||
import sh.sar.basedbank.util.ProfileImageStore
|
||||
import sh.sar.basedbank.util.RecentsCache
|
||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||
|
||||
class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
@@ -145,8 +149,9 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
viewModel.contacts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||
|
||||
(activity as? HomeActivity)?.loadAllContacts()
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
}
|
||||
|
||||
private fun attachMediator(pages: List<TabDef>) {
|
||||
@@ -163,6 +168,10 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
|
||||
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
|
||||
when {
|
||||
accountNumber.startsWith("bmlqr:") -> {
|
||||
bundle.putString(KEY_SUBTITLE, "BML QR Merchant")
|
||||
bundle.putString(KEY_COLOR, "#0066A1")
|
||||
}
|
||||
account != null -> {
|
||||
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
|
||||
bundle.putString(KEY_COLOR, "#FE860E")
|
||||
@@ -183,6 +192,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
private fun buildPageItems(tabTag: String?): List<ContactPickerAdapter.PickerItem> {
|
||||
val search = binding.etSheetSearch.text?.toString()?.trim() ?: ""
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val items = mutableListOf<ContactPickerAdapter.PickerItem>()
|
||||
|
||||
if (tabTag == RECENTS_TAG) {
|
||||
@@ -209,11 +219,11 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
|
||||
val fromCurrency = fromAccount?.currencyName ?: ""
|
||||
val fromLoginTag = fromAccount?.loginTag ?: ""
|
||||
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT"
|
||||
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT" || fromAccount?.profileType == "BML_DEBIT"
|
||||
|
||||
if (tabTag == MY_ACCOUNTS_TAG) {
|
||||
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
|
||||
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
|
||||
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" }
|
||||
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
|
||||
|
||||
val filteredRegular = if (search.isBlank()) regularAccounts else regularAccounts.filter {
|
||||
it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
|
||||
@@ -223,16 +233,29 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
for (acc in filteredRegular) {
|
||||
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
|
||||
val isSame = acc.accountNumber == fromAccountNumber
|
||||
val parsedBalance = AccountListParser.from(acc)?.balance
|
||||
?: "${acc.currencyName} ${acc.availableBalance}"
|
||||
val balance = if (hide) maskAmount(parsedBalance) else parsedBalance
|
||||
val logoRes = when (acc.bank) {
|
||||
"BML" -> R.drawable.bml_logo_vector
|
||||
"FAHIPAY" -> R.drawable.fahipay_logo
|
||||
"MIB" -> R.drawable.mib_logo
|
||||
else -> null
|
||||
}
|
||||
val localKey = localImageKeyFor(acc)
|
||||
if (localKey != null) profileImageHashes.add("local:$localKey")
|
||||
items.add(ContactPickerAdapter.PickerItem.Row(
|
||||
accountNumber = acc.accountNumber,
|
||||
displayName = acc.accountBriefName,
|
||||
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}",
|
||||
subtitle = acc.accountNumber,
|
||||
colorHex = "#FE860E",
|
||||
isSameAsFrom = isSame,
|
||||
imageHash = acc.profileImageHash,
|
||||
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
||||
inactiveReason = if (isSame) null
|
||||
else if (fromIsCard && acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
||||
else currencyMismatchReason(fromCurrency, acc.currencyName)
|
||||
else currencyMismatchReason(fromCurrency, acc.currencyName),
|
||||
balance = balance,
|
||||
bankLogoRes = logoRes
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -246,17 +269,26 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
|
||||
val isSame = acc.accountNumber == fromAccountNumber
|
||||
val isActive = acc.statusDesc.equals("Active", ignoreCase = true)
|
||||
val isDebit = acc.profileType == "BML_DEBIT"
|
||||
val parsedBalance = if (isDebit) null
|
||||
else AccountListParser.from(acc)?.balance ?: "${acc.currencyName} ${acc.availableBalance}"
|
||||
val balance = parsedBalance?.let { if (hide) maskAmount(it) else it }
|
||||
val logoRes = BmlCardParser.cardNetworkIcon(acc) ?: R.drawable.bml_logo_vector
|
||||
val localKey = localImageKeyFor(acc)
|
||||
if (localKey != null) profileImageHashes.add("local:$localKey")
|
||||
items.add(ContactPickerAdapter.PickerItem.Row(
|
||||
accountNumber = acc.accountNumber,
|
||||
displayName = acc.accountBriefName,
|
||||
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}",
|
||||
subtitle = acc.accountNumber,
|
||||
colorHex = "#FE860E",
|
||||
isSameAsFrom = isSame,
|
||||
imageHash = acc.profileImageHash,
|
||||
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
||||
inactiveReason = if (isSame) null
|
||||
else if (!isActive) acc.statusDesc
|
||||
else if (acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
||||
else currencyMismatchReason(fromCurrency, acc.currencyName)
|
||||
else currencyMismatchReason(fromCurrency, acc.currencyName),
|
||||
balance = balance,
|
||||
bankLogoRes = logoRes
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -287,6 +319,17 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
private fun fetchImage(hash: String) {
|
||||
if (!pendingHashes.add(hash)) return
|
||||
// Local image keys for BML/Fahipay (prefixed with "local:")
|
||||
if (hash.startsWith("local:")) {
|
||||
val key = hash.removePrefix("local:")
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val bitmap = ProfileImageStore.load(requireContext(), key) ?: run {
|
||||
pendingHashes.remove(hash); return@launch
|
||||
}
|
||||
withContext(Dispatchers.Main) { pagerAdapter.updateImage(hash, bitmap) }
|
||||
}
|
||||
return
|
||||
}
|
||||
val sess = session ?: return
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -306,9 +349,24 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun maskAmount(formatted: String): String {
|
||||
val currency = formatted.substringBefore(' ', formatted)
|
||||
return "$currency ••••••"
|
||||
}
|
||||
|
||||
private fun currencyMismatchReason(fromCurrency: String, toCurrency: String): String? =
|
||||
if (fromCurrency == "MVR" && toCurrency == "USD") "Cannot transfer from MVR to USD account" else null
|
||||
|
||||
/** Returns the ProfileImageStore key for BML/Fahipay accounts, or null for MIB/others. */
|
||||
private fun localImageKeyFor(acc: sh.sar.basedbank.api.models.BankAccount): String? = when (acc.bank) {
|
||||
"BML" -> if (acc.profileId.isNotBlank()) sh.sar.basedbank.util.ProfileImageStore.bmlKey(acc.profileId) else null
|
||||
"FAHIPAY" -> {
|
||||
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(acc.loginTag)
|
||||
if (loginId.isNotBlank()) sh.sar.basedbank.util.ProfileImageStore.fahipayKey(loginId) else null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
|
||||
@@ -23,7 +23,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
|
||||
import sh.sar.basedbank.api.models.BankContactCategory
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.databinding.FragmentContactsBinding
|
||||
import sh.sar.basedbank.util.ContactDisplay
|
||||
@@ -134,6 +134,11 @@ class ContactsFragment : Fragment() {
|
||||
|
||||
(activity as? HomeActivity)?.loadAllContacts()
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
|
||||
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
|
||||
rebuildPager(cats)
|
||||
}
|
||||
@@ -153,7 +158,7 @@ class ContactsFragment : Fragment() {
|
||||
}.also { it.attach() }
|
||||
}
|
||||
|
||||
private fun rebuildPager(cats: List<MibBeneficiaryCategory>) {
|
||||
private fun rebuildPager(cats: List<BankContactCategory>) {
|
||||
val pages = buildList {
|
||||
add(TabPage(null, getString(R.string.contacts_tab_all)))
|
||||
cats.forEach { add(TabPage(it.id, it.categoryName)) }
|
||||
@@ -173,7 +178,7 @@ class ContactsFragment : Fragment() {
|
||||
colorHex = contact.bankColor,
|
||||
imageHash = contact.imageHash
|
||||
)
|
||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
|
||||
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||
}
|
||||
|
||||
private fun confirmDelete(contact: ContactDisplay) {
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.LinearSnapHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibCard
|
||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.PaymvQrParser
|
||||
import kotlin.math.abs
|
||||
import sh.sar.basedbank.databinding.FragmentDashboardBinding
|
||||
import sh.sar.basedbank.databinding.ItemForeignLimitBinding
|
||||
|
||||
@@ -22,6 +36,36 @@ class DashboardFragment : Fragment() {
|
||||
private var _binding: FragmentDashboardBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
private var pendingQrCardNumber: String? = null
|
||||
|
||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
|
||||
)
|
||||
} else {
|
||||
val qr = PaymvQrParser.parse(raw)
|
||||
if (qr?.accountNumber != null) {
|
||||
Toast.makeText(requireContext(), R.string.card_qr_paymv_unsupported, Toast.LENGTH_SHORT).show()
|
||||
val defaultFrom = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromQr(
|
||||
accountNumber = qr.accountNumber,
|
||||
displayName = qr.merchantName ?: qr.accountNumber,
|
||||
amount = qr.amount,
|
||||
remarks = qr.purpose,
|
||||
fromAccountNumber = defaultFrom
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentDashboardBinding.inflate(inflater, container, false)
|
||||
@@ -29,13 +73,68 @@ class DashboardFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
|
||||
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) }
|
||||
viewModel.accounts.observe(viewLifecycleOwner) {
|
||||
updateBalances(it)
|
||||
updateAttentionRow()
|
||||
}
|
||||
viewModel.financing.observe(viewLifecycleOwner) {
|
||||
updatePendingFinances()
|
||||
updateAttentionRow()
|
||||
}
|
||||
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) {
|
||||
updatePendingFinances()
|
||||
updateAttentionRow()
|
||||
}
|
||||
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
||||
updateBalances(viewModel.accounts.value ?: emptyList())
|
||||
updatePendingFinances()
|
||||
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
|
||||
updateAttentionRow()
|
||||
}
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
|
||||
binding.cardPendingFinances.setOnClickListener {
|
||||
(activity as? HomeActivity)?.showWithBackStackAndNav(FinancingFragment(), R.id.nav_finances)
|
||||
}
|
||||
|
||||
binding.cardOverdue.setOnClickListener {
|
||||
(activity as? HomeActivity)?.showWithBackStackAndNav(FinancingFragment(), R.id.nav_finances)
|
||||
}
|
||||
|
||||
val cardAdapter = DashboardCardAdapter()
|
||||
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
||||
binding.rvCards.adapter = cardAdapter
|
||||
LinearSnapHelper().attachToRecyclerView(binding.rvCards)
|
||||
|
||||
val updateCardList = {
|
||||
val credStore = CredentialStore(requireContext())
|
||||
val hidden = credStore.getHiddenDashboardCardNumbers()
|
||||
val mibItems = (viewModel.mibCards.value ?: emptyList())
|
||||
.filter { !hidden.contains(it.maskedCardNumber) }
|
||||
.map { CardItem.Mib(it) }
|
||||
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
||||
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
|
||||
.map { CardItem.Bml(it) }
|
||||
val all = mibItems + bmlItems
|
||||
val defaultNum = credStore.getDefaultCardAccountNumber()
|
||||
val ordered = if (defaultNum != null) {
|
||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||
if (def != null) listOf(def) + all.filter { it !== def } else all
|
||||
} else all
|
||||
cardAdapter.update(ordered)
|
||||
binding.sectionCards.visibility = if (ordered.isNotEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
|
||||
|
||||
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
|
||||
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||
val isBottomNav = NavCustomization.getNavMode(requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE)) == NavCustomization.NAV_MODE_BOTTOM
|
||||
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)
|
||||
@@ -46,12 +145,37 @@ class DashboardFragment : Fragment() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = getString(R.string.nav_dashboard)
|
||||
val isBottom = NavCustomization.getNavMode(requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)) == NavCustomization.NAV_MODE_BOTTOM
|
||||
if (isBottom) {
|
||||
requireActivity().title = getString(R.string.app_name)
|
||||
val size = (28 * resources.displayMetrics.density).toInt()
|
||||
val gap = (8 * resources.displayMetrics.density).toInt()
|
||||
val icon = requireContext().packageManager.getApplicationIcon(requireContext().packageName)
|
||||
val bmp = android.graphics.Bitmap.createBitmap(size + gap, size, android.graphics.Bitmap.Config.ARGB_8888)
|
||||
val canvas = android.graphics.Canvas(bmp)
|
||||
icon.setBounds(0, 0, size, size)
|
||||
icon.draw(canvas)
|
||||
requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar).logo =
|
||||
android.graphics.drawable.BitmapDrawable(resources, bmp)
|
||||
} else {
|
||||
requireActivity().title = getString(R.string.nav_dashboard)
|
||||
}
|
||||
refreshQuickActions()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar).logo = null
|
||||
}
|
||||
|
||||
private fun refreshQuickActions() {
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
val isBottom = NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM
|
||||
if (isBottom) {
|
||||
binding.buttonBar.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
binding.buttonBar.visibility = View.VISIBLE
|
||||
val ids = NavCustomization.getQuickActions(prefs)
|
||||
listOf(binding.btnQuickAction1, binding.btnQuickAction2).forEachIndexed { i, btn ->
|
||||
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == ids[i] }
|
||||
@@ -63,48 +187,253 @@ class DashboardFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateBalances(accounts: List<MibAccount>) {
|
||||
val mvrTotal = accounts
|
||||
private fun updateBalances(accounts: List<BankAccount>) {
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
|
||||
val nonCreditAccounts = accounts.filter { it.profileType != "BML_CREDIT" && it.profileType != "BML_LOAN" }
|
||||
val creditAccounts = accounts.filter { it.profileType == "BML_CREDIT" }
|
||||
|
||||
if (hide) {
|
||||
binding.tvMvrBalance.text = "MVR ••••••"
|
||||
binding.tvUsdBalance.text = "USD ••••••"
|
||||
if (creditAccounts.isNotEmpty()) {
|
||||
binding.rowCreditCards.visibility = View.VISIBLE
|
||||
val hasMvrCredit = creditAccounts.any { it.currencyName.equals("MVR", ignoreCase = true) }
|
||||
val hasUsdCredit = creditAccounts.any { it.currencyName.equals("USD", ignoreCase = true) }
|
||||
binding.cardMvrCredit.visibility = if (hasMvrCredit) View.VISIBLE else View.GONE
|
||||
binding.cardUsdCredit.visibility = if (hasUsdCredit) View.VISIBLE else View.GONE
|
||||
binding.tvMvrCredit.text = "MVR ••••••"
|
||||
binding.tvUsdCredit.text = "USD ••••••"
|
||||
} else {
|
||||
binding.rowCreditCards.visibility = View.GONE
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val mvrTotal = nonCreditAccounts
|
||||
.filter { it.currencyName.equals("MVR", ignoreCase = true) }
|
||||
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
||||
val usdTotal = accounts
|
||||
val usdTotal = nonCreditAccounts
|
||||
.filter { it.currencyName.equals("USD", ignoreCase = true) }
|
||||
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
||||
|
||||
binding.tvMvrBalance.text = "MVR %,.2f".format(mvrTotal)
|
||||
binding.tvUsdBalance.text = "USD %,.2f".format(usdTotal)
|
||||
|
||||
val mvrCredit = creditAccounts
|
||||
.filter { it.currencyName.equals("MVR", ignoreCase = true) }
|
||||
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
||||
val usdCredit = creditAccounts
|
||||
.filter { it.currencyName.equals("USD", ignoreCase = true) }
|
||||
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
||||
|
||||
if (creditAccounts.isNotEmpty()) {
|
||||
binding.rowCreditCards.visibility = View.VISIBLE
|
||||
binding.cardMvrCredit.visibility = if (mvrCredit > 0) View.VISIBLE else View.GONE
|
||||
binding.cardUsdCredit.visibility = if (usdCredit > 0) View.VISIBLE else View.GONE
|
||||
binding.tvMvrCredit.text = "MVR %,.2f".format(mvrCredit)
|
||||
binding.tvUsdCredit.text = "USD %,.2f".format(usdCredit)
|
||||
} else {
|
||||
binding.rowCreditCards.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private val expandedLimits = mutableSetOf<Int>()
|
||||
|
||||
private fun updateForeignLimits(entries: List<HomeViewModel.BmlLimitsData>) {
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
binding.containerForeignLimits.removeAllViews()
|
||||
var cardIndex = 0
|
||||
for (entry in entries) {
|
||||
for (limit in entry.limits) {
|
||||
val idx = cardIndex++
|
||||
val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false)
|
||||
card.tvLimitUserName.text = entry.userName.ifBlank { "BML" }
|
||||
card.tvLimitType.text = limit.type
|
||||
card.tvLimitGeneral.text = "USD %,.0f / %,.0f".format(limit.generalRemaining, limit.generalCap)
|
||||
card.tvLimitMedical.text = "USD %,.0f".format(limit.medicalRemaining)
|
||||
card.tvLimitAtm.text = if (!limit.isAtmEnabled)
|
||||
"USD %,.0f / %,.0f · Disabled".format(limit.atmRemaining, limit.atmLimit)
|
||||
else
|
||||
"USD %,.0f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
|
||||
card.tvLimitEcom.text = "USD %,.0f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
|
||||
card.tvLimitPos.text = if (!limit.isPosEnabled)
|
||||
"USD %,.0f / %,.0f · Disabled".format(limit.posRemaining, limit.posLimit)
|
||||
else
|
||||
"USD %,.0f / %,.0f".format(limit.posRemaining, limit.posLimit)
|
||||
bindLimitCard(card, entry.userName, limit, hide, idx in expandedLimits)
|
||||
card.root.setOnClickListener {
|
||||
if (idx in expandedLimits) expandedLimits.remove(idx) else expandedLimits.add(idx)
|
||||
updateForeignLimits(entries)
|
||||
}
|
||||
binding.containerForeignLimits.addView(card.root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePendingFinances(deals: List<MibFinanceDeal>) {
|
||||
val total = deals.sumOf { it.outstandingAmount }
|
||||
binding.tvPendingFinances.text = "MVR %,.2f".format(total)
|
||||
private fun bindLimitCard(
|
||||
card: ItemForeignLimitBinding,
|
||||
userName: String,
|
||||
limit: BmlForeignLimit,
|
||||
hide: Boolean,
|
||||
expanded: Boolean
|
||||
) {
|
||||
card.tvLimitUserName.text = userName.ifBlank { "BML" }
|
||||
card.tvLimitType.text = limit.type
|
||||
|
||||
// ECOM (always visible)
|
||||
card.tvLimitEcom.text = if (hide) "USD ••••••"
|
||||
else "USD %,.2f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
|
||||
card.progressEcom.progress = if (hide || limit.ecomLimit <= 0) 0
|
||||
else ((limit.ecomRemaining / limit.ecomLimit) * 100).toInt().coerceIn(0, 100)
|
||||
|
||||
// General (always visible)
|
||||
card.tvLimitGeneral.text = if (hide) "USD ••••••"
|
||||
else "USD %,.2f / %,.0f".format(limit.generalRemaining, limit.generalCap)
|
||||
card.progressGeneral.progress = if (hide || limit.generalCap <= 0) 0
|
||||
else ((limit.generalRemaining / limit.generalCap) * 100).toInt().coerceIn(0, 100)
|
||||
|
||||
// Expanded section
|
||||
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
|
||||
card.dividerLimitDetails.visibility = detailsVisible
|
||||
card.detailsGroup.visibility = detailsVisible
|
||||
|
||||
if (expanded) {
|
||||
// ATM
|
||||
if (!limit.isAtmEnabled) card.tvAtmLabel.append(" (Disabled)")
|
||||
card.tvLimitAtm.text = if (hide) "USD ••••••"
|
||||
else "USD %,.2f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
|
||||
card.progressAtm.progress = if (hide || limit.atmLimit <= 0) 0
|
||||
else ((limit.atmRemaining / limit.atmLimit) * 100).toInt().coerceIn(0, 100)
|
||||
|
||||
// POS
|
||||
if (!limit.isPosEnabled) card.tvPosLabel.append(" (Disabled)")
|
||||
card.tvLimitPos.text = if (hide) "USD ••••••"
|
||||
else "USD %,.2f / %,.0f".format(limit.posRemaining, limit.posLimit)
|
||||
card.progressPos.progress = if (hide || limit.posLimit <= 0) 0
|
||||
else ((limit.posRemaining / limit.posLimit) * 100).toInt().coerceIn(0, 100)
|
||||
|
||||
// Medical
|
||||
card.tvLimitMedical.text = if (hide) "USD ••••••"
|
||||
else "USD %,.2f / %,.0f".format(limit.medicalRemaining, limit.totalLimit)
|
||||
card.progressMedical.progress = if (hide || limit.totalLimit <= 0) 0
|
||||
else ((limit.medicalRemaining / limit.totalLimit) * 100).toInt().coerceIn(0, 100)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateAttentionRow() {
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val accounts = viewModel.accounts.value ?: emptyList()
|
||||
|
||||
// Blocked: sum across CASA-style accounts (exclude cards and loans) per currency.
|
||||
val blockedByCurrency = accounts
|
||||
.filter { it.profileType != "BML_CREDIT" && it.profileType != "BML_PREPAID" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" }
|
||||
.mapNotNull { acc ->
|
||||
val v = acc.blockedAmount.replace(",", "").toDoubleOrNull() ?: 0.0
|
||||
if (v > 0.0) acc.currencyName.uppercase() to v else null
|
||||
}
|
||||
.groupBy({ it.first }, { it.second })
|
||||
.mapValues { (_, vs) -> vs.sum() }
|
||||
|
||||
val blockedMvr = blockedByCurrency["MVR"] ?: 0.0
|
||||
val blockedUsd = blockedByCurrency["USD"] ?: 0.0
|
||||
val blockedTotal = blockedByCurrency.values.sum()
|
||||
|
||||
if (blockedMvr > 0.0) {
|
||||
binding.tvBlockedMvr.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(blockedMvr)
|
||||
binding.cardBlockedMvr.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.cardBlockedMvr.visibility = View.GONE
|
||||
}
|
||||
if (blockedUsd > 0.0) {
|
||||
binding.tvBlockedUsd.text = if (hide) "USD ••••••" else "USD %,.2f".format(blockedUsd)
|
||||
binding.cardBlockedUsd.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.cardBlockedUsd.visibility = View.GONE
|
||||
}
|
||||
binding.rowBlocked.visibility = if (blockedTotal > 0.0) View.VISIBLE else View.GONE
|
||||
|
||||
// Overdue: MIB finance deals + BML loan details (assumed MVR — matches existing Pending Finances).
|
||||
val mibOverdue = (viewModel.financing.value ?: emptyList()).sumOf { it.overdueAmount }
|
||||
val bmlOverdue = (viewModel.bmlLoanDetails.value ?: emptyMap()).values.sumOf { it.overdueAmount }
|
||||
val overdueTotal = mibOverdue + bmlOverdue
|
||||
if (overdueTotal > 0.0) {
|
||||
binding.tvOverdueTotal.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(overdueTotal)
|
||||
binding.cardOverdue.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.cardOverdue.visibility = View.GONE
|
||||
}
|
||||
|
||||
binding.rowAttention.visibility = if (overdueTotal > 0.0) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun updatePendingFinances() {
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val mibTotal = (viewModel.financing.value ?: emptyList()).sumOf { it.outstandingAmount }
|
||||
val bmlLoanDetails = viewModel.bmlLoanDetails.value ?: emptyMap()
|
||||
val bmlTotal = bmlLoanDetails.values.sumOf { abs(it.outstandingAmt) }
|
||||
val total = mibTotal + bmlTotal
|
||||
binding.tvPendingFinances.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(total)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private inner class DashboardCardAdapter : RecyclerView.Adapter<DashboardCardAdapter.VH>() {
|
||||
private var cards: List<CardItem> = emptyList()
|
||||
|
||||
fun update(newCards: List<CardItem>) {
|
||||
cards = newCards
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_card_dashboard, parent, false)
|
||||
return VH(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position])
|
||||
override fun getItemCount() = cards.size
|
||||
|
||||
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
|
||||
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
|
||||
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
|
||||
private val tvCardStatus: TextView = view.findViewById(R.id.tvCardStatus)
|
||||
private val btnPayQr: View = view.findViewById(R.id.btnPayQr)
|
||||
private val btnPayNfc: View = view.findViewById(R.id.btnPayNfc)
|
||||
|
||||
fun bind(item: CardItem) {
|
||||
when (item) {
|
||||
is CardItem.Mib -> {
|
||||
tvCardOwner.text = item.card.cardHolderName
|
||||
tvCardNumber.text = CardsFragment.formatMasked(item.card.maskedCardNumber)
|
||||
val assetPath = CardsFragment.cardImageAsset(item.card)
|
||||
if (assetPath != null) CardsFragment.loadCardImage(ivCardImage, assetPath)
|
||||
else ivCardImage.setImageDrawable(null)
|
||||
CardsFragment.bindCardStatus(tvCardStatus, CardsFragment.mibCardStatusLabel(item.card.cardStatus))
|
||||
}
|
||||
is CardItem.Bml -> {
|
||||
tvCardOwner.text = item.account.accountBriefName
|
||||
tvCardNumber.text = CardsFragment.formatMasked(item.account.accountNumber)
|
||||
CardsFragment.loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
|
||||
CardsFragment.bindCardStatus(tvCardStatus, null) // only Active BML cards reach dashboard
|
||||
}
|
||||
}
|
||||
val isMib = item is CardItem.Mib
|
||||
btnPayQr.setOnClickListener {
|
||||
if (isMib) {
|
||||
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
pendingQrCardNumber = (item as CardItem.Bml).account.accountNumber
|
||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||
}
|
||||
}
|
||||
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(requireContext())
|
||||
val nfcSupported = nfcAdapter != null
|
||||
btnPayNfc.isEnabled = nfcSupported
|
||||
btnPayNfc.setOnClickListener {
|
||||
if (isMib) {
|
||||
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
val accountNumber = (item as CardItem.Bml).account.accountNumber
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_pay_with_card,
|
||||
CardsFragment.newInstanceWithAutoTapMode(accountNumber)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,92 +5,139 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||
import sh.sar.basedbank.api.mib.MibFinancingClient
|
||||
import sh.sar.basedbank.databinding.ItemBmlLoanBinding
|
||||
import sh.sar.basedbank.databinding.ItemFinanceDealBinding
|
||||
import java.text.NumberFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
|
||||
class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
||||
RecyclerView.Adapter<FinancingAdapter.ViewHolder>() {
|
||||
class FinancingAdapter(mibDeals: List<MibFinanceDeal>) :
|
||||
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private sealed class Item {
|
||||
data class Mib(val deal: MibFinanceDeal) : Item()
|
||||
data class Bml(val account: BankAccount, val detail: BmlLoanDetail?) : Item()
|
||||
}
|
||||
|
||||
private var items: List<Item> = mibDeals.map { Item.Mib(it) }
|
||||
private var hideAmounts: Boolean = false
|
||||
|
||||
private val expandedPositions = mutableSetOf<Int>()
|
||||
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
|
||||
minimumFractionDigits = 2
|
||||
maximumFractionDigits = 2
|
||||
}
|
||||
private val inputDateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
private val mibDateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
private val isoDateFmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US)
|
||||
private val outputDateFmt = SimpleDateFormat("d MMM yyyy", Locale.US)
|
||||
|
||||
fun updateDeals(newDeals: List<MibFinanceDeal>) {
|
||||
deals = newDeals
|
||||
expandedPositions.clear()
|
||||
fun setHideAmounts(hide: Boolean) {
|
||||
if (hideAmounts == hide) return
|
||||
hideAmounts = hide
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val binding = ItemFinanceDealBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return ViewHolder(binding)
|
||||
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>) {
|
||||
expandedPositions.clear()
|
||||
items = mibDeals.map { Item.Mib(it) } + bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(deals[position], position in expandedPositions)
|
||||
holder.binding.root.setOnClickListener {
|
||||
// Legacy compatibility — used on initial empty construction
|
||||
fun updateDeals(newDeals: List<MibFinanceDeal>) {
|
||||
expandedPositions.clear()
|
||||
val bmlItems = items.filterIsInstance<Item.Bml>()
|
||||
items = newDeals.map { Item.Mib(it) } + bmlItems
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun updateBmlLoans(loans: List<Pair<BankAccount, BmlLoanDetail?>>) {
|
||||
expandedPositions.clear()
|
||||
val mibItems = items.filterIsInstance<Item.Mib>()
|
||||
items = mibItems + loans.map { (acc, detail) -> Item.Bml(acc, detail) }
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int) = when (items[position]) {
|
||||
is Item.Mib -> TYPE_MIB
|
||||
is Item.Bml -> TYPE_BML
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
return when (viewType) {
|
||||
TYPE_BML -> BmlViewHolder(ItemBmlLoanBinding.inflate(inflater, parent, false))
|
||||
else -> MibViewHolder(ItemFinanceDealBinding.inflate(inflater, parent, false))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
val expanded = position in expandedPositions
|
||||
when (val item = items[position]) {
|
||||
is Item.Mib -> (holder as MibViewHolder).bind(item.deal, expanded)
|
||||
is Item.Bml -> (holder as BmlViewHolder).bind(item.account, item.detail, expanded)
|
||||
}
|
||||
holder.itemView.setOnClickListener {
|
||||
val pos = holder.bindingAdapterPosition
|
||||
if (pos in expandedPositions) expandedPositions.remove(pos) else expandedPositions.add(pos)
|
||||
notifyItemChanged(pos)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = deals.size
|
||||
override fun getItemCount() = items.size
|
||||
|
||||
inner class ViewHolder(val binding: ItemFinanceDealBinding) :
|
||||
// ── MIB ViewHolder ────────────────────────────────────────────────────────
|
||||
|
||||
inner class MibViewHolder(val binding: ItemFinanceDealBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(deal: MibFinanceDeal, expanded: Boolean) {
|
||||
val ctx = binding.root.context
|
||||
val currency = deal.currency
|
||||
val hide = hideAmounts
|
||||
|
||||
binding.tvProductName.text = deal.productDesc
|
||||
binding.tvDealNo.text = ctx.getString(R.string.financing_deal_no_fmt, deal.dealNo)
|
||||
binding.tvStatus.text = deal.statusDesc
|
||||
binding.tvTotal.text = "$currency ${amountFmt.format(deal.dealAmount)}"
|
||||
binding.tvPaid.text = "$currency ${amountFmt.format(deal.paidAmount)}"
|
||||
binding.tvUnpaid.text = "$currency ${amountFmt.format(deal.outstandingAmount)}"
|
||||
binding.tvTotal.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.dealAmount)}"
|
||||
binding.tvPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.paidAmount)}"
|
||||
binding.tvUnpaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.outstandingAmount)}"
|
||||
|
||||
// Progress bar
|
||||
val progress = if (deal.dealAmount > 0)
|
||||
((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100)
|
||||
else 0
|
||||
binding.progressBar.progress = progress
|
||||
binding.progressBar.progress = if (hide) 0 else progress
|
||||
|
||||
// Completion estimate
|
||||
binding.tvCompletion.text = completionText(deal, ctx)
|
||||
binding.tvCompletion.text = mibCompletionText(deal, ctx)
|
||||
|
||||
// Expanded details
|
||||
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
|
||||
binding.dividerDetails.visibility = detailsVisible
|
||||
binding.detailsGroup.visibility = detailsVisible
|
||||
|
||||
if (expanded) {
|
||||
binding.tvDealDate.text = formatDate(deal.dealDate)
|
||||
binding.tvInstallment.text = "$currency ${amountFmt.format(deal.installmentAmount)}"
|
||||
binding.tvDealDate.text = formatMibDate(deal.dealDate)
|
||||
binding.tvInstallment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.installmentAmount)}"
|
||||
binding.tvNumInstallments.text = deal.noOfInstallments.toString()
|
||||
binding.tvLastPaidDate.text = formatDate(deal.lastPaidDate)
|
||||
binding.tvLastPayAmount.text = "$currency ${amountFmt.format(deal.lastPayAmount)}"
|
||||
binding.tvLastPaidDate.text = formatMibDate(deal.lastPaidDate)
|
||||
binding.tvLastPayAmount.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.lastPayAmount)}"
|
||||
|
||||
if (deal.overdueAmount > 0) {
|
||||
binding.rowOverdue.visibility = View.VISIBLE
|
||||
binding.tvOverdue.text = "$currency ${amountFmt.format(deal.overdueAmount)}"
|
||||
binding.tvOverdue.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.overdueAmount)}"
|
||||
} else {
|
||||
binding.rowOverdue.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun completionText(deal: MibFinanceDeal, ctx: android.content.Context): String {
|
||||
private fun mibCompletionText(deal: MibFinanceDeal, ctx: android.content.Context): String {
|
||||
if (deal.outstandingAmount <= 0.0) return ctx.getString(R.string.financing_completion_done)
|
||||
val remaining = MibFinancingClient.remainingMonths(deal)
|
||||
if (remaining <= 0) return ctx.getString(R.string.financing_completion_done)
|
||||
@@ -100,12 +147,84 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
||||
return ctx.getString(R.string.financing_completion_fmt, month)
|
||||
}
|
||||
|
||||
private fun formatDate(raw: String): String {
|
||||
private fun formatMibDate(raw: String): String {
|
||||
return try {
|
||||
outputDateFmt.format(inputDateFmt.parse(raw)!!)
|
||||
} catch (_: Exception) {
|
||||
raw.take(10)
|
||||
}
|
||||
outputDateFmt.format(mibDateFmt.parse(raw)!!)
|
||||
} catch (_: Exception) { raw.take(10) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── BML ViewHolder ────────────────────────────────────────────────────────
|
||||
|
||||
inner class BmlViewHolder(val binding: ItemBmlLoanBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(account: BankAccount, detail: BmlLoanDetail?, expanded: Boolean) {
|
||||
val ctx = binding.root.context
|
||||
val currency = account.currencyName
|
||||
val hide = hideAmounts
|
||||
|
||||
binding.tvLoanProduct.text = account.accountTypeName
|
||||
.trim().lowercase().split(" ")
|
||||
.joinToString(" ") { it.replaceFirstChar { c -> c.uppercaseChar() } }
|
||||
binding.tvLoanAccount.text = account.accountNumber
|
||||
binding.tvLoanStatus.text = detail?.loanStatus?.ifBlank { account.statusDesc } ?: account.statusDesc
|
||||
|
||||
val loanAmt = detail?.loanAmount ?: 0.0
|
||||
val outstanding = if (detail != null) abs(detail.outstandingAmt) else account.availableBalance.toDoubleOrNull() ?: 0.0
|
||||
val paid = (loanAmt - outstanding).coerceAtLeast(0.0)
|
||||
|
||||
binding.tvLoanTotal.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(loanAmt)}"
|
||||
binding.tvLoanPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(paid)}"
|
||||
binding.tvLoanOutstanding.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(outstanding)}"
|
||||
|
||||
val progress = if (loanAmt > 0) ((paid / loanAmt) * 100).toInt().coerceIn(0, 100) else 0
|
||||
binding.loanProgressBar.progress = if (hide) 0 else progress
|
||||
|
||||
binding.tvLoanCompletion.text = bmlCompletionText(detail, ctx)
|
||||
|
||||
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
|
||||
binding.loanDividerDetails.visibility = detailsVisible
|
||||
binding.loanDetailsGroup.visibility = detailsVisible
|
||||
|
||||
if (expanded && detail != null) {
|
||||
binding.tvLoanRepayment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(detail.repayAmount)}"
|
||||
binding.tvLoanIntRate.text = ctx.getString(R.string.loan_rate_fmt, detail.intRate)
|
||||
binding.tvLoanStartDate.text = formatIsoDate(detail.startDate)
|
||||
binding.tvLoanEndDate.text = formatIsoDate(detail.endDate)
|
||||
|
||||
if (detail.overdueAmount > 0) {
|
||||
binding.loanRowOverdue.visibility = View.VISIBLE
|
||||
binding.tvLoanOverdue.text = if (hide) "$currency ••••••"
|
||||
else "$currency ${amountFmt.format(detail.overdueAmount)} (${detail.noOfRepayOverdue})"
|
||||
} else {
|
||||
binding.loanRowOverdue.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bmlCompletionText(detail: BmlLoanDetail?, ctx: android.content.Context): String {
|
||||
if (detail == null) return ""
|
||||
val outstanding = abs(detail.outstandingAmt)
|
||||
if (outstanding <= 0.0 || detail.repayAmount <= 0.0)
|
||||
return ctx.getString(R.string.financing_completion_done)
|
||||
val remaining = ceil(outstanding / detail.repayAmount).toInt()
|
||||
if (remaining <= 0) return ctx.getString(R.string.financing_completion_done)
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.MONTH, remaining)
|
||||
val month = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(cal.time)
|
||||
return ctx.getString(R.string.financing_completion_fmt, month)
|
||||
}
|
||||
|
||||
private fun formatIsoDate(raw: String): String {
|
||||
return try {
|
||||
outputDateFmt.format(isoDateFmt.parse(raw)!!)
|
||||
} catch (_: Exception) { raw.take(10) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TYPE_MIB = 0
|
||||
private const val TYPE_BML = 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.databinding.FragmentFinancingBinding
|
||||
|
||||
class FinancingFragment : Fragment() {
|
||||
@@ -19,6 +22,9 @@ class FinancingFragment : Fragment() {
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
private lateinit var adapter: FinancingAdapter
|
||||
|
||||
private var latestMibDeals: List<MibFinanceDeal> = emptyList()
|
||||
private var latestBmlLoanDetails: Map<String, BmlLoanDetail> = emptyMap()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentFinancingBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
@@ -38,12 +44,35 @@ class FinancingFragment : Fragment() {
|
||||
insets
|
||||
}
|
||||
|
||||
viewModel.financing.observe(viewLifecycleOwner) { deals ->
|
||||
adapter.updateDeals(deals)
|
||||
binding.recyclerView.visibility = if (deals.isEmpty()) View.GONE else View.VISIBLE
|
||||
binding.emptyView.visibility = if (deals.isEmpty()) View.VISIBLE else View.GONE
|
||||
binding.loadingView.visibility = View.GONE
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { rebuildAdapter() }
|
||||
viewModel.financing.observe(viewLifecycleOwner) { deals ->
|
||||
latestMibDeals = deals
|
||||
rebuildAdapter()
|
||||
}
|
||||
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) { details ->
|
||||
latestBmlLoanDetails = details
|
||||
rebuildAdapter()
|
||||
}
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||
}
|
||||
|
||||
private fun rebuildAdapter() {
|
||||
val accounts = viewModel.accounts.value ?: emptyList()
|
||||
val loanAccounts = accounts.filter { it.profileType == "BML_LOAN" }
|
||||
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
|
||||
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
|
||||
|
||||
adapter.update(latestMibDeals, bmlLoans)
|
||||
|
||||
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
|
||||
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
||||
binding.emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE
|
||||
binding.loadingView.visibility = View.GONE
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
||||