Compare commits
201 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
86e1dc0521
|
|||
|
a90d832dba
|
|||
|
51c2dff4b2
|
|||
|
43f3cca2aa
|
|||
|
0e226d17ae
|
|||
|
24021d7eeb
|
|||
|
e997969070
|
|||
|
3182e14873
|
|||
|
52d2eb235b
|
|||
|
ae18a8c6c8
|
|||
|
a8cd22cbe1
|
|||
|
281864347e
|
|||
|
16fd909c7f
|
|||
|
a95ca0e7a5
|
|||
|
286a6f845d
|
|||
|
5b5f776715
|
|||
|
98990544fc
|
|||
|
798e9da9ca
|
|||
|
014c002ebe
|
|||
|
6f8b7130fe
|
|||
|
05430f043a
|
|||
|
80bbacc130
|
|||
|
570e6b750b
|
|||
|
21fbd8b12c
|
|||
|
d0f46e2118
|
|||
|
71002ed70c
|
|||
|
fbc34d6435
|
|||
|
4b1c2419ec
|
|||
|
26dcb20f7f
|
|||
|
33eb33e18c
|
|||
|
6a910facaf
|
|||
|
e3c6b3a695
|
|||
|
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
|
||||
|
||||
@@ -17,6 +17,8 @@ jobs:
|
||||
run: |
|
||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n' | base64 -d > app/key.jks
|
||||
echo "${{ secrets.DOTENV_BASE64 }}" | tr -d '\n' | base64 -d > .build/release/.env
|
||||
echo "ACCOUNT_MVR=${{ vars.ACCOUNT_MVR }}" >> .build/release/.env
|
||||
echo "ACCOUNT_USD=${{ vars.ACCOUNT_USD }}" >> .build/release/.env
|
||||
|
||||
- name: Build APK
|
||||
working-directory: .build/release
|
||||
@@ -87,3 +89,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}"
|
||||
|
||||
@@ -16,5 +16,7 @@ local.properties
|
||||
docs/mibapi/tmp
|
||||
docs/bmlapi/tmp
|
||||
docs/fahipayapi/tmp
|
||||
docs/mfaisaapi/tmp
|
||||
tmp
|
||||
app/key.jks
|
||||
.kotlin/*
|
||||
|
||||
@@ -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-13T17:53:06.478193524Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=ce61d84c" />
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" />
|
||||
</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,32 @@ 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.
|
||||
|
||||
## Contributing
|
||||
|
||||
<blockquote class="twitter-tweet"><p lang="en" dir="ltr">Talk is cheap, send patches.</p>— FFmpeg (@FFmpeg) <a href="https://x.com/FFmpeg/status/1762805900035686805?ref_src=twsrc%5Etfw">February 28, 2024</a></blockquote>
|
||||
|
||||
## License
|
||||
|
||||
GNU General Public License v3.0 - See [LICENSE](LICENSE) file for details
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
}
|
||||
|
||||
val localProps = Properties().also { props ->
|
||||
val f = rootProject.file("local.properties")
|
||||
if (f.exists()) props.load(f.inputStream())
|
||||
}
|
||||
|
||||
fun localOrEnv(key: String, envKey: String) =
|
||||
localProps.getProperty(key) ?: System.getenv(envKey) ?: ""
|
||||
|
||||
android {
|
||||
namespace = "sh.sar.basedbank"
|
||||
compileSdk = 36
|
||||
@@ -11,10 +21,13 @@ android {
|
||||
applicationId = "sh.sar.basedbank"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 3
|
||||
versionName = "1.0.4"
|
||||
versionCode = 22
|
||||
versionName = "1.0.21"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
buildConfigField("String", "ACCOUNT_MVR", "\"${localOrEnv("account.mvr", "ACCOUNT_MVR")}\"")
|
||||
buildConfigField("String", "ACCOUNT_USD", "\"${localOrEnv("account.usd", "ACCOUNT_USD")}\"")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -27,6 +40,10 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
}
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
isMinifyEnabled = false
|
||||
@@ -45,6 +62,7 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +91,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 +105,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,18 @@
|
||||
<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-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
|
||||
|
||||
<application
|
||||
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 +38,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 +67,44 @@
|
||||
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=".service.NotificationPollingService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service
|
||||
android:name=".nfc.BmlHostCardEmulatorService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_NFC_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.nfc.cardemulation.host_apdu_service"
|
||||
android:resource="@xml/bml_aid_list" />
|
||||
</service>
|
||||
|
||||
<!-- Share-sheet alias: "Scan to Pay" receives shared images and decodes their QR code -->
|
||||
<activity-alias
|
||||
android:name=".ScanToPayActivity"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/transfer_scan_qr"
|
||||
android:icon="@drawable/ic_qr_scan">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<provider
|
||||
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,12 @@ 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.mfaisa.MfaisaSession
|
||||
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 +17,46 @@ 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()
|
||||
|
||||
/** Active M-Faisa sessions keyed by loginId (= msisdn). */
|
||||
val mfaisaSessions: MutableMap<String, MfaisaSession> = mutableMapOf()
|
||||
var mfaisaAccounts: 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,23 +79,72 @@ 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_")]
|
||||
|
||||
// ─── M-Faisa helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the M-Faisa session for the given account (matched via loginTag = "mfaisa_${msisdn}"). */
|
||||
fun mfaisaSessionFor(account: BankAccount): MfaisaSession? =
|
||||
mfaisaSessions[account.loginTag.removePrefix("mfaisa_")]
|
||||
|
||||
/**
|
||||
* Re-runs `fetchSubscriber` + `doMobileLogin` using the saved credentials for [loginId] and
|
||||
* replaces the cached session. Call this after catching [sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException].
|
||||
* Returns the fresh session, or null if no credentials are saved for that login.
|
||||
*/
|
||||
fun refreshMfaisaSession(loginId: String): MfaisaSession? {
|
||||
val creds = CredentialStore(this).loadMfaisaCredentials(loginId) ?: return null
|
||||
val flow = sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow(this)
|
||||
flow.fetchSubscriber(creds.msisdn)
|
||||
val result = flow.doMobileLogin(creds.msisdn, creds.pin)
|
||||
mfaisaSessions[loginId] = result.session
|
||||
return result.session
|
||||
}
|
||||
|
||||
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
|
||||
val mibMutex = Mutex()
|
||||
|
||||
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,28 @@ 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() || store.hasMfaisaCredentials()
|
||||
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,16 +7,63 @@ 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()
|
||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() ||
|
||||
store.hasFahipayCredentials() || store.hasMfaisaCredentials()
|
||||
|
||||
// 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
|
||||
@@ -24,7 +71,17 @@ class MainActivity : AppCompatActivity() {
|
||||
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,55 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
|
||||
data class BmlCardActionResult(
|
||||
val success: Boolean,
|
||||
val message: String
|
||||
)
|
||||
|
||||
class BmlCardClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
/**
|
||||
* Freezes or unfreezes a BML card.
|
||||
* @param cardId BML card UUID (BankAccount.internalId)
|
||||
* @param action "freeze" or "unfreeze"
|
||||
*/
|
||||
fun setCardFreezeState(session: BmlSession, cardId: String, action: String): BmlCardActionResult {
|
||||
val body = JSONObject().apply {
|
||||
put("card", cardId)
|
||||
put("action", action)
|
||||
}.toString().toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("$BML_BASE_URL/api/mobile/services/card/freeze")
|
||||
.post(body)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
|
||||
val resp = client.newCall(request).execute()
|
||||
val code = resp.code
|
||||
val responseBody = resp.body?.string()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
if (code in 500..599) throw BankServerException("BML")
|
||||
return try {
|
||||
val json = JSONObject(responseBody ?: "")
|
||||
val ok = json.optBoolean("success") && json.optInt("code") == 0
|
||||
BmlCardActionResult(
|
||||
success = ok,
|
||||
message = json.optString("payload").ifBlank { json.optString("message") }
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
BmlCardActionResult(success = false, message = "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,203 @@
|
||||
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
|
||||
|
||||
data class BmlCardHistoryResult(
|
||||
val statement: List<BankTransaction>,
|
||||
val outstanding: List<BankTransaction>,
|
||||
val unbilled: List<BankTransaction>
|
||||
)
|
||||
|
||||
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
|
||||
): BmlCardHistoryResult {
|
||||
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 BmlCardHistoryResult(emptyList(), emptyList(), emptyList())
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
if (code in 500..599) throw BankServerException("BML")
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return BmlCardHistoryResult(emptyList(), emptyList(), emptyList())
|
||||
val payload = root.optJSONObject("payload")
|
||||
?: return BmlCardHistoryResult(emptyList(), emptyList(), emptyList())
|
||||
|
||||
val outstanding = parseCardArray(
|
||||
payload.optJSONObject("outstanding")?.optJSONArray("CardOutStdAuthDetails"),
|
||||
idPrefix = "auth", accountNumber, accountDisplayName
|
||||
)
|
||||
val unbilled = parseCardArray(
|
||||
payload.optJSONObject("unbilled")?.optJSONArray("CardUnbillTxnDetails"),
|
||||
idPrefix = "unbilled", accountNumber, accountDisplayName
|
||||
)
|
||||
val statement = parseCardArray(
|
||||
payload.optJSONArray("cardstatement"),
|
||||
idPrefix = "stmt", accountNumber, accountDisplayName
|
||||
)
|
||||
|
||||
BmlCardHistoryResult(statement, outstanding, unbilled)
|
||||
} catch (_: Exception) { BmlCardHistoryResult(emptyList(), emptyList(), emptyList()) }
|
||||
}
|
||||
|
||||
private fun parseCardArray(
|
||||
arr: org.json.JSONArray?,
|
||||
idPrefix: String,
|
||||
accountNumber: String,
|
||||
accountDisplayName: String
|
||||
): List<BankTransaction> {
|
||||
if (arr == null) return emptyList()
|
||||
return (0 until arr.length()).map { i ->
|
||||
val item = arr.getJSONObject(i)
|
||||
val ref = item.optString("TranApprCode")
|
||||
BankTransaction(
|
||||
id = "${idPrefix}_${ref.ifBlank { i.toString() }}",
|
||||
date = item.optString("DateTime"),
|
||||
description = item.optString("TranDesc").trim(),
|
||||
amount = item.optDouble("BillingAmount", 0.0),
|
||||
currency = item.optString("BillingCcy", "MVR"),
|
||||
counterpartyName = null,
|
||||
reference = ref.takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchPendingHistory(
|
||||
session: BmlSession,
|
||||
accountId: String,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String
|
||||
): List<BankTransaction> {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/history/pending/$accountId")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return emptyList()
|
||||
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,80 @@
|
||||
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,
|
||||
// BML's MIB verify endpoint doesn't return the MIB account's currency.
|
||||
currency = "",
|
||||
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,35 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
|
||||
object MfaisaAccountClient {
|
||||
|
||||
/**
|
||||
* Build one BankAccount per pocket from a login result.
|
||||
* `loginTag` is "mfaisa_<msisdn>" (one per M-Faisa account on the device).
|
||||
*/
|
||||
fun buildAccounts(result: MfaisaLoginResult, loginTag: String): List<BankAccount> {
|
||||
val displayName = result.profile.name.ifBlank { "M-Faisa" }
|
||||
return result.pockets.map { p ->
|
||||
val balance = "%.2f".format(p.balance)
|
||||
BankAccount(
|
||||
bank = "MFAISA",
|
||||
profileName = displayName,
|
||||
profileType = if (p.pocketValueType == "PAYPAL_USD") "MFAISA_PAYPAL" else "MFAISA",
|
||||
accountNumber = p.pocketId,
|
||||
accountBriefName = p.nickname.ifBlank { p.displayName.ifBlank { "M-Faisa" } },
|
||||
currencyName = p.currency,
|
||||
accountTypeName = p.displayName.ifBlank { "Mobile Wallet" },
|
||||
availableBalance = balance,
|
||||
currentBalance = balance,
|
||||
blockedAmount = "0.00",
|
||||
mvrBalance = if (p.currency == "MVR") balance else "0.00",
|
||||
statusDesc = p.statusType.ifBlank { "ACTIVE" },
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
profileId = result.profile.subscriberId,
|
||||
internalId = result.profile.walletId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
import android.util.Base64
|
||||
import java.math.BigInteger
|
||||
import java.security.KeyFactory
|
||||
import java.security.PublicKey
|
||||
import java.security.SecureRandom
|
||||
import java.security.spec.MGF1ParameterSpec
|
||||
import java.security.spec.RSAPublicKeySpec
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.OAEPParameterSpec
|
||||
import javax.crypto.spec.PSource
|
||||
|
||||
/**
|
||||
* Field-level encryption for Ooredoo M-Faisa request payloads.
|
||||
*
|
||||
* Both keys live (obfuscated) in libnative-lib.so and were extracted by hooking
|
||||
* the live app with Frida.
|
||||
*/
|
||||
object MfaisaCrypto {
|
||||
|
||||
// 1024-bit RSA key. Used for mdnId / mobileNumber / userName.
|
||||
// Plaintext is "960" + msisdn. Cipher is OAEP/SHA-256.
|
||||
private val MOBILE_N = BigInteger(
|
||||
"125043708524451715642963973698406708755269502293565460606118930542682275971580032704131362488150174351194407172452175275612284031366512484449720820404229217064541745811143629538982383390723079478499614160620616911679256603296752844216620113064874342531851472851319065258962732556596958868200227678294957694889"
|
||||
)
|
||||
private val MOBILE_E = BigInteger("65537")
|
||||
|
||||
// 2048-bit RSA key. Used for mPin. Plaintext is `pin + <6-char alphanumeric salt>`.
|
||||
// Cipher is OAEP/SHA-1. Output is hex.
|
||||
private val PIN_N = BigInteger(
|
||||
"30853988905151679601945771998041800603731623930944610745590884250489036547584511246061683594739124713335100655247634233703624305850983479131604065498722268916133039937128796419041248167624160300158401049118446352988895953596475734156239882174799821436218294725935232359347780127398770443981734096915599443841496235741614376221345134752344583283770986295156829944214841171989893291834036934949311011654192369326666754259268756426483563391867503815261490458479377640385950664660570354934951526319509191336410208609648686869010157285218492218371799827560010164293202383337546810220755107741865769246084291990864545504123"
|
||||
)
|
||||
private val PIN_E = BigInteger("65537")
|
||||
|
||||
private val mobileKey: PublicKey by lazy { rsaPublicKey(MOBILE_N, MOBILE_E) }
|
||||
private val pinKey: PublicKey by lazy { rsaPublicKey(PIN_N, PIN_E) }
|
||||
|
||||
private val random = SecureRandom()
|
||||
private const val SALT_ALPHABET =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
/** Encrypts "960" + MSISDN. Output is non-deterministic (OAEP random padding). */
|
||||
fun encryptMobile(msisdn: String): String {
|
||||
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")
|
||||
val params = OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, mobileKey, params)
|
||||
val ct = cipher.doFinal(("960" + msisdn).toByteArray(Charsets.UTF_8))
|
||||
return Base64.encodeToString(ct, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
/** Encrypts `pin + <6-char random alphanumeric salt>`. Output is hex (lowercase). */
|
||||
fun encryptPin(pin: String): String {
|
||||
val salt = buildString {
|
||||
repeat(6) { append(SALT_ALPHABET[random.nextInt(SALT_ALPHABET.length)]) }
|
||||
}
|
||||
val plaintext = (pin + salt).toByteArray(Charsets.UTF_8)
|
||||
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding")
|
||||
val params = OAEPParameterSpec("SHA-1", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, pinKey, params)
|
||||
val ct = cipher.doFinal(plaintext)
|
||||
return ct.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun rsaPublicKey(n: BigInteger, e: BigInteger): PublicKey =
|
||||
KeyFactory.getInstance("RSA").generatePublic(RSAPublicKeySpec(n, e))
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.Adler32
|
||||
|
||||
/**
|
||||
* Fetches the M-Faisa transaction summary for the active subscriber session.
|
||||
*
|
||||
* Endpoint: POST /transactionInquiry/fetchSummary
|
||||
*
|
||||
* Two extra anti-replay fields are required:
|
||||
* - rndValue : RSA-OAEP-SHA1 encryption of a fresh timestamp+salt with the mPin key
|
||||
* (i.e. the same routine as [MfaisaCrypto.encryptPin] applied to a timestamp)
|
||||
* - csValue : Adler32(formDataJson + timestampPlaintext), as a decimal string
|
||||
*/
|
||||
class MfaisaHistoryClient {
|
||||
|
||||
private val baseUrl = "https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web"
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val random = SecureRandom()
|
||||
|
||||
/**
|
||||
* Fetches one page (`pageNo` is 1-based; recordSize defaults to 70 — the official app's value).
|
||||
* Returns the parsed transactions and a flag indicating whether the server returned a full
|
||||
* page (= more pages may be available).
|
||||
*/
|
||||
data class Page(val transactions: List<BankTransaction>, val hasMore: Boolean)
|
||||
|
||||
fun fetchHistory(
|
||||
session: MfaisaSession,
|
||||
accountNumber: String,
|
||||
accountDisplayName: String,
|
||||
pageNo: Int,
|
||||
recordSize: Int = 70
|
||||
): Page {
|
||||
if (session.loginExchangeKey.isBlank() || session.subscriberId.isBlank() || session.msisdn.isBlank()) {
|
||||
throw IllegalStateException("M-Faisa session is missing fields required for fetchSummary")
|
||||
}
|
||||
|
||||
val innerMdn = MfaisaCrypto.encryptMobile(session.msisdn)
|
||||
val outerMdn = MfaisaCrypto.encryptMobile(session.msisdn) // independent encryption
|
||||
|
||||
val formData = JSONObject()
|
||||
.put("actorRole", "RETAIL_SUBSCRIBER")
|
||||
.put("actorRoleId", session.subscriberId)
|
||||
.put("fromDate", "")
|
||||
.put("mdnId", innerMdn)
|
||||
.put("pageNo", pageNo.toString())
|
||||
.put("recordSize", recordSize.toString())
|
||||
.put("toDate", "")
|
||||
.put("transactionType", "")
|
||||
val formJson = formData.toString().matchGsonHtmlSafe()
|
||||
|
||||
// Anti-replay: nonce_str = (currentTimeMillis() + offset). Offset is small noise (0..5).
|
||||
val offset = (random.nextInt(5) + 10) xor 0xE
|
||||
val nonceStr = (System.currentTimeMillis() + offset).toString()
|
||||
// rndValue uses the same key/cipher as the mPin encryption — see [MfaisaCrypto.encryptPin].
|
||||
val rndValue = MfaisaCrypto.encryptPin(nonceStr)
|
||||
val csValue = Adler32().apply { update((formJson + nonceStr).toByteArray(Charsets.UTF_8)) }
|
||||
.value.toString()
|
||||
|
||||
val body = FormBody.Builder()
|
||||
.add("role", "RETAIL_SUBSCRIBER")
|
||||
.add("channel", "SubscriberApp")
|
||||
.add("rndValue", rndValue)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("formData", formJson)
|
||||
.add("mdnId", outerMdn)
|
||||
.add("csValue", csValue)
|
||||
.build()
|
||||
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$baseUrl/transactionInquiry/fetchSummary").post(body).build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val raw = resp.body?.string() ?: throw Exception("Empty history response")
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Ooredoo M-Faisa")
|
||||
|
||||
// The server returns its error envelope as a JSON array even on HTTP 200.
|
||||
val trimmed = raw.trimStart()
|
||||
if (trimmed.startsWith("[")) {
|
||||
val errArr = JSONArray(trimmed)
|
||||
val first = errArr.optJSONObject(0)
|
||||
val errObj = first?.optJSONArray("error")?.optJSONObject(0)
|
||||
val attrVal = errObj?.optString("attributeValue")
|
||||
val errCode = errObj?.optString("errorCode")
|
||||
if (attrVal == "SESSION_EXPIRED" || errCode == "SESSION_EXPIRED") {
|
||||
throw MfaisaSessionExpiredException()
|
||||
}
|
||||
val msg = errObj?.optString("errorMessage") ?: first?.optString("message")
|
||||
throw Exception(msg?.ifBlank { null } ?: "M-Faisa history failed")
|
||||
}
|
||||
|
||||
val obj = JSONObject(trimmed)
|
||||
val arr = obj.optJSONArray("transactionInquiryDTOList") ?: JSONArray()
|
||||
val out = mutableListOf<BankTransaction>()
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.getJSONObject(i) ?: continue
|
||||
out += parse(o, accountNumber, accountDisplayName)
|
||||
}
|
||||
// The server returns nothing useful for "total"; assume more pages exist when this page is full.
|
||||
return Page(out, hasMore = arr.length() >= recordSize)
|
||||
}
|
||||
|
||||
private fun parse(o: JSONObject, accountNumber: String, accountDisplayName: String): BankTransaction {
|
||||
val trnDate = o.optString("trnDate") // "yyyy-MM-dd HH:mm:ss" — already in target format
|
||||
val trnType = o.optString("trnType") // CASH_IN | PURCHASE | TRANSFER | …
|
||||
val status = o.optString("status") // SUCCESS | FAILED
|
||||
val amtObj = o.optJSONObject("transactionAmount") ?: JSONObject()
|
||||
val amount = amtObj.optDouble("amount", 0.0)
|
||||
val currency = amtObj.optString("currencyCode", "MVR")
|
||||
val refId = o.optString("referenceId").ifBlank { o.optString("requestId") }
|
||||
val narration = o.optString("narrationString").ifBlank { trnType.replace("_", " ").lowercase().replaceFirstChar { it.uppercase() } }
|
||||
|
||||
// Direction: CASH_IN / TRANSFER_IN etc. are credits, everything else is a debit
|
||||
val isCredit = trnType.endsWith("_IN") ||
|
||||
trnType == "CASH_IN" ||
|
||||
trnType == "RECEIVE_MONEY"
|
||||
val signedAmount = if (isCredit) amount else -amount
|
||||
|
||||
// Counterparty hint: parse the typeSummaryString for richer info if present
|
||||
val counterparty = extractCounterparty(o)
|
||||
|
||||
// Failed transactions still appear in the list — we still show them but tag in the description.
|
||||
val description = if (status == "FAILED") "$narration · Failed" else narration
|
||||
|
||||
return BankTransaction(
|
||||
id = refId,
|
||||
date = trnDate,
|
||||
description = description,
|
||||
amount = signedAmount,
|
||||
currency = currency,
|
||||
counterpartyName = counterparty,
|
||||
reference = refId.ifBlank { null },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "MFAISA"
|
||||
)
|
||||
}
|
||||
|
||||
/** Best-effort counterparty / merchant name extraction from the response's nested JSON. */
|
||||
private fun extractCounterparty(o: JSONObject): String? {
|
||||
// typeSummaryString is itself a JSON-encoded array string
|
||||
val ts = o.optString("typeSummaryString").trim()
|
||||
if (ts.startsWith("[")) {
|
||||
try {
|
||||
val arr = JSONArray(ts)
|
||||
for (i in 0 until arr.length()) {
|
||||
val item = arr.optJSONObject(i) ?: continue
|
||||
item.optString("Merchant Name").takeIf { it.isNotBlank() }?.let { return it }
|
||||
item.optString("Receiver Name").takeIf { it.isNotBlank() }?.let { return it }
|
||||
item.optString("Sender Name").takeIf { it.isNotBlank() }?.let { return it }
|
||||
}
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
}
|
||||
// sourceMDN like "Shiham-DT Pocket-9609198026" — the bit before the first dash is the user-facing name
|
||||
val source = o.optString("sourceMDN")
|
||||
if (source.isNotBlank() && source.contains("-")) return source.substringBefore("-")
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Match the official app's Gson serialiser: replace `=` with the Unicode-escaped equivalent so
|
||||
* the M-Faisa server's strict parser accepts the payload (same trick as in [MfaisaLoginFlow]).
|
||||
*/
|
||||
private fun String.matchGsonHtmlSafe(): String =
|
||||
replace("\\/", "/").replace("=", "\\u003d")
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MfaisaLoginFlow(context: Context) {
|
||||
|
||||
private val BASE_URL = "https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web"
|
||||
|
||||
// Do NOT set User-Agent explicitly: Cloudflare in front of superapp.ooredoo.mv
|
||||
// fingerprints header order, and an explicit .header("User-Agent", ...) call
|
||||
// pushes it to the front of the request, returning 400. Letting OkHttp's
|
||||
// BridgeInterceptor add its default "okhttp/4.12.0" at the end matches the
|
||||
// official app's on-wire ordering and gets 200.
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Step 0: look up the subscriber by MSISDN and verify they have Full KYC.
|
||||
* Throws [MfaisaKycRequiredException] if kycStatus != "Full KYC".
|
||||
* Throws [MfaisaWalletNotReadyException] if the wallet isn't registered / activated / PIN-set.
|
||||
*/
|
||||
fun fetchSubscriber(msisdn: String): JSONObject {
|
||||
val body = JSONObject()
|
||||
.put("mdnId", MfaisaCrypto.encryptMobile(msisdn))
|
||||
.toString()
|
||||
.matchGsonHtmlSafe()
|
||||
.toRequestBody("application/json; charset=UTF-8".toMediaType())
|
||||
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/fetchSubscriberByMDN")
|
||||
.post(body)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val raw = resp.body?.string() ?: throw Exception("Empty subscriber response")
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Ooredoo M-Faisa")
|
||||
|
||||
val obj = JSONObject(raw)
|
||||
if (!obj.optBoolean("success", false)) {
|
||||
throw Exception(obj.optString("message").ifBlank { "Could not look up this number" })
|
||||
}
|
||||
if (!obj.optBoolean("subscriberRegistered", false)) {
|
||||
throw MfaisaNotRegisteredException()
|
||||
}
|
||||
if (!obj.optBoolean("passwordCreated", false)) {
|
||||
throw MfaisaWalletNotReadyException("Set your M-Faisa mPIN in the Ooredoo SuperApp first, then try again.")
|
||||
}
|
||||
if (obj.optBoolean("activationPending", false)) {
|
||||
throw MfaisaWalletNotReadyException("Your M-Faisa wallet activation is still pending. Complete it in the Ooredoo SuperApp first.")
|
||||
}
|
||||
val kyc = obj.optString("kycStatus")
|
||||
if (kyc != "Full KYC") {
|
||||
throw MfaisaKycRequiredException(kyc)
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1: submit the PIN. Returns parsed login result on success.
|
||||
* Throws [MfaisaInvalidPinException] on a rejected PIN (with [MfaisaInvalidPinException.lastAttempt] = true
|
||||
* if the server's message says "one more wrong attempt will lock your account").
|
||||
*/
|
||||
fun doMobileLogin(msisdn: String, pin: String): MfaisaLoginResult {
|
||||
val mobileEnc = MfaisaCrypto.encryptMobile(msisdn)
|
||||
val userNameEnc = MfaisaCrypto.encryptMobile(msisdn) // independent encryption, same plaintext
|
||||
val pinEnc = MfaisaCrypto.encryptPin(pin)
|
||||
|
||||
val deviceId = androidId()
|
||||
val deviceGeo = JSONObject()
|
||||
.put("appType", "CustomerAndroid")
|
||||
.put("appversion", "1.0")
|
||||
.put("deviceId", deviceId)
|
||||
.put("deviceManufacturer", Build.MANUFACTURER)
|
||||
.put("imieNumber", deviceId)
|
||||
.put("ipaddress", "11.22.33.55")
|
||||
.put("latitude", "0.0")
|
||||
.put("longitude", "0.0")
|
||||
.put("simId", deviceId)
|
||||
|
||||
val formData = JSONObject()
|
||||
.put("deviceGeoInfo", deviceGeo)
|
||||
.put("mPin", pinEnc)
|
||||
.put("mobileNumber", mobileEnc)
|
||||
.put("role", "RETAIL_SUBSCRIBER")
|
||||
.put("tenantCode", "ooredoo")
|
||||
.put("userName", userNameEnc)
|
||||
.toString()
|
||||
.matchGsonHtmlSafe()
|
||||
|
||||
val body = FormBody.Builder()
|
||||
.add("channel", "C03")
|
||||
.add("formData", formData)
|
||||
.add("formDataCs", "null")
|
||||
.build()
|
||||
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/doMobileLogin")
|
||||
.post(body)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val raw = resp.body?.string() ?: throw Exception("Empty login response")
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Ooredoo M-Faisa")
|
||||
|
||||
// Wrong-PIN response is a JSON array; success is an object.
|
||||
val trimmed = raw.trimStart()
|
||||
if (trimmed.startsWith("[")) {
|
||||
val arr = JSONArray(trimmed)
|
||||
val first = arr.optJSONObject(0)
|
||||
val errObj = first?.optJSONArray("error")?.optJSONObject(0)
|
||||
val msg = errObj?.optString("errorMessage")
|
||||
?: first?.optString("message") ?: "Login failed"
|
||||
val lastAttempt = msg.contains("one more", ignoreCase = true) ||
|
||||
msg.contains("will lock", ignoreCase = true)
|
||||
throw MfaisaInvalidPinException(msg, lastAttempt)
|
||||
}
|
||||
|
||||
val obj = try { JSONObject(trimmed) } catch (e: JSONException) {
|
||||
throw Exception("Unexpected login response")
|
||||
}
|
||||
if (!obj.optBoolean("success", false)) {
|
||||
throw MfaisaInvalidPinException(obj.optString("message").ifBlank { "Login failed" }, false)
|
||||
}
|
||||
// Defensive: server also returns kycStatus on success.
|
||||
val kyc = obj.optString("kycStatus")
|
||||
if (kyc.isNotBlank() && kyc != "Full KYC") {
|
||||
throw MfaisaKycRequiredException(kyc)
|
||||
}
|
||||
|
||||
val session = MfaisaSession(
|
||||
loginExchangeKey = obj.optString("loginExchangeKey"),
|
||||
sessionTimeoutSec = obj.optString("mobileLoginSessionTimeout").toIntOrNull() ?: 240,
|
||||
msisdn = msisdn,
|
||||
subscriberId = obj.optString("suscriberId")
|
||||
)
|
||||
|
||||
// pocketDetails[0] holds this user's identity + pockets.
|
||||
val pd = obj.optJSONArray("pocketDetails")?.optJSONObject(0) ?: JSONObject()
|
||||
val profile = MfaisaUserProfile(
|
||||
name = pd.optString("name").ifBlank { "M-Faisa" },
|
||||
email = pd.optString("eMailId"),
|
||||
mdnId = pd.optString("mdnId").ifBlank { msisdn },
|
||||
roleId = pd.optString("roleId"),
|
||||
walletId = pd.optString("walletId"),
|
||||
subscriberId = obj.optString("suscriberId"),
|
||||
offerId = pd.optString("offerId")
|
||||
)
|
||||
val pockets = mutableListOf<MfaisaPocket>()
|
||||
val pktArr = pd.optJSONArray("pocketSummaryDetailsArrayDTO") ?: JSONArray()
|
||||
for (i in 0 until pktArr.length()) {
|
||||
val p = pktArr.getJSONObject(i)
|
||||
val bal = p.optJSONObject("balanceAmount") ?: JSONObject()
|
||||
pockets += MfaisaPocket(
|
||||
pocketId = p.optString("pocketId"),
|
||||
pocketType = p.optString("pocketType"),
|
||||
pocketValueType = p.optString("pocketValueType"),
|
||||
nickname = p.optString("nickName"),
|
||||
currency = bal.optString("currencyCode", "MVR"),
|
||||
balance = bal.optDouble("amount", 0.0),
|
||||
isDefault = p.optBoolean("isDefaultPocket", false),
|
||||
isSecondary = p.optBoolean("isSecondaryPocket", false),
|
||||
statusType = p.optString("statusType"),
|
||||
displayName = p.optString("displayName")
|
||||
)
|
||||
}
|
||||
|
||||
return MfaisaLoginResult(session, profile, pockets)
|
||||
}
|
||||
|
||||
private fun androidId(): String {
|
||||
return Settings.Secure.getString(appContext.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
?: "0000000000000000"
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the body byte-identical to what the official app's Gson serializer emits:
|
||||
* 1. `\/` (org.json's default escape for `/`) → `/`
|
||||
* 2. `=` → `=` (Gson `htmlSafe` mode)
|
||||
* Both are technically valid JSON either way, but the M-Faisa server's parser appears strict.
|
||||
*/
|
||||
private fun String.matchGsonHtmlSafe(): String =
|
||||
replace("\\/", "/").replace("=", "\\u003d")
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
data class MfaisaSession(
|
||||
val loginExchangeKey: String,
|
||||
val sessionTimeoutSec: Int,
|
||||
val msisdn: String = "",
|
||||
val subscriberId: String = ""
|
||||
)
|
||||
|
||||
/** Subset of the doMobileLogin success response we keep around. */
|
||||
data class MfaisaUserProfile(
|
||||
val name: String,
|
||||
val email: String,
|
||||
val mdnId: String,
|
||||
val roleId: String,
|
||||
val walletId: String,
|
||||
val subscriberId: String,
|
||||
val offerId: String
|
||||
)
|
||||
|
||||
/** Pocket = M-Faisa balance bucket (E-Money MVR, IMT MVR, PayPal USD). */
|
||||
data class MfaisaPocket(
|
||||
val pocketId: String,
|
||||
val pocketType: String, // INTERNAL, ...
|
||||
val pocketValueType: String, // EMONEY, PAYPAL_USD, ...
|
||||
val nickname: String,
|
||||
val currency: String, // MVR, USD
|
||||
val balance: Double,
|
||||
val isDefault: Boolean,
|
||||
val isSecondary: Boolean,
|
||||
val statusType: String,
|
||||
val displayName: String
|
||||
)
|
||||
|
||||
data class MfaisaLoginResult(
|
||||
val session: MfaisaSession,
|
||||
val profile: MfaisaUserProfile,
|
||||
val pockets: List<MfaisaPocket>
|
||||
)
|
||||
|
||||
/** Thrown when the wallet is not "Full KYC" — login must abort. */
|
||||
class MfaisaKycRequiredException(val kycStatus: String) :
|
||||
Exception("M-Faisa wallet is not fully verified (kycStatus=$kycStatus)")
|
||||
|
||||
/** Thrown when this MSISDN has no M-Faisa wallet at all — user must sign up in the Ooredoo SuperApp. */
|
||||
class MfaisaNotRegisteredException : Exception("This number does not have an M-Faisa wallet")
|
||||
|
||||
/** Thrown when fetchSubscriberByMDN says the wallet exists but is not yet usable (no PIN, activation pending, …). */
|
||||
class MfaisaWalletNotReadyException(message: String) : Exception(message)
|
||||
|
||||
/**
|
||||
* Thrown for an invalid PIN. The PIN field should be re-enabled.
|
||||
* [lastAttempt] is true when the server's message warns the user one more wrong attempt will lock their account.
|
||||
*/
|
||||
class MfaisaInvalidPinException(message: String, val lastAttempt: Boolean = false) : Exception(message)
|
||||
|
||||
/**
|
||||
* Thrown when a session-scoped M-Faisa endpoint returns the
|
||||
* `[{ ..., "attributeValue": "SESSION_EXPIRED", ... }]` envelope (still as HTTP 200).
|
||||
* Callers should re-run the login (`fetchSubscriber` + `doMobileLogin`) using the saved
|
||||
* credentials and retry the request once.
|
||||
*/
|
||||
class MfaisaSessionExpiredException : Exception("M-Faisa session expired")
|
||||
|
||||
/** Thrown by [MfaisaTransferClient.searchRecipient] when no M-Faisa wallet exists for the queried MSISDN. */
|
||||
class MfaisaRecipientNotFoundException : Exception("No M-Faisa wallet found for this number")
|
||||
|
||||
/** Thrown by [MfaisaTransferClient.confirmTransfer] when the OTP is rejected. */
|
||||
class MfaisaInvalidOtpException(message: String) : Exception(message)
|
||||
@@ -0,0 +1,199 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.Adler32
|
||||
|
||||
/**
|
||||
* M-Faisa merchant QR payment ("smart pay") flow:
|
||||
* 1. [fetchQrDetails] POST /QRCodeUtility/fetchQRCodeById — resolve qrCodeId to merchant
|
||||
* 2. [initiatePurchase] POST /initiateNewBuy — start the purchase, returns referenceId.
|
||||
* Server returns 2FARequired=NONE for wallet QR pay,
|
||||
* so no OTP is required.
|
||||
* 3. [confirmPurchase] POST /confirmNewBuy — settles the purchase. `transactionAuthDetails`
|
||||
* is sent as the literal string "null".
|
||||
*
|
||||
* Anti-replay scheme is the same as [MfaisaTransferClient]: rndValue = encryptPin(timestampStr),
|
||||
* csValue = Adler32(formDataJson + timestampStr). The server responds with `[{...}]` envelopes
|
||||
* for both success and error — callers must check the `success` flag.
|
||||
*/
|
||||
class MfaisaQrPayClient {
|
||||
|
||||
private val baseUrl = "https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web"
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val random = SecureRandom()
|
||||
|
||||
/** Resolved merchant for a scanned M-Faisa QR. */
|
||||
data class QrMerchant(
|
||||
val qrCodeId: String,
|
||||
val merchantId: String, // customerId from the lookup response
|
||||
val merchantName: String, // commercialName
|
||||
val merchantMsisdn: String, // mobileNumber — already includes "960" prefix
|
||||
val currencyCode: String, // e.g. "MVR"
|
||||
/** Pre-set amount for a dynamic QR; null for a static QR (user enters amount). */
|
||||
val txnAmount: String?,
|
||||
val status: String // "Active" for usable QRs
|
||||
)
|
||||
|
||||
// ─── Step 1: resolve qrCodeId → merchant details ─────────────────────────
|
||||
|
||||
fun fetchQrDetails(session: MfaisaSession, qrCodeId: String): QrMerchant {
|
||||
val formData = JSONObject()
|
||||
.put("qrCodeId", qrCodeId)
|
||||
.put("tenantCode", "ooredoo")
|
||||
.toString().matchGsonHtmlSafe()
|
||||
|
||||
val (rnd, cs) = makeAntiReplay(formData)
|
||||
// Note: fetchQRCodeById uses role=R01 (not RETAIL_SUBSCRIBER like the other two endpoints).
|
||||
val body = FormBody.Builder()
|
||||
.add("role", "R01")
|
||||
.add("channel", "C03")
|
||||
.add("rndValue", rnd)
|
||||
.add("formData", formData)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("csValue", cs)
|
||||
.build()
|
||||
|
||||
val first = postAndUnwrap("$baseUrl/QRCodeUtility/fetchQRCodeById", body, "QR lookup failed")
|
||||
val response = first.optJSONArray("response")?.optJSONObject(0)
|
||||
?: throw Exception("QR code not found")
|
||||
if (!response.optString("status").equals("Active", ignoreCase = true)) {
|
||||
throw Exception("QR code is not active")
|
||||
}
|
||||
|
||||
// The lookup response stores absent values as the literal JSON null (decoded by org.json as
|
||||
// `JSONObject.NULL`) — optString surfaces that as the string "null". Guard against both.
|
||||
fun strOrNull(name: String): String? = response.opt(name)
|
||||
?.takeIf { it != JSONObject.NULL }
|
||||
?.toString()
|
||||
?.takeIf { it.isNotBlank() && it != "null" }
|
||||
|
||||
return QrMerchant(
|
||||
qrCodeId = strOrNull("qrCodeId") ?: qrCodeId,
|
||||
merchantId = strOrNull("customerId") ?: throw Exception("Merchant id missing"),
|
||||
merchantName = strOrNull("commercialName") ?: throw Exception("Merchant name missing"),
|
||||
merchantMsisdn = strOrNull("mobileNumber") ?: throw Exception("Merchant number missing"),
|
||||
currencyCode = strOrNull("currencyCode") ?: "MVR",
|
||||
txnAmount = strOrNull("txnAmount"),
|
||||
status = response.optString("status")
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Step 2: initiate the purchase ───────────────────────────────────────
|
||||
|
||||
/** Returns the `referenceId` to be passed to [confirmPurchase]. */
|
||||
fun initiatePurchase(
|
||||
session: MfaisaSession,
|
||||
sourcePocketId: String,
|
||||
sourceMsisdn: String, // user's "960..." MSISDN
|
||||
merchant: QrMerchant,
|
||||
amount: String,
|
||||
description: String = ""
|
||||
): String {
|
||||
val formData = JSONObject()
|
||||
.put("channel", "SubscriberApp")
|
||||
.put("commodityType", "WALLET")
|
||||
.put("description", description)
|
||||
.put("merchantId", merchant.merchantId)
|
||||
.put("mobileNumber", merchant.merchantMsisdn)
|
||||
.put("sourceDetails", JSONObject()
|
||||
.put("MDNId", sourceMsisdn)
|
||||
.put("actorRoleType", "RETAIL_SUBSCRIBER")
|
||||
.put("pocketId", sourcePocketId))
|
||||
.put("transactionAmount", amount)
|
||||
.put("transactionCurrency", merchant.currencyCode)
|
||||
.put("transactionType", "PURCHASE")
|
||||
.toString().matchGsonHtmlSafe()
|
||||
|
||||
val (rnd, cs) = makeAntiReplay(formData)
|
||||
val body = FormBody.Builder()
|
||||
.add("role", "RETAIL_SUBSCRIBER")
|
||||
.add("channel", "C03")
|
||||
.add("rndValue", rnd)
|
||||
.add("formData", formData)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("csValue", cs)
|
||||
.build()
|
||||
|
||||
val first = postAndUnwrap("$baseUrl/initiateNewBuy", body, "Payment initiation failed")
|
||||
// We've only seen 2FARequired=NONE for wallet QR pay. If the server ever asks for OTP we
|
||||
// surface a clear error instead of silently completing a no-op confirm.
|
||||
val twoFa = first.optString("2FARequired").ifBlank { "NONE" }
|
||||
if (!twoFa.equals("NONE", ignoreCase = true)) {
|
||||
throw Exception("This QR requires 2FA ($twoFa) which is not yet supported")
|
||||
}
|
||||
val responseObj = first.optJSONArray("response")?.optJSONObject(0)?.optJSONObject("responseObject")
|
||||
?: throw Exception("Missing responseObject")
|
||||
val refId = responseObj.optString("referenceId")
|
||||
if (refId.isBlank()) throw Exception("Server did not return a referenceId")
|
||||
return refId
|
||||
}
|
||||
|
||||
// ─── Step 3: confirm (no OTP) ────────────────────────────────────────────
|
||||
|
||||
fun confirmPurchase(session: MfaisaSession, referenceId: String) {
|
||||
val formData = JSONObject().put("referenceId", referenceId).toString().matchGsonHtmlSafe()
|
||||
val (rnd, cs) = makeAntiReplay(formData)
|
||||
val body = FormBody.Builder()
|
||||
.add("role", "RETAIL_SUBSCRIBER")
|
||||
.add("channel", "C03")
|
||||
.add("rndValue", rnd)
|
||||
// Literal string "null" — matches the captured request from the official app.
|
||||
.add("transactionAuthDetails", "null")
|
||||
.add("formData", formData)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("csValue", cs)
|
||||
.build()
|
||||
|
||||
postAndUnwrap("$baseUrl/confirmNewBuy", body, "Payment confirmation failed")
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** POSTs [body] to [url], unwraps the `[{...}]` envelope, throws on non-success / session expiry. */
|
||||
private fun postAndUnwrap(url: String, body: okhttp3.RequestBody, fallbackError: String): JSONObject {
|
||||
val resp = client.newCall(Request.Builder().url(url).post(body).build()).execute()
|
||||
val code = resp.code
|
||||
val raw = resp.body?.string() ?: throw Exception("Empty response")
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Ooredoo M-Faisa")
|
||||
|
||||
val arr = JSONArray(raw.trimStart())
|
||||
val first = arr.optJSONObject(0) ?: throw Exception(fallbackError)
|
||||
handleSessionExpiry(first)
|
||||
if (!first.optBoolean("success", false)) {
|
||||
val errObj = first.optJSONArray("error")?.optJSONObject(0)
|
||||
throw Exception(errObj?.optString("errorMessage")?.ifBlank { null }
|
||||
?: first.optString("message").ifBlank { fallbackError })
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
private fun handleSessionExpiry(envelope: JSONObject?) {
|
||||
val attr = envelope?.optJSONArray("error")?.optJSONObject(0)?.optString("attributeValue")
|
||||
val code = envelope?.optJSONArray("error")?.optJSONObject(0)?.optString("errorCode")
|
||||
if (attr == "SESSION_EXPIRED" || code == "SESSION_EXPIRED") throw MfaisaSessionExpiredException()
|
||||
}
|
||||
|
||||
private fun makeAntiReplay(formJson: String): Pair<String, String> {
|
||||
val offset = (random.nextInt(5) + 10) xor 0xE
|
||||
val nonceStr = (System.currentTimeMillis() + offset).toString()
|
||||
val rndValue = MfaisaCrypto.encryptPin(nonceStr)
|
||||
val csValue = Adler32().apply {
|
||||
update((formJson + nonceStr).toByteArray(Charsets.UTF_8))
|
||||
}.value.toString()
|
||||
return rndValue to csValue
|
||||
}
|
||||
|
||||
private fun String.matchGsonHtmlSafe(): String =
|
||||
replace("\\/", "/").replace("=", "\\u003d")
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.Adler32
|
||||
|
||||
/**
|
||||
* Three-step M-Faisa transfer flow:
|
||||
* 1. [searchRecipient] POST /Pocket/basicBeneDetails — look up the recipient
|
||||
* 2. [initiateTransfer] POST /initiateFTRequest — kicks off the transfer; server SMSes an OTP
|
||||
* 3. [confirmTransfer] POST /confirmFTRequest — submit the OTP to actually move the money
|
||||
*
|
||||
* Every request uses the same anti-replay scheme as the history endpoint (see [MfaisaHistoryClient]):
|
||||
* `rndValue` = `encryptPin(timestampStr)` and `csValue` = `Adler32(formDataJson + timestampStr)`.
|
||||
*
|
||||
* On a session timeout the server returns `[{... "attributeValue":"SESSION_EXPIRED" ...}]` with HTTP 200;
|
||||
* each method throws [MfaisaSessionExpiredException] in that case so the caller can re-login and retry.
|
||||
*/
|
||||
class MfaisaTransferClient(private val deviceId: String) {
|
||||
|
||||
private val baseUrl = "https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web"
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val random = SecureRandom()
|
||||
|
||||
// ─── Step 1: recipient lookup ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of [searchRecipient]. The MVR pocket (`isMvr`) is the only target for outgoing transfers
|
||||
* in Thijooree — PayPal pockets are not supported as recipients.
|
||||
*/
|
||||
data class Recipient(
|
||||
val name: String,
|
||||
val msisdn: String, // already includes the "960" prefix, as returned by the server
|
||||
val mvrPocketId: String?,
|
||||
val paypalPocketId: String?,
|
||||
val walletId: String,
|
||||
val actorId: String
|
||||
) {
|
||||
val isMvr: Boolean get() = mvrPocketId != null
|
||||
}
|
||||
|
||||
/** @throws MfaisaRecipientNotFoundException if no M-Faisa wallet exists for [recipientMsisdn]. */
|
||||
fun searchRecipient(session: MfaisaSession, recipientMsisdn: String): Recipient {
|
||||
require(session.msisdn.isNotBlank() && session.subscriberId.isNotBlank()) {
|
||||
"session is missing fields required for basicBeneDetails"
|
||||
}
|
||||
val formData = JSONObject()
|
||||
.put("beneficaryDetails", JSONObject()
|
||||
.put("MDNId", MfaisaCrypto.encryptMobile(recipientMsisdn))
|
||||
.put("actorRoleType", "RETAIL_SUBSCRIBER"))
|
||||
.put("initiatorDetailsDTO", JSONObject()
|
||||
.put("initiatingMDN", MfaisaCrypto.encryptMobile(session.msisdn))
|
||||
.put("initiatingRoleId", session.subscriberId)
|
||||
.put("initiatorRole", "RETAIL_SUBSCRIBER"))
|
||||
.toString().matchGsonHtmlSafe()
|
||||
|
||||
val (rnd, cs) = makeAntiReplay(formData)
|
||||
val body = FormBody.Builder()
|
||||
.add("role", "RETAIL_SUBSCRIBER")
|
||||
.add("channel", "SubscriberApp")
|
||||
.add("rndValue", rnd)
|
||||
.add("formData", formData)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("csValue", cs)
|
||||
.build()
|
||||
|
||||
val raw = execute("$baseUrl/Pocket/basicBeneDetails", body)
|
||||
// Response shape: `[{ success, response: [[pocket1, pocket2, ...]] }]`
|
||||
val arr = JSONArray(raw.trimStart())
|
||||
val first = arr.optJSONObject(0) ?: throw Exception("Unexpected response")
|
||||
if (!first.optBoolean("success", false)) {
|
||||
handleSessionExpiry(first)
|
||||
val msg = first.optString("message")
|
||||
if (msg.contains("not found", ignoreCase = true)) throw MfaisaRecipientNotFoundException()
|
||||
throw Exception(msg.ifBlank { "Recipient lookup failed" })
|
||||
}
|
||||
val outer = first.optJSONArray("response") ?: throw Exception("Empty recipient list")
|
||||
val pockets = outer.optJSONArray(0) ?: throw MfaisaRecipientNotFoundException()
|
||||
if (pockets.length() == 0) throw MfaisaRecipientNotFoundException()
|
||||
|
||||
var name = ""
|
||||
var msisdn = ""
|
||||
var mvr: String? = null
|
||||
var paypal: String? = null
|
||||
var wallet = ""
|
||||
var actor = ""
|
||||
for (i in 0 until pockets.length()) {
|
||||
val p = pockets.getJSONObject(i)
|
||||
if (name.isBlank()) name = p.optString("name")
|
||||
if (msisdn.isBlank()) msisdn = p.optString("MDNId")
|
||||
if (wallet.isBlank()) wallet = p.optString("walletId")
|
||||
if (actor.isBlank()) actor = p.optString("actorId")
|
||||
when (p.optString("pocketValueType")) {
|
||||
"EMONEY" -> mvr = p.optString("pocketId")
|
||||
"PAYPAL_USD" -> paypal = p.optString("pocketId")
|
||||
}
|
||||
}
|
||||
return Recipient(name, msisdn, mvr, paypal, wallet, actor)
|
||||
}
|
||||
|
||||
// ─── Step 2: initiate (server sends OTP) ─────────────────────────────────
|
||||
|
||||
/** Returns the `referenceId` to be passed to [confirmTransfer]. */
|
||||
fun initiateTransfer(
|
||||
session: MfaisaSession,
|
||||
sourcePocketId: String,
|
||||
recipient: Recipient,
|
||||
amount: String,
|
||||
description: String
|
||||
): String {
|
||||
require(recipient.isMvr) { "M-Faisa transfers can only target the recipient's MVR pocket" }
|
||||
|
||||
// Inner formData JSON. The server expects PLAINTEXT mobile numbers here (already
|
||||
// prefixed with "960" — recipient.msisdn already includes that), unlike step 1 which
|
||||
// encrypts them.
|
||||
val formData = JSONObject()
|
||||
.put("MDNId", recipient.msisdn)
|
||||
.put("beneDetails", JSONObject()
|
||||
.put("miscDetails", description)
|
||||
.put("transferMode", "MOBILE"))
|
||||
.put("channel", "SubscriberApp")
|
||||
.put("commodityType", "WALLET")
|
||||
.put("description", description)
|
||||
.put("inputDetailsDTO", JSONObject()
|
||||
.put("deviceId", deviceId)
|
||||
.put("simId", deviceId))
|
||||
.put("mfs-transactionType", "send-money-to-mobile")
|
||||
.put("pocketId", "")
|
||||
.put("sourceDetails", JSONObject()
|
||||
.put("MDNId", "960${session.msisdn}")
|
||||
.put("actorRoleType", "RETAIL_SUBSCRIBER")
|
||||
.put("pocketId", sourcePocketId))
|
||||
.put("transactionAmount", amount)
|
||||
.put("transactionCurrency", "MVR")
|
||||
.put("transferMode", "MOBILE")
|
||||
.toString().matchGsonHtmlSafe()
|
||||
|
||||
val (rnd, cs) = makeAntiReplay(formData)
|
||||
// The "identifier" top-level field is the recipient MDN re-encrypted (matches step 1's
|
||||
// `beneficaryDetails.MDNId` plaintext; independent OAEP randomness gives a different ciphertext).
|
||||
val identifier = MfaisaCrypto.encryptMobile(recipient.msisdn.removePrefix("960"))
|
||||
|
||||
val body = FormBody.Builder()
|
||||
.add("identifier", identifier)
|
||||
.add("role", "RETAIL_SUBSCRIBER")
|
||||
.add("transferMode", "MOBILE")
|
||||
.add("channel", "C03") // NB: top-level "C03", inner formData.channel is "SubscriberApp"
|
||||
.add("rndValue", rnd)
|
||||
.add("formData", formData)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("tPin", "")
|
||||
.add("csValue", cs)
|
||||
.build()
|
||||
|
||||
val raw = execute("$baseUrl/initiateFTRequest", body)
|
||||
val trimmed = raw.trimStart()
|
||||
if (trimmed.startsWith("[")) {
|
||||
val errArr = JSONArray(trimmed)
|
||||
handleSessionExpiry(errArr.optJSONObject(0))
|
||||
val errObj = errArr.optJSONObject(0)?.optJSONArray("error")?.optJSONObject(0)
|
||||
throw Exception(errObj?.optString("errorMessage")?.ifBlank { null } ?: "Transfer initiation failed")
|
||||
}
|
||||
val obj = JSONObject(trimmed)
|
||||
if (!obj.optBoolean("success", false)) {
|
||||
throw Exception(obj.optString("message").ifBlank { "Transfer initiation failed" })
|
||||
}
|
||||
val responseArr = obj.optJSONArray("response") ?: throw Exception("Missing response array")
|
||||
val responseObj = responseArr.optJSONObject(0)?.optJSONObject("responseObject")
|
||||
?: throw Exception("Missing responseObject")
|
||||
val refId = responseObj.optString("referenceId")
|
||||
if (refId.isBlank()) throw Exception("Server did not return a referenceId")
|
||||
return refId
|
||||
}
|
||||
|
||||
// ─── Step 3: confirm with OTP ────────────────────────────────────────────
|
||||
|
||||
/** Submits [otpCode] for [referenceId]. Throws on invalid OTP / server failure. */
|
||||
fun confirmTransfer(session: MfaisaSession, referenceId: String, otpCode: String) {
|
||||
val formData = JSONObject().put("referenceId", referenceId).toString().matchGsonHtmlSafe()
|
||||
val transactionAuthDetails = JSONObject()
|
||||
.put("authenticationType", "OTP")
|
||||
.put("authenticationValue", MfaisaCrypto.encryptPin(otpCode)) // same cipher as PIN
|
||||
.put("otpTransactionType", "TRANSACTION")
|
||||
.put("referenceId", referenceId)
|
||||
.toString().matchGsonHtmlSafe()
|
||||
|
||||
val (rnd, cs) = makeAntiReplay(formData)
|
||||
val body = FormBody.Builder()
|
||||
.add("role", "RETAIL_SUBSCRIBER")
|
||||
.add("channel", "C03")
|
||||
.add("rndValue", rnd)
|
||||
.add("transactionAuthDetails", transactionAuthDetails)
|
||||
.add("formData", formData)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("csValue", cs)
|
||||
.build()
|
||||
|
||||
val raw = execute("$baseUrl/confirmFTRequest", body)
|
||||
val trimmed = raw.trimStart()
|
||||
if (trimmed.startsWith("[")) {
|
||||
val errArr = JSONArray(trimmed)
|
||||
handleSessionExpiry(errArr.optJSONObject(0))
|
||||
val errObj = errArr.optJSONObject(0)?.optJSONArray("error")?.optJSONObject(0)
|
||||
val attr = errObj?.optString("attributeName")
|
||||
val msg = errObj?.optString("errorMessage")?.ifBlank { null }
|
||||
?: errArr.optJSONObject(0)?.optString("message")
|
||||
if (attr.equals("OTP", ignoreCase = true) || (msg ?: "").contains("OTP", ignoreCase = true)) {
|
||||
throw MfaisaInvalidOtpException(msg ?: "Invalid OTP")
|
||||
}
|
||||
throw Exception(msg ?: "Transfer confirmation failed")
|
||||
}
|
||||
val obj = JSONObject(trimmed)
|
||||
if (!obj.optBoolean("success", false)) {
|
||||
throw Exception(obj.optString("message").ifBlank { "Transfer confirmation failed" })
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private fun execute(url: String, body: okhttp3.RequestBody): String {
|
||||
val resp = client.newCall(Request.Builder().url(url).post(body).build()).execute()
|
||||
val code = resp.code
|
||||
val raw = resp.body?.string() ?: throw Exception("Empty response")
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Ooredoo M-Faisa")
|
||||
return raw
|
||||
}
|
||||
|
||||
private fun handleSessionExpiry(envelope: JSONObject?) {
|
||||
val attr = envelope?.optJSONArray("error")?.optJSONObject(0)?.optString("attributeValue")
|
||||
val code = envelope?.optJSONArray("error")?.optJSONObject(0)?.optString("errorCode")
|
||||
if (attr == "SESSION_EXPIRED" || code == "SESSION_EXPIRED") throw MfaisaSessionExpiredException()
|
||||
}
|
||||
|
||||
private fun makeAntiReplay(formJson: String): Pair<String, String> {
|
||||
val offset = (random.nextInt(5) + 10) xor 0xE
|
||||
val nonceStr = (System.currentTimeMillis() + offset).toString()
|
||||
val rndValue = MfaisaCrypto.encryptPin(nonceStr)
|
||||
val csValue = Adler32().apply {
|
||||
update((formJson + nonceStr).toByteArray(Charsets.UTF_8))
|
||||
}.value.toString()
|
||||
return rndValue to csValue
|
||||
}
|
||||
|
||||
private fun String.matchGsonHtmlSafe(): String =
|
||||
replace("\\/", "/").replace("=", "\\u003d")
|
||||
|
||||
companion object {
|
||||
/** Convenience factory that pulls the device identifier the way [MfaisaLoginFlow] does. */
|
||||
fun forContext(context: android.content.Context): MfaisaTransferClient {
|
||||
val id = Settings.Secure.getString(context.applicationContext.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
?: "0000000000000000"
|
||||
// suppress "unused" — kept for symmetry with MfaisaLoginFlow if we later read Build.MANUFACTURER.
|
||||
@Suppress("UNUSED_VARIABLE") val mfg = Build.MANUFACTURER
|
||||
return MfaisaTransferClient(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import android.os.Build
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.ui.home.AppNotification
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val SKIP_TYPES = setOf("Switch Profile", "Log in")
|
||||
private const val MIB_WV_URL = "https://faisamobilex-wv.mib.com.mv"
|
||||
|
||||
class MibActivityHistoryClient {
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val sdf = SimpleDateFormat("dd MMM yyyy HH:mm", Locale.US)
|
||||
|
||||
data class FetchResult(
|
||||
val items: List<AppNotification>, // already filtered (no Switch Profile)
|
||||
val rawCount: Int, // raw items returned by API before filtering
|
||||
val totalCount: Int,
|
||||
val nextStart: Int
|
||||
)
|
||||
|
||||
fun fetchActivity(
|
||||
session: MibSession,
|
||||
loginId: String,
|
||||
start: Int,
|
||||
end: Int
|
||||
): FetchResult {
|
||||
val cookieHeader = "mbmodel=IOS-1.0; " +
|
||||
"xxid=${session.xxid}; " +
|
||||
"IBSID=${session.xxid}; " +
|
||||
"mbnonce=${session.nonceGenerator}; " +
|
||||
"time-tracker=597"
|
||||
|
||||
val formBody = FormBody.Builder()
|
||||
.add("start", start.toString())
|
||||
.add("end", end.toString())
|
||||
.add("includeCount", "1")
|
||||
.build()
|
||||
|
||||
val req = Request.Builder()
|
||||
.url("$MIB_WV_URL/aProfile/getPagedActivityHistory")
|
||||
.header("Cookie", cookieHeader)
|
||||
.header("User-Agent", "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36")
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.post(formBody)
|
||||
.build()
|
||||
|
||||
val body = try {
|
||||
val resp = client.newCall(req).execute()
|
||||
if (!resp.isSuccessful) { resp.close(); return FetchResult(emptyList(), 0, 0, end + 1) }
|
||||
resp.body?.string().also { resp.close() } ?: return FetchResult(emptyList(), 0, 0, end + 1)
|
||||
} catch (_: Exception) { return FetchResult(emptyList(), 0, 0, end + 1) }
|
||||
|
||||
return try {
|
||||
val json = JSONObject(body)
|
||||
if (!json.optBoolean("success")) return FetchResult(emptyList(), 0, 0, end + 1)
|
||||
val totalCount = json.optString("total_count", "0").toIntOrNull() ?: 0
|
||||
val dataArr = json.optJSONArray("data") ?: return FetchResult(emptyList(), 0, totalCount, end + 1)
|
||||
|
||||
val items = mutableListOf<AppNotification>()
|
||||
val rawCount = dataArr.length()
|
||||
for (i in 0 until rawCount) {
|
||||
val obj = dataArr.getJSONObject(i)
|
||||
val activityType = obj.optString("activityType")
|
||||
if (activityType in SKIP_TYPES) continue
|
||||
|
||||
val pa = obj.optString("pa")
|
||||
val activity = obj.optString("activity")
|
||||
val pb = obj.optString("pb")
|
||||
val dateStr = obj.optString("date")
|
||||
|
||||
val message = buildString {
|
||||
append(pa)
|
||||
if (activity.isNotBlank()) { append(" "); append(activity) }
|
||||
if (pb.isNotBlank()) { append(" "); append(pb) }
|
||||
}
|
||||
|
||||
val tsMs = try { sdf.parse(dateStr)?.time ?: System.currentTimeMillis() }
|
||||
catch (_: Exception) { System.currentTimeMillis() }
|
||||
|
||||
val detailFields = mutableListOf<Pair<String, String>>().apply {
|
||||
add("Bank" to "MIB")
|
||||
add("Type" to activityType)
|
||||
if (pa.isNotBlank()) add("By" to pa)
|
||||
if (activity.isNotBlank() && pb.isNotBlank()) add("Action" to "$activity $pb")
|
||||
if (dateStr.isNotBlank()) add("Date" to dateStr)
|
||||
}
|
||||
|
||||
items.add(AppNotification(
|
||||
id = obj.optString("aid"),
|
||||
bank = "MIB",
|
||||
loginId = loginId,
|
||||
group = "ALERTS",
|
||||
title = activityType,
|
||||
message = message,
|
||||
timestampMs = tsMs,
|
||||
isRead = false, // resolved from cache in the sheet
|
||||
detailFields = detailFields
|
||||
))
|
||||
}
|
||||
FetchResult(items, rawCount, totalCount, end + 1)
|
||||
} catch (_: Exception) { FetchResult(emptyList(), 0, 0, end + 1) }
|
||||
}
|
||||
|
||||
// Keeps fetching pages until at least `minCount` non-Switch-Profile items found or all pages exhausted.
|
||||
fun fetchUntilEnough(
|
||||
session: MibSession,
|
||||
loginId: String,
|
||||
minCount: Int = 5,
|
||||
pageSize: Int = 100
|
||||
): FetchResult {
|
||||
val accumulated = mutableListOf<AppNotification>()
|
||||
var start = 1
|
||||
var totalCount = 0
|
||||
|
||||
while (accumulated.size < minCount) {
|
||||
val result = fetchActivity(session, loginId, start, start + pageSize - 1)
|
||||
totalCount = result.totalCount
|
||||
accumulated.addAll(result.items)
|
||||
if (result.rawCount == 0 || start + pageSize - 1 >= totalCount) break
|
||||
start = result.nextStart
|
||||
}
|
||||
return FetchResult(accumulated, accumulated.size, totalCount, start)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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
|
||||
|
||||
data class MibCardActionResult(
|
||||
val success: Boolean,
|
||||
val message: String,
|
||||
val currentStatusCode: String
|
||||
)
|
||||
|
||||
class MibCardsClient {
|
||||
|
||||
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
|
||||
|
||||
private val USER_AGENT = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.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, profileId: 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", USER_AGENT)
|
||||
.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,
|
||||
profileId = profileId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Freezes a MIB card. action = "freeze" or "unfreeze". */
|
||||
fun setCardFreezeState(session: MibSession, cardId: String, action: String, comments: String): MibCardActionResult {
|
||||
val body = FormBody.Builder()
|
||||
.add("cardId", cardId)
|
||||
.add("comments", comments)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("$BASE_WV_URL/ajaxDebitCard/$action")
|
||||
.post(body)
|
||||
.header("Cookie", cookieHeader(session))
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("Accept", "*/*")
|
||||
.header("Origin", BASE_WV_URL)
|
||||
.header("Referer", "$BASE_WV_URL//debitCards/manage?cardId=$cardId&dashurl=1")
|
||||
.build()
|
||||
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val bodyStr = response.body?.string()
|
||||
?: return MibCardActionResult(false, "", "")
|
||||
val json = try { JSONObject(bodyStr) } catch (_: Exception) {
|
||||
return MibCardActionResult(false, "", "")
|
||||
}
|
||||
MibCardActionResult(
|
||||
success = json.optBoolean("success"),
|
||||
message = json.optString("reasonText"),
|
||||
currentStatusCode = json.optString("currentStatusCode")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -70,7 +73,7 @@ class MibHistoryClient {
|
||||
id = item.optString("trxNumber"),
|
||||
date = item.optString("trxDate"),
|
||||
description = item.optString("descr1").trim(),
|
||||
amount = item.optString("baseAmount", "0").toDoubleOrNull() ?: 0.0,
|
||||
amount = item.optString("foreignAmount", "0").toDoubleOrNull() ?: 0.0,
|
||||
currency = item.optString("curCodeDesc"),
|
||||
counterpartyName = item.optString("benefName").takeIf {
|
||||
it.isNotBlank() && it != "null"
|
||||
|
||||
@@ -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,46 +38,26 @@ 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,
|
||||
val accountNumber: String,
|
||||
val bankId: String
|
||||
val bankId: String,
|
||||
val currency: String = "" // "MVR", "USD", or "" if unknown
|
||||
)
|
||||
|
||||
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,
|
||||
val profileId: 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")) {
|
||||
@@ -128,7 +130,10 @@ class MibTransferClient {
|
||||
MibIpsAccountInfo(
|
||||
accountName = json.optString("accountName").trim(),
|
||||
accountNumber = accountNumber,
|
||||
bankId = json.optString("bankBic")
|
||||
bankId = json.optString("bankBic"),
|
||||
// MIB IPS only returns success for MVR cross-bank accounts;
|
||||
// USD cross-bank accounts fail this lookup entirely.
|
||||
currency = "MVR"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -154,10 +159,18 @@ class MibTransferClient {
|
||||
// accountName may be at root or inside a "data" object
|
||||
val name = json.optString("accountName").takeIf { it.isNotBlank() }
|
||||
?: json.optJSONObject("data")?.optString("accountName") ?: ""
|
||||
val currencyCode = json.optString("currencyCode").takeIf { it.isNotBlank() }
|
||||
?: json.optJSONObject("data")?.optString("currencyCode") ?: ""
|
||||
val currency = when (currencyCode) {
|
||||
"840" -> "USD"
|
||||
"462" -> "MVR"
|
||||
else -> ""
|
||||
}
|
||||
MibIpsAccountInfo(
|
||||
accountName = name.trim(),
|
||||
accountNumber = accountNumber,
|
||||
bankId = "MADVMVMV" // MIB
|
||||
bankId = "MADVMVMV", // MIB
|
||||
currency = currency
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package sh.sar.basedbank.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlNotificationsClient
|
||||
import sh.sar.basedbank.api.mib.MibActivityHistoryClient
|
||||
import sh.sar.basedbank.ui.home.AppNotification
|
||||
import sh.sar.basedbank.util.NotificationsCache
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class NotificationPollingService : Service() {
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val app get() = application as BasedBankApp
|
||||
private val notifIdCounter = AtomicInteger(2000)
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannels()
|
||||
startForeground(SERVICE_NOTIF_ID, buildServiceNotification())
|
||||
startPolling()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startPolling() {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
runCatching { poll() }
|
||||
delay(POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun poll() {
|
||||
pollBml()
|
||||
pollMib()
|
||||
}
|
||||
|
||||
private suspend fun pollBml() {
|
||||
val sessions = app.bmlSessions.toMap()
|
||||
if (sessions.isEmpty()) return
|
||||
val client = BmlNotificationsClient()
|
||||
sessions.forEach { (loginId, session) ->
|
||||
val result = try { client.fetchNotifications(session, loginId, page = 1) }
|
||||
catch (_: Exception) { return@forEach }
|
||||
if (result.items.isEmpty()) return@forEach
|
||||
|
||||
val cached = NotificationsCache.loadBml(this@NotificationPollingService, loginId)
|
||||
if (cached.isEmpty()) {
|
||||
NotificationsCache.saveBml(this@NotificationPollingService, loginId, result.items)
|
||||
return@forEach
|
||||
}
|
||||
val cachedIds = cached.map { it.id }.toSet()
|
||||
val newItems = result.items.filter { it.id !in cachedIds }
|
||||
if (newItems.isNotEmpty()) {
|
||||
NotificationsCache.saveBml(this@NotificationPollingService, loginId, result.items)
|
||||
val channelId = ensureLoginChannel("BML", loginId)
|
||||
newItems.forEach { postBankNotification(it, channelId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollMib() {
|
||||
val sessions = app.mibSessions.toMap()
|
||||
if (sessions.isEmpty()) return
|
||||
val client = MibActivityHistoryClient()
|
||||
sessions.forEach { (loginId, session) ->
|
||||
val result = try { client.fetchActivity(session, loginId, 1, 100) }
|
||||
catch (_: Exception) { return@forEach }
|
||||
|
||||
val readIds = NotificationsCache.getMibReadIds(this@NotificationPollingService)
|
||||
val cached = NotificationsCache.loadMib(this@NotificationPollingService, loginId, readIds)
|
||||
if (cached.isEmpty()) {
|
||||
NotificationsCache.saveMib(this@NotificationPollingService, loginId, result.items)
|
||||
return@forEach
|
||||
}
|
||||
val cachedIds = cached.map { it.id }.toSet()
|
||||
val newItems = result.items.filter { it.id !in cachedIds }
|
||||
if (newItems.isNotEmpty()) {
|
||||
val all = (cached + newItems).sortedByDescending { it.timestampMs }
|
||||
NotificationsCache.saveMib(this@NotificationPollingService, loginId, all)
|
||||
val channelId = ensureLoginChannel("MIB", loginId)
|
||||
newItems.forEach { postBankNotification(it, channelId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureLoginChannel(bank: String, loginId: String): String {
|
||||
val channelId = "bank_${bank.lowercase()}_$loginId"
|
||||
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (nm.getNotificationChannel(channelId) == null) {
|
||||
val profileName = when (bank) {
|
||||
"BML" -> app.bmlProfilesMap[loginId]?.firstOrNull()?.name
|
||||
"MIB" -> app.mibProfilesMap[loginId]?.firstOrNull()?.name
|
||||
else -> null
|
||||
} ?: loginId
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(channelId, "$bank · $profileName", NotificationManager.IMPORTANCE_DEFAULT)
|
||||
)
|
||||
}
|
||||
return channelId
|
||||
}
|
||||
|
||||
private fun postBankNotification(notif: AppNotification, channelId: String) {
|
||||
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
val pi = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
packageManager.getLaunchIntentForPackage(packageName),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
val n = Notification.Builder(this, channelId)
|
||||
.setSmallIcon(R.drawable.ic_bell)
|
||||
.setContentTitle(notif.title)
|
||||
.setContentText(notif.message)
|
||||
.setContentIntent(pi)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
nm.notify(notifIdCounter.getAndIncrement(), n)
|
||||
}
|
||||
|
||||
private fun buildServiceNotification(): Notification {
|
||||
val pi = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
packageManager.getLaunchIntentForPackage(packageName),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
return Notification.Builder(this, CHANNEL_SERVICE)
|
||||
.setSmallIcon(R.drawable.ic_bell)
|
||||
.setContentTitle(getString(R.string.notif_service_title))
|
||||
.setContentText(getString(R.string.notif_service_desc))
|
||||
.setContentIntent(pi)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createChannels() {
|
||||
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_SERVICE,
|
||||
getString(R.string.notif_channel_service),
|
||||
NotificationManager.IMPORTANCE_MIN
|
||||
).apply { setShowBadge(false) }
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val POLL_INTERVAL_MS = 30_000L
|
||||
private const val SERVICE_NOTIF_ID = 1001
|
||||
const val CHANNEL_SERVICE = "notif_polling_service"
|
||||
}
|
||||
}
|
||||
@@ -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,28 @@ 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>) {
|
||||
setLeadingSections(listOf("Pending" to transactions))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets one or more labeled sections that render above the main statement list
|
||||
* (e.g. card "Outstanding" + "Unbilled"). Empty sections are skipped.
|
||||
*/
|
||||
fun setLeadingSections(sections: List<Pair<String, List<BankTransaction>>>) {
|
||||
pendingItems.clear()
|
||||
for ((label, transactions) in sections) {
|
||||
if (transactions.isEmpty()) continue
|
||||
pendingItems.add(Item.DateHeader(label))
|
||||
for (trx in transactions) pendingItems.add(Item.Trx(trx, showDate = true))
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private var _showLoadingFooter = false
|
||||
var showLoadingFooter: Boolean
|
||||
get() = _showLoadingFooter
|
||||
@@ -73,7 +110,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 +131,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 +148,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 +184,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 +200,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 +233,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 +269,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 +316,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 +338,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 +353,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,26 @@ 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, non-M-Faisa accounts.
|
||||
// M-Faisa pockets (including PayPal) cannot be set as the default transfer/QR account.
|
||||
val isCard = AccountListParser.from(account)?.isCard ?: false
|
||||
if (!isCard && account.bank != "MFAISA") {
|
||||
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 +140,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 +171,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,13 +201,39 @@ 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()
|
||||
|
||||
fetcher.takeCardPendingSections()?.let { (outstanding, unbilled) ->
|
||||
adapter.setLeadingSections(listOf(
|
||||
"Outstanding" to outstanding,
|
||||
"Unbilled" to unbilled
|
||||
))
|
||||
}
|
||||
|
||||
if (transactions.isNotEmpty()) {
|
||||
@@ -178,6 +261,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,15 +70,17 @@ 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"
|
||||
"MIB" -> "Maldives Islamic Bank"
|
||||
"MFAISA" -> if (account.profileType == "MFAISA_PAYPAL") "PayPal · M-Faisa" else "M-Faisa"
|
||||
else -> account.bank
|
||||
}
|
||||
val profileLabel = when (account.bank) {
|
||||
"MIB" -> account.cifType.ifBlank { account.profileName }
|
||||
"MIB" -> account.productCode.ifBlank { account.profileName }
|
||||
"MFAISA" -> "" // bank-level grouping is already specific (M-Faisa / PayPal · M-Faisa)
|
||||
else -> account.profileName
|
||||
}
|
||||
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
|
||||
@@ -105,29 +120,64 @@ 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
|
||||
"MFAISA" -> R.drawable.ooredoo_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 +196,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
|
||||
@@ -88,6 +89,52 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
categories = cats.filter { it.id != "BML" }
|
||||
if (selectedDest?.isBml == false) setupCategoryDropdown()
|
||||
}
|
||||
|
||||
applyPrefillArgs()
|
||||
}
|
||||
|
||||
private fun applyPrefillArgs() {
|
||||
val args = arguments ?: return
|
||||
val bmlProfileId = args.getString(ARG_BML_PROFILE_ID)
|
||||
val accountNumber = args.getString(ARG_ACCOUNT_NUMBER)
|
||||
val recipientName = args.getString(ARG_RECIPIENT_NAME)
|
||||
val currency = args.getString(ARG_CURRENCY)
|
||||
|
||||
if (bmlProfileId != null) {
|
||||
val match = destinations.firstOrNull { it.isBml && it.bmlLoginId == bmlProfileId }
|
||||
if (match != null) {
|
||||
selectedDest = match
|
||||
binding.actvDestination.setText(match.label, false)
|
||||
updateMibOnlyVisibility()
|
||||
}
|
||||
}
|
||||
|
||||
if (accountNumber != null) {
|
||||
binding.etAccount.setText(accountNumber)
|
||||
}
|
||||
|
||||
// Skip lookup only when we have a MIB-verified name+currency from the caller.
|
||||
if (selectedDest != null && accountNumber != null &&
|
||||
!recipientName.isNullOrBlank() && !currency.isNullOrBlank()
|
||||
) {
|
||||
val bankBic = when {
|
||||
accountNumber.matches(Regex("^9\\d{16}$")) -> "MADVMVMV"
|
||||
accountNumber.matches(Regex("^7\\d{12}$")) -> "MALBMVMV"
|
||||
else -> ""
|
||||
}
|
||||
val trnType = if (accountNumber.matches(Regex("^9\\d{16}$"))) "DOT" else "IAT"
|
||||
val validation = BmlAccountValidation(
|
||||
trnType = trnType,
|
||||
validationType = "prefilled",
|
||||
account = accountNumber,
|
||||
originalInput = accountNumber,
|
||||
name = recipientName,
|
||||
alias = null,
|
||||
currency = currency,
|
||||
agnt = bankBic.takeIf { it.isNotBlank() }
|
||||
)
|
||||
showLookupResult(validation, accountNumber)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDestinations(): List<DestinationOption> {
|
||||
@@ -98,10 +145,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 +291,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 +340,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 +466,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 +533,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)
|
||||
@@ -516,5 +563,24 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
companion object {
|
||||
// BML's internal UUID for MIB bank — used as the "swift" field when saving DOT contacts
|
||||
private const val MIB_SWIFT_ON_BML = "F4E79935-3E73-E611-80DD-00155D020F0A"
|
||||
|
||||
private const val ARG_BML_PROFILE_ID = "bml_profile_id"
|
||||
private const val ARG_ACCOUNT_NUMBER = "account_number"
|
||||
private const val ARG_RECIPIENT_NAME = "recipient_name"
|
||||
private const val ARG_CURRENCY = "currency"
|
||||
|
||||
fun newInstance(
|
||||
bmlProfileId: String? = null,
|
||||
accountNumber: String? = null,
|
||||
recipientName: String? = null,
|
||||
currency: String? = null
|
||||
) = AddContactSheetFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
if (bmlProfileId != null) putString(ARG_BML_PROFILE_ID, bmlProfileId)
|
||||
if (accountNumber != null) putString(ARG_ACCOUNT_NUMBER, accountNumber)
|
||||
if (recipientName != null) putString(ARG_RECIPIENT_NAME, recipientName)
|
||||
if (currency != null) putString(ARG_CURRENCY, currency)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
data class AppNotification(
|
||||
val id: String,
|
||||
val bank: String, // "BML" or "MIB"
|
||||
val loginId: String, // key in bmlSessions / mibSessions
|
||||
val group: String, // "ALERTS" or "INFORMATION"
|
||||
val title: String,
|
||||
val message: String,
|
||||
val timestampMs: Long,
|
||||
val isRead: Boolean,
|
||||
val detailFields: List<Pair<String, String>> = emptyList()
|
||||
)
|
||||