Compare commits
230 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
255f43db24
|
|||
|
01cae559cf
|
|||
|
015919a4ac
|
|||
|
93a7c8bbde
|
|||
|
8f4672f269
|
|||
|
00e6b40ee0
|
|||
|
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
|
|||
|
b1e73533f6
|
|||
|
3a5b9459a9
|
|||
|
9c9729e268
|
|||
|
399cfbf108
|
|||
|
19f4d01015
|
|||
|
8c40322ff0
|
|||
|
782e2e7674
|
|||
|
89a9731797
|
|||
|
50badc7d54
|
|||
|
d4f86bb738
|
|||
|
b35f44f35b
|
|||
|
1a58ce8b54
|
|||
|
2dd84ec50a
|
|||
|
33651ca107
|
|||
|
ae307e3118
|
|||
|
3ab75bff92
|
|||
|
1753d648bd
|
|||
|
423b0bf1e1
|
|||
|
d713047970
|
|||
|
d59a6fad82
|
|||
|
8e47101401
|
|||
|
9431a90cd0
|
|||
|
3a10f36c39
|
@@ -1,11 +1,9 @@
|
|||||||
services:
|
services:
|
||||||
release:
|
release:
|
||||||
# image: git.shihaam.dev/dockerfiles/android-builder
|
|
||||||
image: git.shihaam.dev/dockerfiles/runners/gradle
|
image: git.shihaam.dev/dockerfiles/runners/gradle
|
||||||
hostname: isodroid
|
|
||||||
network_mode: host
|
network_mode: host
|
||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./release:/release
|
- ./release:/release
|
||||||
- ../../:/source
|
- ../../:/source
|
||||||
# - /root/.cache/cache-runners/gradle:/root/.gradle
|
- /root/.cache/cache-runners/gradle:/root/.gradle
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n' | base64 -d > app/key.jks
|
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 "${{ 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
|
- name: Build APK
|
||||||
working-directory: .build/release
|
working-directory: .build/release
|
||||||
@@ -87,3 +89,24 @@ jobs:
|
|||||||
--data-binary "@${ASSET_PATH}"
|
--data-binary "@${ASSET_PATH}"
|
||||||
|
|
||||||
echo "Uploaded asset: $ASSET_NAME"
|
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/mibapi/tmp
|
||||||
docs/bmlapi/tmp
|
docs/bmlapi/tmp
|
||||||
docs/fahipayapi/tmp
|
docs/fahipayapi/tmp
|
||||||
|
docs/mfaisaapi/tmp
|
||||||
tmp
|
tmp
|
||||||
app/key.jks
|
app/key.jks
|
||||||
|
.kotlin/*
|
||||||
|
|||||||
@@ -4,14 +4,27 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2026-05-15T13:54:16.798188666Z">
|
<DropdownSelection timestamp="2026-06-13T17:53:06.478193524Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" />
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
<DialogSelection />
|
<DialogSelection>
|
||||||
|
<targets>
|
||||||
|
<Target type="DEFAULT_BOOT">
|
||||||
|
<handle>
|
||||||
|
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=67d022c2" />
|
||||||
|
</handle>
|
||||||
|
</Target>
|
||||||
|
<Target type="DEFAULT_BOOT">
|
||||||
|
<handle>
|
||||||
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=683a9830" />
|
||||||
|
</handle>
|
||||||
|
</Target>
|
||||||
|
</targets>
|
||||||
|
</DialogSelection>
|
||||||
</SelectionState>
|
</SelectionState>
|
||||||
</selectionStates>
|
</selectionStates>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -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)
|
[](https://sladge.net)
|
||||||
[](LICENSE)
|
[](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
|
## Requirements
|
||||||
|
|
||||||
- Android 8.0+ (API 26)
|
- Android 8.0+ (API 26)
|
||||||
- Existing accounts with MIB, BML, or Fahipay
|
- Existing accounts with MIB, BML, or Fahipay
|
||||||
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
|
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
|
||||||
|
|
||||||
## Building
|
## Download APK
|
||||||
|
[Gitea Releases](https://git.shihaam.dev/shihaam/thijooree/releases)
|
||||||
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.
|
[Telegram Channel](https://t.me/s/thijooreeapks)
|
||||||
|
|
||||||
The release signing config reads from environment variables (`KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`).
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
BasedBank talks directly to each bank's existing mobile API using the same protocol as their official apps, reverse-engineered from the APKs. It does not use any intermediary server — requests go straight from your device to the bank.
|
|
||||||
|
|
||||||
- **MIB**: Blowfish/ECB encrypted JSON over HTTPS with a Diffie-Hellman session key exchange
|
|
||||||
- **BML**: PKCE OAuth 2.0 flow via the BML web login, exchanged for a Bearer token used on the mobile API
|
|
||||||
- **Fahipay**: multipart form login with TOTP, session maintained via `__Secure-sess` cookie and `authid` header
|
|
||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|
||||||
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
|
## Disclaimer
|
||||||
|
|
||||||
This is an unofficial third-party app. It is not affiliated with, endorsed by, or supported by MIB, BML, or Fahipay. Use at your own risk. Review the source code before entering your banking credentials.
|
This is an unofficial third-party app. It is not affiliated with, endorsed by, or supported by MIB, BML, or Fahipay. Use at your own risk. Review the source code before entering your banking credentials.
|
||||||
|
|
||||||
|
## 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 {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
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 {
|
android {
|
||||||
namespace = "sh.sar.basedbank"
|
namespace = "sh.sar.basedbank"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
@@ -11,10 +21,13 @@ android {
|
|||||||
applicationId = "sh.sar.basedbank"
|
applicationId = "sh.sar.basedbank"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 23
|
||||||
versionName = "1.0"
|
versionName = "1.0.22"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
buildConfigField("String", "ACCOUNT_MVR", "\"${localOrEnv("account.mvr", "ACCOUNT_MVR")}\"")
|
||||||
|
buildConfigField("String", "ACCOUNT_USD", "\"${localOrEnv("account.usd", "ACCOUNT_USD")}\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -27,6 +40,10 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
@@ -45,6 +62,7 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding = true
|
viewBinding = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,12 +82,18 @@ dependencies {
|
|||||||
// RecyclerView for accounts list
|
// RecyclerView for accounts list
|
||||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||||
|
|
||||||
|
// CircularProgressDrawable for spinning search icons
|
||||||
|
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||||
|
|
||||||
// OkHttp for API calls
|
// OkHttp for API calls
|
||||||
implementation("com.squareup.okhttp3:okhttp:4.11.0")
|
implementation("com.squareup.okhttp3:okhttp:4.11.0")
|
||||||
|
|
||||||
// Coroutines
|
// Coroutines
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||||
|
|
||||||
|
// ZXing core for QR code generation
|
||||||
|
implementation("com.google.zxing:core:3.5.3")
|
||||||
|
|
||||||
// QR scanning — CameraX + zxing-cpp (MIT, same stack as BinaryEye)
|
// QR scanning — CameraX + zxing-cpp (MIT, same stack as BinaryEye)
|
||||||
implementation("androidx.camera:camera-core:1.4.2")
|
implementation("androidx.camera:camera-core:1.4.2")
|
||||||
implementation("androidx.camera:camera-camera2:1.4.2")
|
implementation("androidx.camera:camera-camera2:1.4.2")
|
||||||
@@ -81,6 +105,9 @@ dependencies {
|
|||||||
// Biometric authentication
|
// Biometric authentication
|
||||||
implementation("androidx.biometric:biometric:1.1.0")
|
implementation("androidx.biometric:biometric:1.1.0")
|
||||||
|
|
||||||
|
// Encrypted SharedPreferences (HCE token store)
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
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.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<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
|
<application
|
||||||
android:name=".BasedBankApp"
|
android:name=".BasedBankApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
@@ -30,6 +38,9 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.shortcuts"
|
||||||
|
android:resource="@xml/shortcuts" />
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
@@ -48,13 +59,52 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.home.HomeActivity"
|
android:name=".ui.home.HomeActivity"
|
||||||
android:exported="false" />
|
android:exported="false"
|
||||||
|
android:windowSoftInputMode="adjustPan" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.home.QrScannerActivity"
|
android:name=".ui.home.QrScannerActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:screenOrientation="portrait" />
|
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
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.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 androidx.appcompat.app.AppCompatDelegate
|
||||||
import com.google.android.material.color.DynamicColors
|
import com.google.android.material.color.DynamicColors
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||||
|
import sh.sar.basedbank.api.bml.BmlProfile
|
||||||
import sh.sar.basedbank.api.bml.BmlSession
|
import sh.sar.basedbank.api.bml.BmlSession
|
||||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.mfaisa.MfaisaSession
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||||
import sh.sar.basedbank.api.mib.MibProfile
|
import sh.sar.basedbank.api.mib.MibProfile
|
||||||
import sh.sar.basedbank.api.mib.MibSession
|
import sh.sar.basedbank.api.mib.MibSession
|
||||||
@@ -14,39 +17,134 @@ import sh.sar.basedbank.util.CredentialStore
|
|||||||
|
|
||||||
class BasedBankApp : Application() {
|
class BasedBankApp : Application() {
|
||||||
|
|
||||||
// Held in memory after successful login; cleared on logout
|
/**
|
||||||
var accounts: List<MibAccount> = emptyList()
|
* Set to true only after the user passes LockActivity or completes fresh login.
|
||||||
var fullName: String = ""
|
* Resets to false on every process restart so direct ADB/root activity launches
|
||||||
var mibSession: MibSession? = null
|
* cannot reach HomeActivity without re-authenticating.
|
||||||
var mibProfiles: List<MibProfile> = emptyList()
|
*/
|
||||||
/** Active BML sessions keyed by loginId (= BML username). */
|
var isUnlocked = false
|
||||||
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
|
|
||||||
var bmlAccounts: List<MibAccount> = emptyList()
|
|
||||||
var fahipaySession: FahipaySession? = null
|
|
||||||
var fahipayAccounts: List<MibAccount> = emptyList()
|
|
||||||
|
|
||||||
/** Returns the BML session for the given account (matched via loginTag). */
|
// Held in memory after successful login; cleared on logout
|
||||||
fun bmlSessionFor(account: MibAccount): BmlSession? =
|
var accounts: List<BankAccount> = emptyList()
|
||||||
bmlSessions[account.loginTag.removePrefix("bml_")]
|
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<BankAccount> = emptyList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active BML sessions keyed by profileId (a globally unique GUID per BML profile).
|
||||||
|
* Use [bmlSessionFor] to look up the session for an account.
|
||||||
|
*/
|
||||||
|
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
|
||||||
|
/** BML profiles per loginId (= BML username). */
|
||||||
|
val bmlProfilesMap: MutableMap<String, List<BmlProfile>> = mutableMapOf()
|
||||||
|
/** BML login flows per loginId — hold the web session (cookies) needed for profile activation. */
|
||||||
|
val bmlLoginFlows: MutableMap<String, BmlLoginFlow> = mutableMapOf()
|
||||||
|
var bmlAccounts: List<BankAccount> = emptyList()
|
||||||
|
|
||||||
|
/** Active Fahipay sessions keyed by loginId (= profileId). */
|
||||||
|
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
|
||||||
|
var fahipayAccounts: List<BankAccount> = emptyList()
|
||||||
|
|
||||||
|
/** 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: BankAccount): MibSession? =
|
||||||
|
mibSessions[account.loginTag.removePrefix("mib_")]
|
||||||
|
|
||||||
|
/** Returns any available MIB session. */
|
||||||
|
fun anyMibSession(): MibSession? = mibSessions.values.firstOrNull()
|
||||||
|
|
||||||
|
/** Returns all MIB profiles across all logins. */
|
||||||
|
fun allMibProfiles(): List<MibProfile> = mibProfilesMap.values.flatten()
|
||||||
|
|
||||||
|
/** Returns the MibLoginFlow for a given loginId, creating and caching it if needed. */
|
||||||
|
fun mibFlowFor(loginId: String): MibLoginFlow =
|
||||||
|
mibLoginFlows.getOrPut(loginId) {
|
||||||
|
MibLoginFlow(CredentialStore(this)).also { flow ->
|
||||||
|
flow.onSessionRefreshed = { session, profiles ->
|
||||||
|
mibSessions[loginId] = session
|
||||||
|
mibProfilesMap[loginId] = profiles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns any available MibLoginFlow. */
|
||||||
|
fun anyMibFlow(): MibLoginFlow? = mibLoginFlows.values.firstOrNull()
|
||||||
|
|
||||||
|
// ─── BML helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the BML session for the given account.
|
||||||
|
* Looks up by profileId first (multi-profile), falls back to loginId (legacy single-profile).
|
||||||
|
*/
|
||||||
|
fun bmlSessionFor(account: BankAccount): BmlSession? {
|
||||||
|
val byProfile = if (account.profileId.isNotBlank()) bmlSessions[account.profileId] else null
|
||||||
|
return byProfile ?: bmlSessions[account.loginTag.removePrefix("bml_")]
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns any available BML session (for non-account-specific operations). */
|
/** Returns any available BML session (for non-account-specific operations). */
|
||||||
fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull()
|
fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns any active BML session for the given loginId.
|
||||||
|
* Tries all profiles for that login; falls back to legacy loginId key.
|
||||||
|
*/
|
||||||
|
fun anyBmlSessionFor(loginId: String): BmlSession? {
|
||||||
|
val profiles = bmlProfilesMap[loginId]
|
||||||
|
if (!profiles.isNullOrEmpty()) {
|
||||||
|
return profiles.firstNotNullOfOrNull { bmlSessions[it.profileId] }
|
||||||
|
}
|
||||||
|
return bmlSessions[loginId]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the BmlLoginFlow for a given loginId, creating and caching it if needed. */
|
||||||
|
fun bmlFlowFor(loginId: String): BmlLoginFlow =
|
||||||
|
bmlLoginFlows.getOrPut(loginId) { BmlLoginFlow() }
|
||||||
|
|
||||||
|
// ─── Fahipay helpers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Returns the Fahipay session for the given account (matched via loginTag = "fahipay_${profileId}"). */
|
||||||
|
fun fahipaySessionFor(account: BankAccount): FahipaySession? =
|
||||||
|
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
|
||||||
|
|
||||||
|
// ─── 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. */
|
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
|
||||||
val mibMutex = Mutex()
|
val mibMutex = Mutex()
|
||||||
|
|
||||||
val mibLoginFlow by lazy {
|
|
||||||
MibLoginFlow(CredentialStore(this)).also { flow ->
|
|
||||||
flow.onSessionRefreshed = { session, profiles ->
|
|
||||||
mibSession = session
|
|
||||||
mibProfiles = profiles
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
// 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")
|
val theme = getSharedPreferences("prefs", MODE_PRIVATE).getString("theme", "system")
|
||||||
AppCompatDelegate.setDefaultNightMode(when (theme) {
|
AppCompatDelegate.setDefaultNightMode(when (theme) {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import androidx.appcompat.app.AppCompatActivity
|
|||||||
import androidx.biometric.BiometricManager
|
import androidx.biometric.BiometricManager
|
||||||
import androidx.biometric.BiometricPrompt
|
import androidx.biometric.BiometricPrompt
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -18,8 +21,8 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.databinding.ActivityLockBinding
|
import sh.sar.basedbank.databinding.ActivityLockBinding
|
||||||
import sh.sar.basedbank.ui.home.HomeActivity
|
import sh.sar.basedbank.ui.home.HomeActivity
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import java.security.MessageDigest
|
import sh.sar.basedbank.util.ThemeHelper
|
||||||
import java.security.SecureRandom
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import javax.crypto.SecretKeyFactory
|
import javax.crypto.SecretKeyFactory
|
||||||
import javax.crypto.spec.PBEKeySpec
|
import javax.crypto.spec.PBEKeySpec
|
||||||
|
|
||||||
@@ -31,7 +34,8 @@ class LockActivity : AppCompatActivity() {
|
|||||||
private lateinit var salt: String
|
private lateinit var salt: String
|
||||||
private lateinit var storedHash: String
|
private lateinit var storedHash: String
|
||||||
private var biometricsEnabled = false
|
private var biometricsEnabled = false
|
||||||
private var isLegacyFormat = false
|
private var autoUnlockPin = false
|
||||||
|
private var pinLength = 4
|
||||||
private var isVerifying = false
|
private var isVerifying = false
|
||||||
|
|
||||||
private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE)
|
private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE)
|
||||||
@@ -39,28 +43,38 @@ class LockActivity : AppCompatActivity() {
|
|||||||
companion object {
|
companion object {
|
||||||
private const val MAX_ATTEMPTS = 5
|
private const val MAX_ATTEMPTS = 5
|
||||||
private const val LOCKOUT_MS = 30_000L
|
private const val LOCKOUT_MS = 30_000L
|
||||||
|
const val EXTRA_RESUME = "resume"
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
ThemeHelper.applyAccent(this)
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
binding = ActivityLockBinding.inflate(layoutInflater)
|
binding = ActivityLockBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
val isLight = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||||
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
|
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)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
method = prefs.getString("security_method", "pin") ?: "pin"
|
method = prefs.getString("security_method", "pin") ?: "pin"
|
||||||
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
|
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
|
||||||
|
autoUnlockPin = prefs.getBoolean("auto_unlock_pin", false)
|
||||||
|
pinLength = prefs.getInt("pin_length", 4)
|
||||||
|
|
||||||
// Try new encrypted format first; fall back to legacy SHA-256
|
val stored = CredentialStore(this).loadSecurityHash() ?: run { finish(); return }
|
||||||
val stored = CredentialStore(this).loadSecurityHash()
|
|
||||||
if (stored != null) {
|
|
||||||
salt = stored.first
|
salt = stored.first
|
||||||
storedHash = stored.second
|
storedHash = stored.second
|
||||||
isLegacyFormat = false
|
|
||||||
} else {
|
|
||||||
salt = prefs.getString("security_salt", "") ?: ""
|
|
||||||
storedHash = prefs.getString("security_hash", "") ?: ""
|
|
||||||
isLegacyFormat = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method == "pin") {
|
if (method == "pin") {
|
||||||
binding.viewPin.visibility = View.VISIBLE
|
binding.viewPin.visibility = View.VISIBLE
|
||||||
@@ -110,8 +124,17 @@ class LockActivity : AppCompatActivity() {
|
|||||||
else
|
else
|
||||||
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
||||||
val btn = MaterialButton(this, null, style).apply {
|
val btn = MaterialButton(this, null, style).apply {
|
||||||
|
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
|
text = key
|
||||||
textSize = 24f
|
textSize = 24f
|
||||||
|
}
|
||||||
insetTop = 0; insetBottom = 0
|
insetTop = 0; insetBottom = 0
|
||||||
minimumWidth = 0; minimumHeight = 0
|
minimumWidth = 0; minimumHeight = 0
|
||||||
cornerRadius = btnSize / 2
|
cornerRadius = btnSize / 2
|
||||||
@@ -130,13 +153,18 @@ class LockActivity : AppCompatActivity() {
|
|||||||
when (key) {
|
when (key) {
|
||||||
"⌫" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() }
|
"⌫" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() }
|
||||||
"✓" -> if (pinDigits.size >= 4) verifyPin()
|
"✓" -> if (pinDigits.size >= 4) verifyPin()
|
||||||
else -> if (pinDigits.size < 8) { pinDigits.add(key.toInt()); updateDots() }
|
else -> if (pinDigits.size < 8) {
|
||||||
|
pinDigits.add(key.toInt())
|
||||||
|
updateDots()
|
||||||
|
if (autoUnlockPin && pinDigits.size == pinLength) verifyPin()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updateDots() {
|
private fun updateDots() {
|
||||||
val n = pinDigits.size
|
val n = pinDigits.size
|
||||||
binding.tvLockPinDots.text = "●".repeat(n) + "○".repeat(maxOf(4 - n, 0))
|
val total = if (autoUnlockPin) pinLength else maxOf(n, 4)
|
||||||
|
binding.tvLockPinDots.text = "●".repeat(n) + "○".repeat(maxOf(total - n, 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun verifyPin() {
|
private fun verifyPin() {
|
||||||
@@ -149,7 +177,6 @@ class LockActivity : AppCompatActivity() {
|
|||||||
val ok = withContext(Dispatchers.Default) { verify(entered) }
|
val ok = withContext(Dispatchers.Default) { verify(entered) }
|
||||||
isVerifying = false
|
isVerifying = false
|
||||||
if (ok) {
|
if (ok) {
|
||||||
migrateIfNeeded(entered)
|
|
||||||
resetFailures()
|
resetFailures()
|
||||||
proceed()
|
proceed()
|
||||||
} else {
|
} else {
|
||||||
@@ -174,7 +201,6 @@ class LockActivity : AppCompatActivity() {
|
|||||||
val ok = withContext(Dispatchers.Default) { verify(entered) }
|
val ok = withContext(Dispatchers.Default) { verify(entered) }
|
||||||
isVerifying = false
|
isVerifying = false
|
||||||
if (ok) {
|
if (ok) {
|
||||||
migrateIfNeeded(entered)
|
|
||||||
resetFailures()
|
resetFailures()
|
||||||
proceed()
|
proceed()
|
||||||
} else {
|
} else {
|
||||||
@@ -192,15 +218,15 @@ class LockActivity : AppCompatActivity() {
|
|||||||
if (remaining <= 0) return false
|
if (remaining <= 0) return false
|
||||||
val secs = ((remaining + 999L) / 1000L).toInt()
|
val secs = ((remaining + 999L) / 1000L).toInt()
|
||||||
val msg = getString(R.string.unlock_locked_out, secs)
|
val msg = getString(R.string.unlock_locked_out, secs)
|
||||||
binding.tvLockPinDots.text = msg
|
binding.tvPinHint.text = msg
|
||||||
binding.root.postDelayed({ updateDots() }, remaining)
|
binding.root.postDelayed({ binding.tvPinHint.text = ""; updateDots() }, remaining)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showFailure() {
|
private fun showFailure() {
|
||||||
val msg = failureMessage()
|
val msg = failureMessage()
|
||||||
binding.tvLockPinDots.text = msg
|
binding.tvPinHint.text = msg
|
||||||
binding.root.postDelayed({ updateDots() }, 1200)
|
binding.root.postDelayed({ binding.tvPinHint.text = ""; updateDots() }, 1200)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun failureMessage(): String {
|
private fun failureMessage(): String {
|
||||||
@@ -216,30 +242,8 @@ class LockActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun verify(input: String): Boolean {
|
private fun verify(input: String): Boolean {
|
||||||
if (storedHash.isBlank()) return false
|
if (storedHash.isBlank()) return false
|
||||||
return if (isLegacyFormat) {
|
|
||||||
sha256Legacy(salt + input) == storedHash
|
|
||||||
} else {
|
|
||||||
val saltBytes = Base64.decode(salt, Base64.NO_WRAP)
|
val saltBytes = Base64.decode(salt, Base64.NO_WRAP)
|
||||||
pbkdf2(input, saltBytes) == storedHash
|
return pbkdf2(input, saltBytes) == storedHash
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* On the first successful unlock after legacy SHA-256 format is detected,
|
|
||||||
* transparently migrate to PBKDF2 + CredentialStore.
|
|
||||||
*/
|
|
||||||
private fun migrateIfNeeded(input: String) {
|
|
||||||
if (!isLegacyFormat) return
|
|
||||||
try {
|
|
||||||
val newSalt = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
|
||||||
val newHash = pbkdf2(input, newSalt)
|
|
||||||
val saltB64 = Base64.encodeToString(newSalt, Base64.NO_WRAP)
|
|
||||||
CredentialStore(this).saveSecurityHash(saltB64, newHash)
|
|
||||||
// Remove legacy plaintext fields
|
|
||||||
getSharedPreferences("prefs", MODE_PRIVATE).edit()
|
|
||||||
.remove("security_salt").remove("security_hash").apply()
|
|
||||||
isLegacyFormat = false
|
|
||||||
} catch (_: Exception) { /* migration will retry next unlock */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun triggerBiometric() {
|
private fun triggerBiometric() {
|
||||||
@@ -270,8 +274,30 @@ class LockActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun proceed() {
|
private fun proceed() {
|
||||||
startActivity(Intent(this, HomeActivity::class.java))
|
(application as BasedBankApp).isUnlocked = true
|
||||||
|
if (intent.getBooleanExtra(EXTRA_RESUME, false)) {
|
||||||
finish()
|
finish()
|
||||||
|
} else {
|
||||||
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Brute-force tracking ──────────────────────────────────────────────────
|
// ── Brute-force tracking ──────────────────────────────────────────────────
|
||||||
@@ -308,9 +334,4 @@ class LockActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Legacy: raw SHA-256(salt + input) — only used for migration path. */
|
|
||||||
private fun sha256Legacy(input: String) = MessageDigest.getInstance("SHA-256")
|
|
||||||
.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,16 +7,63 @@ import sh.sar.basedbank.ui.home.HomeActivity
|
|||||||
import sh.sar.basedbank.ui.login.LoginActivity
|
import sh.sar.basedbank.ui.login.LoginActivity
|
||||||
import sh.sar.basedbank.ui.onboarding.OnboardingActivity
|
import sh.sar.basedbank.ui.onboarding.OnboardingActivity
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
val onboardingDone = prefs.getBoolean("onboarding_done", false)
|
val onboardingDone = prefs.getBoolean("onboarding_done", false)
|
||||||
val securitySet = prefs.getString("security_method", null) != null
|
val securitySet = prefs.getString("security_method", null) != null
|
||||||
val store = CredentialStore(this)
|
val store = CredentialStore(this)
|
||||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() ||
|
||||||
|
store.hasFahipayCredentials() || 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 {
|
val target = when {
|
||||||
!onboardingDone -> OnboardingActivity::class.java
|
!onboardingDone -> OnboardingActivity::class.java
|
||||||
@@ -24,7 +71,17 @@ class MainActivity : AppCompatActivity() {
|
|||||||
securitySet -> LockActivity::class.java // proceed() → HomeActivity
|
securitySet -> LockActivity::class.java // proceed() → HomeActivity
|
||||||
else -> HomeActivity::class.java
|
else -> HomeActivity::class.java
|
||||||
}
|
}
|
||||||
startActivity(Intent(this, target))
|
// No lock screen configured — mark as unlocked so HomeActivity's guard passes
|
||||||
|
if (target == HomeActivity::class.java) {
|
||||||
|
(application as BasedBankApp).isUnlocked = true
|
||||||
|
}
|
||||||
|
|
||||||
|
startActivity(Intent(this, target).apply {
|
||||||
|
if (navDestination != -1) putExtra("nav_destination", navDestination)
|
||||||
|
if (autoScan) putExtra("auto_scan", true)
|
||||||
|
if (autoTapMode) putExtra("auto_tap_mode", true)
|
||||||
|
if (shareQrText != null) putExtra("share_qr_text", shareQrText)
|
||||||
|
})
|
||||||
finish()
|
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.HttpUrl
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
|
||||||
import sh.sar.basedbank.api.mib.Transaction
|
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Base64
|
import java.util.Base64
|
||||||
import java.util.Locale
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class AuthExpiredException : Exception("Session expired")
|
class AuthExpiredException : Exception("Session expired")
|
||||||
@@ -29,9 +25,9 @@ class BmlLoginFlow {
|
|||||||
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
||||||
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
|
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
|
||||||
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
|
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
|
||||||
private val APP_USER_AGENT = "bml-mobile-banking/345 (POCO; Android 14; 22101320I)"
|
private val APP_USER_AGENT = "bml-mobile-banking/348 (${android.os.Build.MANUFACTURER}; Android ${android.os.Build.VERSION.RELEASE}; ${android.os.Build.MODEL})"
|
||||||
private val APP_VERSION = "2.1.43.345"
|
private val APP_VERSION = "2.1.44.348"
|
||||||
private val WEB_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
|
private val WEB_USER_AGENT = "Mozilla/5.0 (Android ${android.os.Build.VERSION.RELEASE}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
|
||||||
|
|
||||||
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
||||||
private val cookieJar = object : CookieJar {
|
private val cookieJar = object : CookieJar {
|
||||||
@@ -53,14 +49,27 @@ class BmlLoginFlow {
|
|||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
private val apiClient = OkHttpClient.Builder()
|
/** PKCE params — generated once per login and reused across all profile activations. */
|
||||||
.connectTimeout(30, TimeUnit.SECONDS)
|
private var codeVerifier: String = ""
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
private var codeChallenge: String = ""
|
||||||
.build()
|
private var deviceId: String = ""
|
||||||
|
|
||||||
/** Full login: returns a BmlSession and the account list. */
|
/** Profiles returned by the last successful [login] call. */
|
||||||
fun login(username: String, password: String, otpSeed: String): Pair<BmlSession, List<MibAccount>> {
|
var lastProfiles: List<BmlProfile> = emptyList()
|
||||||
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session cookies
|
private set
|
||||||
|
|
||||||
|
// ─── Login ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Performs web authentication (login + TOTP) and returns the list of available profiles.
|
||||||
|
* Call [activateProfile] for each profile to obtain an access token + accounts.
|
||||||
|
*/
|
||||||
|
fun login(username: String, password: String, otpSeed: String): List<BmlProfile> {
|
||||||
|
codeVerifier = generateCodeVerifier()
|
||||||
|
codeChallenge = generateCodeChallenge(codeVerifier)
|
||||||
|
deviceId = generateDeviceId()
|
||||||
|
|
||||||
|
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session
|
||||||
client.newCall(
|
client.newCall(
|
||||||
Request.Builder().url("$BASE_URL/web/login")
|
Request.Builder().url("$BASE_URL/web/login")
|
||||||
.header("User-Agent", WEB_USER_AGENT).build()
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
@@ -82,15 +91,14 @@ class BmlLoginFlow {
|
|||||||
loginResp.close()
|
loginResp.close()
|
||||||
if (loginResp.code != 302) throw Exception("Login failed — check your username/password")
|
if (loginResp.code != 302) throw Exception("Login failed — check your username/password")
|
||||||
|
|
||||||
// Step 3: GET 2FA page (refreshes blaze_session)
|
// Step 3: GET 2FA page (refreshes session cookies)
|
||||||
client.newCall(
|
client.newCall(
|
||||||
Request.Builder().url("$BASE_URL/web/login/2fa")
|
Request.Builder().url("$BASE_URL/web/login/2fa")
|
||||||
.header("X-XSRF-TOKEN", xsrf)
|
|
||||||
.header("User-Agent", WEB_USER_AGENT).build()
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
).execute().close()
|
).execute().close()
|
||||||
val xsrf2 = xsrfToken() ?: xsrf
|
val xsrf2 = xsrfToken() ?: xsrf
|
||||||
|
|
||||||
// Step 4: POST OTP
|
// Step 4: POST TOTP
|
||||||
val otp = Totp.generate(otpSeed)
|
val otp = Totp.generate(otpSeed)
|
||||||
val twoFaBody = JSONObject().apply {
|
val twoFaBody = JSONObject().apply {
|
||||||
put("code", otp)
|
put("code", otp)
|
||||||
@@ -104,18 +112,161 @@ class BmlLoginFlow {
|
|||||||
twoFaResp.close()
|
twoFaResp.close()
|
||||||
if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed")
|
if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed")
|
||||||
|
|
||||||
// Step 5: GET /web/profile (sets blaze_identity cookie for profile selection)
|
// Step 5: GET /web/profile — multi-profile accounts return a 200 with a profile picker;
|
||||||
client.newCall(
|
// single-profile accounts skip the picker and redirect straight to /web/redirect with
|
||||||
|
// blaze_identity already set in the response cookies.
|
||||||
|
val profileResp = client.newCall(
|
||||||
Request.Builder().url("$BASE_URL/web/profile")
|
Request.Builder().url("$BASE_URL/web/profile")
|
||||||
.header("X-XSRF-TOKEN", xsrf2)
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
|
).execute()
|
||||||
|
val profileCode = profileResp.code
|
||||||
|
val profileLocation = profileResp.header("Location") ?: ""
|
||||||
|
val profileBody = profileResp.body?.string() ?: ""
|
||||||
|
profileResp.close()
|
||||||
|
|
||||||
|
lastProfiles = if (profileCode == 302) {
|
||||||
|
// Any 302 from GET /web/profile means the server auto-activated the sole profile
|
||||||
|
// and blaze_identity is already set — no profile picker shown.
|
||||||
|
// Use username as a stable temporary profileId (unique per login); it will be
|
||||||
|
// replaced by the real BML customer ID after fetchUserInfo in finishBmlLogin().
|
||||||
|
listOf(BmlProfile(profileId = username, name = "Personal", type = "Profile", profileType = "default", autoActivated = true))
|
||||||
|
} else {
|
||||||
|
parseProfiles(profileBody)
|
||||||
|
}
|
||||||
|
return lastProfiles
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Profile activation ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activates a profile in the current web session and returns the result.
|
||||||
|
*
|
||||||
|
* - Personal profiles (profile_type="default") succeed immediately and return [BmlActivationResult.Success].
|
||||||
|
* - Business profiles (profile_type="business") require SMS/email OTP; returns
|
||||||
|
* [BmlActivationResult.NeedsBusinessOtp] with available channels. Follow up with
|
||||||
|
* [requestBusinessOtp] + [submitBusinessOtp].
|
||||||
|
*/
|
||||||
|
fun activateProfile(profile: BmlProfile, loginTag: String): BmlActivationResult {
|
||||||
|
// Single-profile accounts: server already activated during login() and set blaze_identity.
|
||||||
|
// autoActivated=true is the sentinel for this case — skip the profile GET entirely.
|
||||||
|
if (profile.autoActivated) {
|
||||||
|
val (session, accounts) = doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
|
||||||
|
return BmlActivationResult.Success(session, accounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
val xsrf = xsrfToken()
|
||||||
|
val reqBuilder = Request.Builder()
|
||||||
|
.url("$BASE_URL/web/profile/${profile.profileId}")
|
||||||
|
.header("User-Agent", WEB_USER_AGENT)
|
||||||
|
if (xsrf != null) reqBuilder.header("X-XSRF-TOKEN", xsrf)
|
||||||
|
|
||||||
|
val resp = client.newCall(reqBuilder.build()).execute()
|
||||||
|
val code = resp.code
|
||||||
|
val location = resp.header("Location") ?: ""
|
||||||
|
resp.close()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
code == 409 || (code == 302 && "/web/profile/2fa/business" !in location) -> {
|
||||||
|
// Profile activated — blaze_identity cookie set in response headers.
|
||||||
|
// Any 302 that isn't to the business 2FA page means success.
|
||||||
|
val (session, accounts) = doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
|
||||||
|
BmlActivationResult.Success(session, accounts)
|
||||||
|
}
|
||||||
|
code == 302 && "/web/profile/2fa/business" in location -> {
|
||||||
|
// Business profile: server requires SMS/email OTP
|
||||||
|
val channels = fetchBusinessOtpChannels()
|
||||||
|
BmlActivationResult.NeedsBusinessOtp(channels)
|
||||||
|
}
|
||||||
|
else -> throw Exception("Profile activation failed (HTTP $code)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns available OTP channels for the business 2FA page.
|
||||||
|
* Also refreshes cookies so the subsequent POST has a valid XSRF token.
|
||||||
|
*/
|
||||||
|
private fun fetchBusinessOtpChannels(): List<BmlOtpChannel> {
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/web/profile/2fa/business")
|
||||||
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
|
).execute()
|
||||||
|
val body = resp.body?.string() ?: ""
|
||||||
|
resp.close()
|
||||||
|
return parseBusinessOtpChannels(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends an OTP to [channel] for business profile activation.
|
||||||
|
* Must be called before [submitBusinessOtp].
|
||||||
|
*/
|
||||||
|
fun requestBusinessOtp(channel: String) {
|
||||||
|
val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
|
||||||
|
val body = JSONObject().apply {
|
||||||
|
put("code", "")
|
||||||
|
put("channel", channel)
|
||||||
|
}.toString().toRequestBody("application/json".toMediaType())
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body)
|
||||||
|
.header("X-XSRF-TOKEN", xsrf)
|
||||||
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
|
).execute()
|
||||||
|
val respCode = resp.code
|
||||||
|
resp.close()
|
||||||
|
if (respCode != 302) throw Exception("Failed to request OTP (HTTP $respCode)")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the OTP and activates the business profile.
|
||||||
|
* Returns a new [BmlSession] and accounts on success.
|
||||||
|
* @throws Exception if the OTP is invalid (retry is allowed).
|
||||||
|
*/
|
||||||
|
fun submitBusinessOtp(
|
||||||
|
channel: String,
|
||||||
|
code: String,
|
||||||
|
profile: BmlProfile,
|
||||||
|
loginTag: String
|
||||||
|
): Pair<BmlSession, List<BankAccount>> {
|
||||||
|
// Refresh XSRF token before submitting
|
||||||
|
client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/web/profile/2fa/business")
|
||||||
.header("User-Agent", WEB_USER_AGENT).build()
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
).execute().close()
|
).execute().close()
|
||||||
|
|
||||||
// Step 6: PKCE OAuth authorize → extract auth code
|
val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
|
||||||
val codeVerifier = generateCodeVerifier()
|
val body = JSONObject().apply {
|
||||||
val codeChallenge = generateCodeChallenge(codeVerifier)
|
put("code", code)
|
||||||
val deviceId = generateDeviceId()
|
put("channel", channel)
|
||||||
|
}.toString().toRequestBody("application/json".toMediaType())
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body)
|
||||||
|
.header("X-XSRF-TOKEN", xsrf)
|
||||||
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
|
).execute()
|
||||||
|
val respCode = resp.code
|
||||||
|
val location = resp.header("Location") ?: ""
|
||||||
|
resp.close()
|
||||||
|
|
||||||
|
return when {
|
||||||
|
respCode == 409 || (respCode == 302 && "/web/redirect" in location) ->
|
||||||
|
doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
|
||||||
|
respCode == 302 ->
|
||||||
|
throw Exception("Invalid OTP — please try again")
|
||||||
|
else ->
|
||||||
|
throw Exception("Business OTP verification failed (HTTP $respCode)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── OAuth + account fetch ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completes PKCE OAuth for the currently activated profile (blaze_identity cookie set).
|
||||||
|
* Returns a fresh [BmlSession] and the profile's accounts.
|
||||||
|
*/
|
||||||
|
private fun doOAuthAndFetchAccounts(
|
||||||
|
loginTag: String,
|
||||||
|
profileName: String,
|
||||||
|
profileId: String
|
||||||
|
): Pair<BmlSession, List<BankAccount>> {
|
||||||
val authorizeUrl = HttpUrl.Builder()
|
val authorizeUrl = HttpUrl.Builder()
|
||||||
.scheme("https").host("www.bankofmaldives.com.mv")
|
.scheme("https").host("www.bankofmaldives.com.mv")
|
||||||
.addPathSegments("internetbanking/oauth/authorize")
|
.addPathSegments("internetbanking/oauth/authorize")
|
||||||
@@ -135,14 +286,12 @@ class BmlLoginFlow {
|
|||||||
Request.Builder().url(authorizeUrl)
|
Request.Builder().url(authorizeUrl)
|
||||||
.header("User-Agent", WEB_USER_AGENT).build()
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
).execute()
|
).execute()
|
||||||
|
val location = authorizeResp.header("Location")
|
||||||
authorizeResp.close()
|
authorizeResp.close()
|
||||||
|
|
||||||
val location = authorizeResp.header("Location")
|
val authCode = location?.let { Uri.parse(it).getQueryParameter("code") }
|
||||||
?: throw Exception("OAuth authorize did not redirect")
|
?: throw Exception("OAuth authorize did not return auth code")
|
||||||
val authCode = Uri.parse(location).getQueryParameter("code")
|
|
||||||
?: throw Exception("No auth code in OAuth redirect")
|
|
||||||
|
|
||||||
// Step 7: Exchange auth code for access token
|
|
||||||
val tokenBody = FormBody.Builder()
|
val tokenBody = FormBody.Builder()
|
||||||
.add("Device-ID", deviceId)
|
.add("Device-ID", deviceId)
|
||||||
.add("code", authCode)
|
.add("code", authCode)
|
||||||
@@ -164,563 +313,95 @@ class BmlLoginFlow {
|
|||||||
val tokenObj = JSONObject(tokenJson)
|
val tokenObj = JSONObject(tokenJson)
|
||||||
val accessToken = tokenObj.optString("access_token")
|
val accessToken = tokenObj.optString("access_token")
|
||||||
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
|
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
|
||||||
|
val refreshToken = tokenObj.optString("refresh_token", "")
|
||||||
|
val expiresIn = tokenObj.optLong("expires_in", 0L)
|
||||||
|
val expiresAt = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000L else 0L
|
||||||
|
|
||||||
val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
|
val session = BmlSession(accessToken = accessToken, deviceId = deviceId, refreshToken = refreshToken, expiresAt = expiresAt)
|
||||||
val accounts = fetchAccounts(session, "bml_$username")
|
val accounts = BmlAccountClient().fetchAccounts(session, loginTag, profileName, profileId)
|
||||||
return Pair(session, accounts)
|
return Pair(session, accounts)
|
||||||
}
|
}
|
||||||
|
// ─── Token refresh ───────────────────────────────────────────────────────
|
||||||
fun fetchAccounts(session: BmlSession, loginTag: String): List<MibAccount> {
|
|
||||||
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute()
|
|
||||||
val code = resp.code
|
|
||||||
val json = resp.body?.string()
|
|
||||||
resp.close()
|
|
||||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
|
||||||
return parseDashboard(json ?: return emptyList(), loginTag)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
|
|
||||||
val resp = apiClient.newCall(
|
|
||||||
Request.Builder().url("https://app.bankofmaldives.com.mv/api/v2/foreign-limits")
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", APP_USER_AGENT)
|
|
||||||
.header("x-app-version", APP_VERSION)
|
|
||||||
.header("Accept", "application/json")
|
|
||||||
.build()
|
|
||||||
).execute()
|
|
||||||
val code = resp.code
|
|
||||||
val json = resp.body?.string()
|
|
||||||
resp.close()
|
|
||||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
|
||||||
return parseForeignLimits(json ?: return emptyList())
|
|
||||||
}
|
|
||||||
|
|
||||||
data class BmlUserInfo(
|
|
||||||
val fullName: String,
|
|
||||||
val email: String,
|
|
||||||
val mobile: String,
|
|
||||||
val customerId: String,
|
|
||||||
val idCard: String,
|
|
||||||
val birthdate: String
|
|
||||||
)
|
|
||||||
|
|
||||||
fun fetchUserInfo(session: BmlSession): BmlUserInfo? {
|
|
||||||
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/userinfo")).execute()
|
|
||||||
val json = resp.body?.string() ?: return null
|
|
||||||
resp.close()
|
|
||||||
return try {
|
|
||||||
val root = JSONObject(json)
|
|
||||||
if (!root.optBoolean("success")) return null
|
|
||||||
val user = root.optJSONObject("payload")?.optJSONObject("user") ?: return null
|
|
||||||
BmlUserInfo(
|
|
||||||
fullName = user.optString("fullname").trim(),
|
|
||||||
email = user.optString("email").trim(),
|
|
||||||
mobile = user.optString("mobile_phone").trim(),
|
|
||||||
customerId = user.optString("customer_number").trim(),
|
|
||||||
idCard = user.optString("idcard").trim(),
|
|
||||||
birthdate = user.optString("birthdate").trim()
|
|
||||||
)
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun validateAccount(session: BmlSession, input: String): BmlAccountValidation? {
|
|
||||||
val resp = apiClient.newCall(
|
|
||||||
Request.Builder().url("$BASE_URL/api/mobile/validate/account/$input")
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", APP_USER_AGENT)
|
|
||||||
.header("x-app-version", APP_VERSION)
|
|
||||||
.header("Accept", "application/json")
|
|
||||||
.build()
|
|
||||||
).execute()
|
|
||||||
val json = resp.body?.string() ?: return null
|
|
||||||
resp.close()
|
|
||||||
return try {
|
|
||||||
val root = JSONObject(json)
|
|
||||||
if (!root.optBoolean("success")) return null
|
|
||||||
val payload = root.optJSONObject("payload") ?: return null
|
|
||||||
val trnType = payload.optString("trnType", "")
|
|
||||||
val validationType = payload.optString("validationType", "")
|
|
||||||
if (validationType == "alias") {
|
|
||||||
val cdtrAcct = payload.optJSONObject("CdtrAcct") ?: return null
|
|
||||||
BmlAccountValidation(
|
|
||||||
trnType = trnType,
|
|
||||||
validationType = validationType,
|
|
||||||
account = cdtrAcct.optString("Acct"),
|
|
||||||
originalInput = input,
|
|
||||||
name = payload.optString("contact_name").trim(),
|
|
||||||
alias = null,
|
|
||||||
currency = payload.optString("currency", "MVR"),
|
|
||||||
agnt = cdtrAcct.optString("FinInstnId").takeIf { it.isNotBlank() }
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
BmlAccountValidation(
|
|
||||||
trnType = trnType,
|
|
||||||
validationType = validationType,
|
|
||||||
account = payload.optString("account"),
|
|
||||||
originalInput = input,
|
|
||||||
name = payload.optString("name"),
|
|
||||||
alias = payload.optString("alias").takeIf { it.isNotBlank() && it != "null" },
|
|
||||||
currency = payload.optString("currency", "MVR")
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun verifyMibAccount(session: BmlSession, account: String): BmlAccountValidation? {
|
|
||||||
val resp = apiClient.newCall(
|
|
||||||
Request.Builder().url("$BASE_URL/api/mobile/favara/account-verification/$account/MIB")
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", APP_USER_AGENT)
|
|
||||||
.header("x-app-version", APP_VERSION)
|
|
||||||
.header("Accept", "application/json")
|
|
||||||
.build()
|
|
||||||
).execute()
|
|
||||||
val json = resp.body?.string() ?: return null
|
|
||||||
resp.close()
|
|
||||||
return try {
|
|
||||||
val root = JSONObject(json)
|
|
||||||
if (!root.optBoolean("success")) return null
|
|
||||||
BmlAccountValidation(
|
|
||||||
trnType = "DOT",
|
|
||||||
validationType = "MIB",
|
|
||||||
account = root.optString("account"),
|
|
||||||
originalInput = account,
|
|
||||||
name = root.optString("name"),
|
|
||||||
alias = null,
|
|
||||||
currency = "MVR",
|
|
||||||
agnt = root.optString("agnt").takeIf { it.isNotBlank() }
|
|
||||||
)
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveContact(
|
|
||||||
session: BmlSession,
|
|
||||||
contactType: String,
|
|
||||||
account: String,
|
|
||||||
alias: String,
|
|
||||||
currency: String? = null,
|
|
||||||
name: String? = null,
|
|
||||||
swift: String? = null
|
|
||||||
): Boolean {
|
|
||||||
val bodyObj = JSONObject().apply {
|
|
||||||
put("contact_type", contactType)
|
|
||||||
put("account", account)
|
|
||||||
put("alias", alias)
|
|
||||||
if (currency != null) put("currency", currency)
|
|
||||||
if (name != null) put("name", name)
|
|
||||||
if (swift != null) put("swift", swift)
|
|
||||||
}
|
|
||||||
val resp = apiClient.newCall(
|
|
||||||
Request.Builder().url("$BASE_URL/api/mobile/contacts")
|
|
||||||
.post(bodyObj.toString().toRequestBody("application/json".toMediaType()))
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", APP_USER_AGENT)
|
|
||||||
.header("x-app-version", APP_VERSION)
|
|
||||||
.header("Accept", "application/json")
|
|
||||||
.build()
|
|
||||||
).execute()
|
|
||||||
val json = resp.body?.string() ?: return false
|
|
||||||
resp.close()
|
|
||||||
return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fetchContacts(session: BmlSession, loginId: String): List<MibBeneficiary> {
|
|
||||||
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/contacts")).execute()
|
|
||||||
val json = resp.body?.string() ?: return emptyList()
|
|
||||||
resp.close()
|
|
||||||
return parseContacts(json, loginId)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Step 1 of BML transfer: POST without OTP. Returns true if server responds code=22 (OTP ready).
|
* Uses the saved refresh token to obtain a new access token without re-login.
|
||||||
|
* Returns a new [BmlSession] with updated tokens.
|
||||||
*/
|
*/
|
||||||
fun initiateTransfer(
|
fun refreshSession(session: BmlSession): BmlSession {
|
||||||
session: BmlSession,
|
val body = FormBody.Builder()
|
||||||
debitAccount: String,
|
.add("grant_type", "refresh_token")
|
||||||
creditAccount: String,
|
.add("refresh_token", session.refreshToken)
|
||||||
amount: Double,
|
.add("client_id", CLIENT_ID)
|
||||||
transferType: String,
|
.add("Device-ID", session.deviceId)
|
||||||
currency: String,
|
.add("User-Agent", APP_USER_AGENT)
|
||||||
bank: String? = null
|
.add("x-app-version", APP_VERSION)
|
||||||
): Boolean {
|
|
||||||
val jo = JSONObject().apply {
|
|
||||||
put("debitAccount", debitAccount)
|
|
||||||
put("creditAccount", creditAccount)
|
|
||||||
put("debitAmount", amount)
|
|
||||||
put("transfertype", transferType)
|
|
||||||
put("currency", currency)
|
|
||||||
put("channel", "token")
|
|
||||||
if (bank != null) put("bank", bank)
|
|
||||||
}
|
|
||||||
val body = jo.toString().toRequestBody("application/json".toMediaType())
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url("$BASE_URL/api/mobile/transfer")
|
|
||||||
.post(body)
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", APP_USER_AGENT)
|
|
||||||
.header("x-app-version", APP_VERSION)
|
|
||||||
.header("accept", "application/json")
|
|
||||||
.build()
|
|
||||||
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()
|
.build()
|
||||||
|
val resp = newBmlApiClient().newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/oauth/token").post(body)
|
||||||
|
.header("User-Agent", WEB_USER_AGENT).build()
|
||||||
).execute()
|
).execute()
|
||||||
val code = resp.code
|
val json = resp.body?.string() ?: throw Exception("Empty refresh response")
|
||||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
|
||||||
resp.close()
|
resp.close()
|
||||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
val obj = JSONObject(json)
|
||||||
return try {
|
val newAccess = obj.optString("access_token").takeIf { it.isNotBlank() }
|
||||||
val root = JSONObject(json)
|
?: throw Exception("Token refresh failed")
|
||||||
if (!root.optBoolean("success")) return Pair(emptyList(), 0)
|
val newRefresh = obj.optString("refresh_token", "").ifBlank { session.refreshToken }
|
||||||
val payload = root.optJSONObject("payload") ?: return Pair(emptyList(), 0)
|
val expiresIn = obj.optLong("expires_in", 0L)
|
||||||
val totalPages = payload.optInt("totalPages", 0)
|
val expiresAt = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000L else 0L
|
||||||
val history = payload.optJSONArray("history") ?: return Pair(emptyList(), totalPages)
|
return BmlSession(accessToken = newAccess, deviceId = session.deviceId, refreshToken = newRefresh, expiresAt = expiresAt)
|
||||||
val transactions = (0 until history.length()).map { i ->
|
|
||||||
val item = history.getJSONObject(i)
|
|
||||||
val desc = item.optString("description").trim()
|
|
||||||
val narrative1 = item.optString("narrative1")
|
|
||||||
val date = when (desc) {
|
|
||||||
"Purchase" -> parsePurchaseNarrative1(narrative1) ?: item.optString("bookingDate")
|
|
||||||
"Transfer Debit", "Transfer Credit" -> parseTransferNarrative1(narrative1) ?: item.optString("bookingDate")
|
|
||||||
else -> item.optString("bookingDate")
|
|
||||||
}
|
|
||||||
Transaction(
|
|
||||||
id = item.optString("id"),
|
|
||||||
date = date,
|
|
||||||
description = desc,
|
|
||||||
amount = item.optDouble("amount", 0.0),
|
|
||||||
currency = item.optString("currency"),
|
|
||||||
counterpartyName = item.optString("narrative2").takeIf { it.isNotBlank() },
|
|
||||||
reference = item.optString("reference").takeIf { it.isNotBlank() },
|
|
||||||
accountNumber = accountNumber,
|
|
||||||
accountDisplayName = accountDisplayName,
|
|
||||||
source = "BML"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Pair(transactions, totalPages)
|
|
||||||
} catch (_: Exception) { Pair(emptyList(), 0) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Parsing ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches card statement for a BML prepaid card for the given month ("YYYYMM").
|
* BML web responses are Inertia.js pages — the data is embedded as HTML-escaped JSON
|
||||||
* Returns combined outstanding authorizations + settled statement entries.
|
* in the `data-page="..."` attribute of the root div. This extracts and unescapes it.
|
||||||
*/
|
*/
|
||||||
fun fetchCardHistory(
|
private fun extractInertiaJson(html: String): String? {
|
||||||
session: BmlSession,
|
val match = Regex("""data-page="([^"]+)"""").find(html) ?: return null
|
||||||
cardId: String,
|
return match.groupValues[1]
|
||||||
accountDisplayName: String,
|
.replace(""", "\"")
|
||||||
accountNumber: String,
|
.replace("&", "&")
|
||||||
month: String
|
.replace("'", "'")
|
||||||
): List<Transaction> {
|
.replace("<", "<")
|
||||||
val body = """{"card":"$cardId","month":"$month"}"""
|
.replace(">", ">")
|
||||||
.toRequestBody("application/json".toMediaType())
|
}
|
||||||
val resp = apiClient.newCall(
|
|
||||||
Request.Builder().url("$BASE_URL/api/mobile/card/statement").post(body)
|
private fun parseProfiles(html: String): List<BmlProfile> {
|
||||||
.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 {
|
return try {
|
||||||
|
val json = extractInertiaJson(html) ?: html
|
||||||
val root = JSONObject(json)
|
val root = JSONObject(json)
|
||||||
if (!root.optBoolean("success")) return emptyList()
|
val props = root.optJSONObject("props") ?: return emptyList()
|
||||||
val payload = root.optJSONObject("payload") ?: return emptyList()
|
val profiles = props.optJSONArray("profiles") ?: return emptyList()
|
||||||
val result = mutableListOf<Transaction>()
|
(0 until profiles.length()).mapNotNull { i ->
|
||||||
|
val p = profiles.getJSONObject(i)
|
||||||
// Outstanding authorizations
|
val profileObj = p.optJSONObject("profile") ?: return@mapNotNull null
|
||||||
val authDetails = payload.optJSONObject("outstanding")
|
BmlProfile(
|
||||||
?.optJSONArray("CardOutStdAuthDetails")
|
profileId = p.optString("profile_id"),
|
||||||
if (authDetails != null) {
|
name = p.optString("name"),
|
||||||
for (i in 0 until authDetails.length()) {
|
type = p.optString("type"),
|
||||||
val item = authDetails.getJSONObject(i)
|
profileType = profileObj.optString("profile_type", "default")
|
||||||
result.add(Transaction(
|
|
||||||
id = "auth_${item.optString("TranApprCode")}_$i",
|
|
||||||
date = item.optString("DateTime"),
|
|
||||||
description = item.optString("TranDesc").trim(),
|
|
||||||
amount = item.optDouble("BillingAmount", 0.0),
|
|
||||||
currency = item.optString("BillingCcy", "MVR"),
|
|
||||||
counterpartyName = null,
|
|
||||||
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
|
|
||||||
accountNumber = accountNumber,
|
|
||||||
accountDisplayName = accountDisplayName,
|
|
||||||
source = "BML_CARD"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Settled statement entries
|
|
||||||
val statement = payload.optJSONArray("cardstatement")
|
|
||||||
if (statement != null) {
|
|
||||||
for (i in 0 until statement.length()) {
|
|
||||||
val item = statement.getJSONObject(i)
|
|
||||||
result.add(Transaction(
|
|
||||||
id = "stmt_${item.optString("TranRef", i.toString())}",
|
|
||||||
date = item.optString("TransDate", item.optString("TranDate", "")),
|
|
||||||
description = item.optString("TranDesc", item.optString("Description", "")).trim(),
|
|
||||||
amount = -item.optDouble("TranAmount", 0.0),
|
|
||||||
currency = item.optString("TranCcy", "MVR"),
|
|
||||||
counterpartyName = null,
|
|
||||||
reference = item.optString("TranRef").takeIf { it.isNotBlank() },
|
|
||||||
accountNumber = accountNumber,
|
|
||||||
accountDisplayName = accountDisplayName,
|
|
||||||
source = "BML_CARD"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result
|
|
||||||
} catch (_: Exception) { emptyList() }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun apiRequest(session: BmlSession, url: String) =
|
|
||||||
Request.Builder().url(url)
|
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
|
||||||
.header("User-Agent", APP_USER_AGENT)
|
|
||||||
.header("x-app-version", APP_VERSION)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
private fun parseDashboard(json: String, loginTag: String): List<MibAccount> {
|
|
||||||
val root = JSONObject(json)
|
|
||||||
if (!root.optBoolean("success")) return emptyList()
|
|
||||||
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
|
|
||||||
|
|
||||||
val casaAccounts = mutableListOf<MibAccount>()
|
|
||||||
val prepaidCards = mutableListOf<MibAccount>()
|
|
||||||
|
|
||||||
for (i in 0 until dashboard.length()) {
|
|
||||||
val item = dashboard.getJSONObject(i)
|
|
||||||
val currency = item.optString("currency", "MVR")
|
|
||||||
val accountType = item.optString("account_type", "CASA")
|
|
||||||
val product = item.optString("product")
|
|
||||||
val accountNumber = item.optString("account")
|
|
||||||
val status = item.optString("account_status", "Active")
|
|
||||||
|
|
||||||
val internalId = item.optString("id", "")
|
|
||||||
|
|
||||||
if (accountType == "CASA") {
|
|
||||||
val available = item.optDouble("availableBalance", 0.0)
|
|
||||||
casaAccounts.add(MibAccount(
|
|
||||||
profileName = "Personal",
|
|
||||||
profileType = "BML",
|
|
||||||
accountNumber = accountNumber,
|
|
||||||
accountBriefName = item.optString("alias"),
|
|
||||||
currencyName = currency,
|
|
||||||
accountTypeName = product,
|
|
||||||
availableBalance = "%.2f".format(available),
|
|
||||||
currentBalance = "%.2f".format(item.optDouble("ledgerBalance", 0.0)),
|
|
||||||
blockedAmount = "%.2f".format(item.optDouble("lockedAmount", 0.0)),
|
|
||||||
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
|
||||||
statusDesc = status,
|
|
||||||
profileImageHash = null,
|
|
||||||
loginTag = loginTag,
|
|
||||||
internalId = internalId
|
|
||||||
))
|
|
||||||
} else if (accountType == "Card") {
|
|
||||||
val isVisible = item.optBoolean("account_visible", false)
|
|
||||||
if (!isVisible) continue // debit cards and other hidden cards — skip
|
|
||||||
val isPrepaid = item.optBoolean("prepaid_card", false)
|
|
||||||
val cardBalance = item.optJSONObject("cardBalance")
|
|
||||||
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
|
|
||||||
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
|
|
||||||
prepaidCards.add(MibAccount(
|
|
||||||
profileName = "Personal",
|
|
||||||
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
|
|
||||||
accountNumber = accountNumber,
|
|
||||||
accountBriefName = item.optString("alias").ifBlank { product },
|
|
||||||
currencyName = currency,
|
|
||||||
accountTypeName = product,
|
|
||||||
availableBalance = "%.2f".format(available),
|
|
||||||
currentBalance = "%.2f".format(current),
|
|
||||||
blockedAmount = "0.00",
|
|
||||||
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
|
||||||
statusDesc = status,
|
|
||||||
profileImageHash = null,
|
|
||||||
loginTag = loginTag,
|
|
||||||
internalId = internalId
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return casaAccounts + prepaidCards
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseForeignLimits(json: String): List<BmlForeignLimit> {
|
|
||||||
return try {
|
|
||||||
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() }
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseContacts(json: String, loginId: String = ""): List<MibBeneficiary> {
|
private fun parseBusinessOtpChannels(html: String): List<BmlOtpChannel> {
|
||||||
|
return try {
|
||||||
|
val json = extractInertiaJson(html) ?: html
|
||||||
val root = JSONObject(json)
|
val root = JSONObject(json)
|
||||||
if (!root.optBoolean("success")) return emptyList()
|
val props = root.optJSONObject("props") ?: return emptyList()
|
||||||
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList()
|
val channels = props.optJSONArray("channels") ?: return emptyList()
|
||||||
val result = mutableListOf<MibBeneficiary>()
|
(0 until channels.length()).map { i ->
|
||||||
for (i in 0 until payload.length()) {
|
val c = channels.getJSONObject(i)
|
||||||
val item = payload.getJSONObject(i)
|
BmlOtpChannel(
|
||||||
val account = item.optString("account", "")
|
channel = c.optString("channel"),
|
||||||
if (account.isBlank()) continue
|
description = c.optString("description"),
|
||||||
result.add(MibBeneficiary(
|
masked = c.optString("masked")
|
||||||
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
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun xsrfToken(): String? =
|
private fun xsrfToken(): String? =
|
||||||
cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value
|
cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value
|
||||||
|
|
||||||
@@ -746,4 +427,5 @@ class BmlLoginFlow {
|
|||||||
SecureRandom().nextBytes(bytes)
|
SecureRandom().nextBytes(bytes)
|
||||||
return bytes.joinToString("") { "%02x".format(it) }
|
return bytes.joinToString("") { "%02x".format(it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,40 @@
|
|||||||
package sh.sar.basedbank.api.bml
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
|
||||||
data class BmlSession(
|
data class BmlSession(
|
||||||
val accessToken: String,
|
val accessToken: String,
|
||||||
val deviceId: String
|
val deviceId: String,
|
||||||
|
val refreshToken: String = "",
|
||||||
|
val expiresAt: Long = 0L // Unix millis; 0 = unknown
|
||||||
|
) {
|
||||||
|
fun isExpired() = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
data class BmlProfile(
|
||||||
|
val profileId: String,
|
||||||
|
val name: String,
|
||||||
|
val type: String, // "Profile" (personal) or "Business"
|
||||||
|
val profileType: String, // "default" or "business"
|
||||||
|
val autoActivated: Boolean = false // true for single-profile accounts where server skips the picker
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class BmlOtpChannel(
|
||||||
|
val channel: String,
|
||||||
|
val description: String,
|
||||||
|
val masked: String
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class BmlActivationResult {
|
||||||
|
data class Success(
|
||||||
|
val session: BmlSession,
|
||||||
|
val accounts: List<BankAccount>
|
||||||
|
) : BmlActivationResult()
|
||||||
|
data class NeedsBusinessOtp(
|
||||||
|
val channels: List<BmlOtpChannel>
|
||||||
|
) : BmlActivationResult()
|
||||||
|
}
|
||||||
|
|
||||||
data class BmlAccountValidation(
|
data class BmlAccountValidation(
|
||||||
val trnType: String, // IAT, QTR, DOT
|
val trnType: String, // IAT, QTR, DOT
|
||||||
val validationType: String, // BML, alias, MIB
|
val validationType: String, // BML, alias, MIB
|
||||||
@@ -24,6 +54,43 @@ data class BmlTransferResult(
|
|||||||
val errorMessage: String = ""
|
val errorMessage: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class BmlLoanDetail(
|
||||||
|
val loanAmount: Double,
|
||||||
|
val outstandingAmt: Double, // negative as returned by API
|
||||||
|
val repayAmount: Double,
|
||||||
|
val intRate: Double,
|
||||||
|
val loanStatus: String,
|
||||||
|
val startDate: String, // ISO8601 e.g. "2023-10-26T00:00:00+05:00"
|
||||||
|
val endDate: String,
|
||||||
|
val noOfRepayOverdue: Int,
|
||||||
|
val overdueAmount: Double
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BmlQrPayInfo(
|
||||||
|
val requestId: String, // base64-encoded full QR URL (trxn_hash)
|
||||||
|
val merchantName: String, // narrative1
|
||||||
|
val merchantAddress: String, // narrative2 + narrative3
|
||||||
|
val amount: Double, // 0.0 for static QR
|
||||||
|
val currency: String
|
||||||
|
)
|
||||||
|
|
||||||
|
data class BmlQrPayResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val merchant: String = "",
|
||||||
|
val amount: String = "",
|
||||||
|
val currency: String = "",
|
||||||
|
val errorMessage: String = ""
|
||||||
|
)
|
||||||
|
|
||||||
|
data class 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(
|
data class BmlForeignLimit(
|
||||||
val type: String,
|
val type: String,
|
||||||
val used: Double,
|
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 okhttp3.RequestBody
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
|
||||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
|
||||||
import sh.sar.basedbank.api.mib.Transaction
|
|
||||||
import sh.sar.basedbank.util.AccountInputParser
|
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class FahipayLoginFlow {
|
class FahipayLoginFlow {
|
||||||
|
|
||||||
private val BASE_URL = "https://fahipay.mv"
|
private val BASE_URL = "https://fahipay.mv"
|
||||||
private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android 14; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||||
private val UA_OKHTTP = "okhttp/4.12.0"
|
|
||||||
private val PAGE_SIZE = 15
|
|
||||||
|
|
||||||
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
||||||
private val cookieJar = object : CookieJar {
|
private val cookieJar = object : CookieJar {
|
||||||
@@ -144,164 +138,6 @@ class FahipayLoginFlow {
|
|||||||
?: throw Exception("No authID in OTP response")
|
?: throw Exception("No authID in OTP response")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fetchProfile(session: FahipaySession): FahipayUserProfile {
|
|
||||||
val resp = client.newCall(
|
|
||||||
Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en")
|
|
||||||
.header("authid", session.authId)
|
|
||||||
.header("content-type", "multipart/form-data")
|
|
||||||
.header("User-Agent", UA_OKHTTP)
|
|
||||||
.build()
|
|
||||||
).execute()
|
|
||||||
val json = resp.body?.string() ?: throw Exception("Empty profile response")
|
|
||||||
resp.close()
|
|
||||||
|
|
||||||
val obj = JSONObject(json)
|
|
||||||
val props = obj.optJSONObject("props") ?: JSONObject()
|
|
||||||
return FahipayUserProfile(
|
|
||||||
fullName = obj.optString("fullname").trim(),
|
|
||||||
email = obj.optString("email").trim(),
|
|
||||||
mobile = obj.optString("mobile").trim(),
|
|
||||||
nid = obj.optString("nid").trim(),
|
|
||||||
profileId = obj.optString("profileID").trim(),
|
|
||||||
walletAccount = props.optString("acc", ""),
|
|
||||||
linkedAccounts = props.optJSONObject("accs")?.toString() ?: "{}"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun fetchBalance(session: FahipaySession): Double {
|
|
||||||
val resp = client.newCall(
|
|
||||||
Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en")
|
|
||||||
.header("authid", session.authId)
|
|
||||||
.header("content-type", "multipart/form-data")
|
|
||||||
.header("User-Agent", UA_OKHTTP)
|
|
||||||
.build()
|
|
||||||
).execute()
|
|
||||||
val json = resp.body?.string() ?: return 0.0
|
|
||||||
resp.close()
|
|
||||||
return try {
|
|
||||||
val obj = JSONObject(json)
|
|
||||||
if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0)
|
|
||||||
} catch (_: Exception) { 0.0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): MibAccount =
|
|
||||||
MibAccount(
|
|
||||||
profileName = profile.fullName.ifBlank { "Fahipay" },
|
|
||||||
profileType = "FAHIPAY",
|
|
||||||
accountNumber = profile.walletAccount,
|
|
||||||
accountBriefName = "Fahipay Wallet",
|
|
||||||
currencyName = "MVR",
|
|
||||||
accountTypeName = "Digital Wallet",
|
|
||||||
availableBalance = "%.2f".format(balance),
|
|
||||||
currentBalance = "%.2f".format(balance),
|
|
||||||
blockedAmount = "0.00",
|
|
||||||
mvrBalance = "%.2f".format(balance),
|
|
||||||
statusDesc = "Active",
|
|
||||||
profileImageHash = null,
|
|
||||||
loginTag = loginTag,
|
|
||||||
internalId = profile.profileId
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches paginated activity history.
|
|
||||||
* @param start offset (0-based)
|
|
||||||
* @return Pair of (transactions, total count)
|
|
||||||
*/
|
|
||||||
fun fetchHistory(
|
|
||||||
session: FahipaySession,
|
|
||||||
accountDisplayName: String,
|
|
||||||
accountNumber: String,
|
|
||||||
start: Int
|
|
||||||
): Pair<List<Transaction>, Int> {
|
|
||||||
val resp = client.newCall(
|
|
||||||
Request.Builder()
|
|
||||||
.url("$BASE_URL/actions/activity/?s=$start&l=$PAGE_SIZE&lang=en")
|
|
||||||
.header("authid", session.authId)
|
|
||||||
.header("content-type", "multipart/form-data")
|
|
||||||
.header("User-Agent", UA_OKHTTP)
|
|
||||||
.build()
|
|
||||||
).execute()
|
|
||||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
|
||||||
resp.close()
|
|
||||||
return try {
|
|
||||||
val obj = JSONObject(json)
|
|
||||||
val total = obj.optInt("total", 0)
|
|
||||||
val entries = obj.optJSONArray("entries") ?: return Pair(emptyList(), total)
|
|
||||||
val list = (0 until entries.length()).map { i ->
|
|
||||||
val e = entries.getJSONObject(i)
|
|
||||||
Transaction(
|
|
||||||
id = e.optString("transaction"),
|
|
||||||
date = e.optString("date"),
|
|
||||||
description = e.optString("name").trim(),
|
|
||||||
amount = e.optDouble("amount", 0.0),
|
|
||||||
currency = "MVR",
|
|
||||||
counterpartyName = e.optString("details").takeIf { it.isNotBlank() },
|
|
||||||
reference = e.optString("transaction").takeIf { it.isNotBlank() },
|
|
||||||
accountNumber = accountNumber,
|
|
||||||
accountDisplayName = accountDisplayName,
|
|
||||||
source = "FAHIPAY",
|
|
||||||
iconUrl = e.optString("icon").takeIf { it.isNotBlank() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Pair(list, total)
|
|
||||||
} catch (_: Exception) { Pair(emptyList(), 0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches Fahipay saved favourites for the 4 service groups.
|
|
||||||
* Only includes entries whose number is a valid 7-digit Maldivian phone number (starts with 7 or 9).
|
|
||||||
* Groups with no valid entries are omitted.
|
|
||||||
*/
|
|
||||||
fun fetchContacts(session: FahipaySession): List<FahipayContactGroup> {
|
|
||||||
val endpoints = listOf(
|
|
||||||
Triple("FAHIPAY_RAASTAS", "Raastas", "ooredooraastas"),
|
|
||||||
Triple("FAHIPAY_RELOAD", "Reload", "dhiraagureload"),
|
|
||||||
Triple("FAHIPAY_OOREDOO_BILL", "Ooredoo Bill", "ooredoobillpay"),
|
|
||||||
Triple("FAHIPAY_DHIRAAGU_BILL", "Dhiraagu Bill", "dhiraagubillpay")
|
|
||||||
)
|
|
||||||
val result = mutableListOf<FahipayContactGroup>()
|
|
||||||
for ((catId, label, page) in endpoints) {
|
|
||||||
try {
|
|
||||||
val resp = client.newCall(
|
|
||||||
Request.Builder()
|
|
||||||
.url("$BASE_URL/api/app/favs/?page=$page&lang=en")
|
|
||||||
.header("authid", session.authId)
|
|
||||||
.header("User-Agent", UA_OKHTTP)
|
|
||||||
.build()
|
|
||||||
).execute()
|
|
||||||
val json = resp.body?.string() ?: continue
|
|
||||||
resp.close()
|
|
||||||
val obj = JSONObject(json)
|
|
||||||
// Empty group comes back as a JSON array [], not an object — optJSONObject returns null
|
|
||||||
val groupObj = obj.optJSONObject(page) ?: continue
|
|
||||||
val contacts = mutableListOf<MibBeneficiary>()
|
|
||||||
for (key in groupObj.keys()) {
|
|
||||||
val entry = groupObj.getJSONObject(key)
|
|
||||||
val number = entry.optString("number")
|
|
||||||
val name = entry.optString("name").trim().ifBlank { number }
|
|
||||||
if (AccountInputParser.detect(number) != AccountInputParser.InputType.PHONE) continue
|
|
||||||
contacts.add(MibBeneficiary(
|
|
||||||
benefNo = "fp_${page}_$number",
|
|
||||||
benefName = "",
|
|
||||||
benefNickName = name,
|
|
||||||
benefAccount = number,
|
|
||||||
benefType = "FAHIPAY",
|
|
||||||
bankColor = "#FF6B00",
|
|
||||||
benefBankName = label,
|
|
||||||
bankCode = "",
|
|
||||||
benefStatus = "",
|
|
||||||
transferCyDesc = "",
|
|
||||||
customerImgHash = null,
|
|
||||||
benefCategoryId = catId,
|
|
||||||
profileId = ""
|
|
||||||
))
|
|
||||||
}
|
|
||||||
if (contacts.isNotEmpty()) result.add(FahipayContactGroup(catId, label, contacts))
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
|
private fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
|
||||||
"device[available]" to "true",
|
"device[available]" to "true",
|
||||||
"device[platform]" to "Android",
|
"device[platform]" to "Android",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package sh.sar.basedbank.api.fahipay
|
package sh.sar.basedbank.api.fahipay
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
|
|
||||||
data class FahipaySession(
|
data class FahipaySession(
|
||||||
val authId: String,
|
val authId: String,
|
||||||
val sessionCookie: String
|
val sessionCookie: String
|
||||||
@@ -23,5 +25,5 @@ data class FahipayLoginStep(
|
|||||||
data class FahipayContactGroup(
|
data class FahipayContactGroup(
|
||||||
val categoryId: String,
|
val categoryId: String,
|
||||||
val label: String,
|
val label: String,
|
||||||
val contacts: List<sh.sar.basedbank.api.mib.MibBeneficiary>
|
val contacts: List<BankContact>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,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
|
package sh.sar.basedbank.api.mib
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
@@ -24,7 +25,7 @@ class MibContactsClient {
|
|||||||
.header("Cookie", cookieHeader(session))
|
.header("Cookie", cookieHeader(session))
|
||||||
.header(
|
.header(
|
||||||
"User-Agent",
|
"User-Agent",
|
||||||
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||||
)
|
)
|
||||||
.header("X-Requested-With", "XMLHttpRequest")
|
.header("X-Requested-With", "XMLHttpRequest")
|
||||||
.header("Accept", "*/*")
|
.header("Accept", "*/*")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package sh.sar.basedbank.api.mib
|
package sh.sar.basedbank.api.mib
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -27,7 +28,7 @@ class MibFinancingClient {
|
|||||||
.header("Cookie", cookieHeader)
|
.header("Cookie", cookieHeader)
|
||||||
.header(
|
.header(
|
||||||
"User-Agent",
|
"User-Agent",
|
||||||
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||||
)
|
)
|
||||||
.header("X-Requested-With", "mv.com.mib.faisamobilex")
|
.header("X-Requested-With", "mv.com.mib.faisamobilex")
|
||||||
.get()
|
.get()
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package sh.sar.basedbank.api.mib
|
package sh.sar.basedbank.api.mib
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.models.BankServerException
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class MibHistoryClient {
|
class MibHistoryClient {
|
||||||
@@ -50,7 +52,7 @@ class MibHistoryClient {
|
|||||||
.header("Cookie", cookieHeader(session))
|
.header("Cookie", cookieHeader(session))
|
||||||
.header(
|
.header(
|
||||||
"User-Agent",
|
"User-Agent",
|
||||||
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||||
)
|
)
|
||||||
.header("X-Requested-With", "XMLHttpRequest")
|
.header("X-Requested-With", "XMLHttpRequest")
|
||||||
.header("Accept", "*/*")
|
.header("Accept", "*/*")
|
||||||
@@ -59,6 +61,7 @@ class MibHistoryClient {
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
return client.newCall(request).execute().use { response ->
|
return client.newCall(request).execute().use { response ->
|
||||||
|
if (response.code in 500..599) throw BankServerException("MIB")
|
||||||
val bodyStr = response.body?.string() ?: return Pair(emptyList(), 0)
|
val bodyStr = response.body?.string() ?: return Pair(emptyList(), 0)
|
||||||
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return Pair(emptyList(), 0) }
|
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return Pair(emptyList(), 0) }
|
||||||
if (!json.optBoolean("success")) return Pair(emptyList(), 0)
|
if (!json.optBoolean("success")) return Pair(emptyList(), 0)
|
||||||
@@ -70,7 +73,7 @@ class MibHistoryClient {
|
|||||||
id = item.optString("trxNumber"),
|
id = item.optString("trxNumber"),
|
||||||
date = item.optString("trxDate"),
|
date = item.optString("trxDate"),
|
||||||
description = item.optString("descr1").trim(),
|
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"),
|
currency = item.optString("curCodeDesc"),
|
||||||
counterpartyName = item.optString("benefName").takeIf {
|
counterpartyName = item.optString("benefName").takeIf {
|
||||||
it.isNotBlank() && it != "null"
|
it.isNotBlank() && it != "null"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import org.json.JSONObject
|
|||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlin.math.abs
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
class SessionExpiredException : Exception("MIB session expired")
|
class SessionExpiredException : Exception("MIB session expired")
|
||||||
@@ -29,6 +30,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null
|
var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null
|
||||||
|
|
||||||
// Stored after login so the session can be silently recovered on 419
|
// Stored after login so the session can be silently recovered on 419
|
||||||
|
@Volatile private var loginId: String = ""
|
||||||
@Volatile private var storedUsername: String? = null
|
@Volatile private var storedUsername: String? = null
|
||||||
@Volatile private var storedPasswordHash: String? = null
|
@Volatile private var storedPasswordHash: String? = null
|
||||||
@Volatile private var storedOtpSeed: String? = null
|
@Volatile private var storedOtpSeed: String? = null
|
||||||
@@ -38,7 +40,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
.connectTimeout(30, TimeUnit.SECONDS)
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.writeTimeout(30, TimeUnit.SECONDS)
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
.addInterceptor { chain ->
|
.addNetworkInterceptor { chain ->
|
||||||
val req = chain.request().newBuilder()
|
val req = chain.request().newBuilder()
|
||||||
.header("User-Agent", "android/1.0")
|
.header("User-Agent", "android/1.0")
|
||||||
.header("Accept", "application/json")
|
.header("Accept", "application/json")
|
||||||
@@ -58,11 +60,12 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
* Returns list of accounts from all profiles on success.
|
* Returns list of accounts from all profiles on success.
|
||||||
*/
|
*/
|
||||||
fun login(username: String, passwordHash: String, otpSeed: String): List<MibAccount> {
|
fun login(username: String, passwordHash: String, otpSeed: String): List<MibAccount> {
|
||||||
|
loginId = username
|
||||||
storedUsername = username
|
storedUsername = username
|
||||||
storedPasswordHash = passwordHash
|
storedPasswordHash = passwordHash
|
||||||
storedOtpSeed = otpSeed
|
storedOtpSeed = otpSeed
|
||||||
val appId = getOrCreateAppId()
|
val appId = getOrCreateAppId()
|
||||||
val keys = credentialStore.loadMibKeys()
|
val keys = credentialStore.loadMibKeys(loginId)
|
||||||
|
|
||||||
return if (keys != null) {
|
return if (keys != null) {
|
||||||
regularLogin(username, passwordHash, appId, keys.first, keys.second)
|
regularLogin(username, passwordHash, appId, keys.first, keys.second)
|
||||||
@@ -106,7 +109,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
val keyData = otpResp.getJSONArray("data").getJSONObject(0)
|
val keyData = otpResp.getJSONArray("data").getJSONObject(0)
|
||||||
val key1 = keyData.getString("key1")
|
val key1 = keyData.getString("key1")
|
||||||
val key2 = keyData.getString("key2")
|
val key2 = keyData.getString("key2")
|
||||||
credentialStore.saveMibKeys(key1, key2)
|
credentialStore.saveMibKeys(loginId, key1, key2)
|
||||||
|
|
||||||
return regularLogin(username, passwordHash, appId, key1, key2)
|
return regularLogin(username, passwordHash, appId, key1, key2)
|
||||||
}
|
}
|
||||||
@@ -136,14 +139,63 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val profiles = parseProfiles(loginResp)
|
val profiles = parseProfiles(loginResp)
|
||||||
|
|
||||||
lastSession = session2
|
lastSession = session2
|
||||||
lastProfiles = profiles
|
lastProfiles = profiles // keep ALL profiles so settings can show them all
|
||||||
return fetchAllProfiles(session2, profiles, "mib_$username")
|
|
||||||
|
val hidden = credentialStore.getHiddenMibProfileIds(loginId)
|
||||||
|
|
||||||
|
// When the server already selected the profile and returned balances in A41
|
||||||
|
// (single-profile case: profileSelected=true), use those accounts directly
|
||||||
|
// without making an extra P47 call (which the server ignores or rejects).
|
||||||
|
if (loginResp.optBoolean("profileSelected", false)) {
|
||||||
|
val a41Balances = loginResp.optJSONArray("accountBalance")
|
||||||
|
if (a41Balances != null && a41Balances.length() > 0) {
|
||||||
|
val selectedId = loginResp.optString("selectedProfileId")
|
||||||
|
val profile = profiles.firstOrNull { it.profileId == selectedId }
|
||||||
|
?: profiles.firstOrNull()
|
||||||
|
if (profile != null && (hidden.isEmpty() || profile.profileId !in hidden)) {
|
||||||
|
val allAccounts = mutableListOf<MibAccount>()
|
||||||
|
for (i in 0 until a41Balances.length()) {
|
||||||
|
val a = a41Balances.getJSONObject(i)
|
||||||
|
allAccounts.add(
|
||||||
|
MibAccount(
|
||||||
|
bank = "MIB",
|
||||||
|
profileName = profile.name,
|
||||||
|
profileType = profile.profileType,
|
||||||
|
productCode = profile.cifType,
|
||||||
|
accountNumber = a.optString("accountNumber"),
|
||||||
|
accountBriefName = a.optString("accountBriefName"),
|
||||||
|
currencyName = a.optString("currencyName"),
|
||||||
|
accountTypeName = a.optString("accountTypeName"),
|
||||||
|
availableBalance = a.optString("availableBalance"),
|
||||||
|
currentBalance = a.optString("currentBalance"),
|
||||||
|
blockedAmount = absBlockedAmount(a.optString("blockedAmount")),
|
||||||
|
mvrBalance = a.optString("mvrBalance"),
|
||||||
|
statusDesc = a.optString("statusDesc"),
|
||||||
|
profileImageHash = profile.customerImage,
|
||||||
|
loginTag = "mib_$username",
|
||||||
|
profileId = profile.profileId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return allAccounts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val visibleProfiles = if (hidden.isEmpty()) profiles else profiles.filter { it.profileId !in hidden }
|
||||||
|
return fetchAllProfiles(session2, visibleProfiles, "mib_$username")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** MIB returns blockedAmount as a signed decimal where negative = funds held.
|
||||||
|
* Normalize to a positive magnitude so downstream code can treat it uniformly. */
|
||||||
|
private fun absBlockedAmount(raw: String): String {
|
||||||
|
val v = raw.toDoubleOrNull() ?: return raw
|
||||||
|
return "%.2f".format(abs(v))
|
||||||
|
}
|
||||||
|
|
||||||
private fun initialKeyExchange(
|
private fun initialKeyExchange(
|
||||||
appId: String, encKey: String, sfunc: String, key2: String? = null
|
appId: String, encKey: String, sfunc: String, key2: String? = null
|
||||||
): Pair<MibSession, String> {
|
): Pair<MibSession, String> {
|
||||||
@@ -271,15 +323,17 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
val a = accountBalances.getJSONObject(i)
|
val a = accountBalances.getJSONObject(i)
|
||||||
allAccounts.add(
|
allAccounts.add(
|
||||||
MibAccount(
|
MibAccount(
|
||||||
|
bank = "MIB",
|
||||||
profileName = profile.name,
|
profileName = profile.name,
|
||||||
profileType = profile.profileType,
|
profileType = profile.profileType,
|
||||||
|
productCode = profile.cifType,
|
||||||
accountNumber = a.optString("accountNumber"),
|
accountNumber = a.optString("accountNumber"),
|
||||||
accountBriefName = a.optString("accountBriefName"),
|
accountBriefName = a.optString("accountBriefName"),
|
||||||
currencyName = a.optString("currencyName"),
|
currencyName = a.optString("currencyName"),
|
||||||
accountTypeName = a.optString("accountTypeName"),
|
accountTypeName = a.optString("accountTypeName"),
|
||||||
availableBalance = a.optString("availableBalance"),
|
availableBalance = a.optString("availableBalance"),
|
||||||
currentBalance = a.optString("currentBalance"),
|
currentBalance = a.optString("currentBalance"),
|
||||||
blockedAmount = a.optString("blockedAmount"),
|
blockedAmount = absBlockedAmount(a.optString("blockedAmount")),
|
||||||
mvrBalance = a.optString("mvrBalance"),
|
mvrBalance = a.optString("mvrBalance"),
|
||||||
statusDesc = a.optString("statusDesc"),
|
statusDesc = a.optString("statusDesc"),
|
||||||
profileImageHash = profile.customerImage,
|
profileImageHash = profile.customerImage,
|
||||||
@@ -310,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. */
|
/** Fetches a profile image via P41. Returns base64 JPEG string, or null if not found. */
|
||||||
fun fetchProfileImage(session: MibSession, imageHash: String): String? {
|
fun fetchProfileImage(session: MibSession, imageHash: String): String? {
|
||||||
val payload = baseData(session, "P41").apply {
|
val payload = baseData(session, "P41").apply {
|
||||||
@@ -358,6 +374,34 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
return resp.optString("profileImage").takeIf { it.isNotBlank() }
|
return resp.optString("profileImage").takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads a profile image via P40.
|
||||||
|
* [imageBase64] is a base64-encoded JPEG string.
|
||||||
|
* Returns the new imageHash on success, or null on failure.
|
||||||
|
*/
|
||||||
|
fun uploadProfileImage(session: MibSession, profile: MibProfile, imageBase64: String): String? {
|
||||||
|
val payload = baseData(session, "P40").apply {
|
||||||
|
put("profileId", profile.profileId)
|
||||||
|
put("profileImage", imageBase64)
|
||||||
|
}
|
||||||
|
val resp = doRequest(session, payload, "n")
|
||||||
|
if (!resp.optBoolean("success", false)) return null
|
||||||
|
return resp.optString("imageHash").takeIf { it.isNotBlank() }
|
||||||
|
?: resp.optString("customerImage").takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the profile image via P42.
|
||||||
|
* Returns true on success.
|
||||||
|
*/
|
||||||
|
fun deleteProfileImage(session: MibSession, profile: MibProfile): Boolean {
|
||||||
|
val payload = baseData(session, "P42").apply {
|
||||||
|
put("profileId", profile.profileId)
|
||||||
|
}
|
||||||
|
val resp = doRequest(session, payload, "n")
|
||||||
|
return resp.optBoolean("success", false)
|
||||||
|
}
|
||||||
|
|
||||||
private fun post(body: FormBody): String {
|
private fun post(body: FormBody): String {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(BASE_URL)
|
.url(BASE_URL)
|
||||||
@@ -365,6 +409,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
.build()
|
.build()
|
||||||
val response = client.newCall(request).execute()
|
val response = client.newCall(request).execute()
|
||||||
if (response.code == 419) throw SessionExpiredException()
|
if (response.code == 419) throw SessionExpiredException()
|
||||||
|
if (response.code in 500..599) throw sh.sar.basedbank.api.models.BankServerException("MIB")
|
||||||
return response.body?.string() ?: throw IllegalStateException("Empty response body")
|
return response.body?.string() ?: throw IllegalStateException("Empty response body")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,11 +434,11 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
private fun generateOtp(seed: String): String = Totp.generate(seed)
|
private fun generateOtp(seed: String): String = Totp.generate(seed)
|
||||||
|
|
||||||
private fun getOrCreateAppId(): String {
|
private fun getOrCreateAppId(): String {
|
||||||
var id = credentialStore.loadMibAppId()
|
var id = credentialStore.loadMibAppId(loginId)
|
||||||
if (id == null) {
|
if (id == null) {
|
||||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("")
|
id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("")
|
||||||
credentialStore.saveMibAppId(id)
|
credentialStore.saveMibAppId(loginId, id)
|
||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
package sh.sar.basedbank.api.mib
|
package sh.sar.basedbank.api.mib
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
|
import sh.sar.basedbank.api.models.BankContactCategory
|
||||||
|
import sh.sar.basedbank.api.models.BankTransaction
|
||||||
|
|
||||||
|
// Kept for source compatibility within the mib package
|
||||||
|
typealias MibAccount = BankAccount
|
||||||
|
typealias MibBeneficiary = BankContact
|
||||||
|
typealias Transaction = BankTransaction
|
||||||
|
typealias MibBeneficiaryCategory = BankContactCategory
|
||||||
|
|
||||||
data class MibSession(
|
data class MibSession(
|
||||||
val appId: String,
|
val appId: String,
|
||||||
val xxid: String,
|
val xxid: String,
|
||||||
@@ -19,23 +30,6 @@ data class MibProfile(
|
|||||||
val customerImage: String?
|
val customerImage: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MibAccount(
|
|
||||||
val profileName: String,
|
|
||||||
val profileType: String,
|
|
||||||
val accountNumber: String,
|
|
||||||
val accountBriefName: String,
|
|
||||||
val currencyName: String,
|
|
||||||
val accountTypeName: String,
|
|
||||||
val availableBalance: String,
|
|
||||||
val currentBalance: String,
|
|
||||||
val blockedAmount: String,
|
|
||||||
val mvrBalance: String,
|
|
||||||
val statusDesc: String,
|
|
||||||
val profileImageHash: String?,
|
|
||||||
val loginTag: String = "",
|
|
||||||
val profileId: String = "", // MIB profile ID; empty for BML accounts
|
|
||||||
val internalId: String = "" // BML internal UUID; empty for MIB accounts
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MibTransferResult(
|
data class MibTransferResult(
|
||||||
val success: Boolean,
|
val success: Boolean,
|
||||||
@@ -44,46 +38,26 @@ data class MibTransferResult(
|
|||||||
val errorMessage: String = ""
|
val errorMessage: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MibBeneficiaryCategory(
|
|
||||||
val id: String,
|
|
||||||
val categoryName: String,
|
|
||||||
val numBenef: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MibBeneficiary(
|
|
||||||
val benefNo: String,
|
|
||||||
val benefName: String,
|
|
||||||
val benefNickName: String,
|
|
||||||
val benefAccount: String,
|
|
||||||
val benefType: String, // L=Local, I=Internal(MIB), S=Swift
|
|
||||||
val bankColor: String,
|
|
||||||
val benefBankName: String,
|
|
||||||
val bankCode: String,
|
|
||||||
val benefStatus: String,
|
|
||||||
val transferCyDesc: String,
|
|
||||||
val customerImgHash: String?,
|
|
||||||
val benefCategoryId: String, // "0" = uncategorized
|
|
||||||
val profileId: String = "" // MIB profile ID; empty for BML contacts
|
|
||||||
)
|
|
||||||
|
|
||||||
data class MibIpsAccountInfo(
|
data class MibIpsAccountInfo(
|
||||||
val accountName: String,
|
val accountName: String,
|
||||||
val accountNumber: String,
|
val accountNumber: String,
|
||||||
val bankId: String
|
val bankId: String,
|
||||||
|
val currency: String = "" // "MVR", "USD", or "" if unknown
|
||||||
)
|
)
|
||||||
|
|
||||||
data class Transaction(
|
|
||||||
val id: String,
|
data class MibCard(
|
||||||
val date: String, // "YYYY-MM-DD HH:mm:ss" for MIB, ISO8601 for BML
|
val cardId: String,
|
||||||
val description: String,
|
val maskedCardNumber: String,
|
||||||
val amount: Double, // negative = debit, positive = credit
|
val cardStatus: String,
|
||||||
val currency: String,
|
val cardType: String,
|
||||||
val counterpartyName: String?,
|
val cardTypeDesc: String,
|
||||||
val reference: String?,
|
val customerId: String,
|
||||||
val accountNumber: String,
|
val phoneNumber: String,
|
||||||
val accountDisplayName: String,
|
val cardHolderName: String,
|
||||||
val source: String, // "MIB", "BML", "BML_CARD", "FAHIPAY"
|
val loginTag: String,
|
||||||
val iconUrl: String? = null // merchant icon URL (Fahipay only)
|
val profileId: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MibFinanceDeal(
|
data class MibFinanceDeal(
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package sh.sar.basedbank.api.mib
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
data class MibPersonalProfile(
|
||||||
|
val fullName: String,
|
||||||
|
val username: String,
|
||||||
|
val email: String,
|
||||||
|
val mobile: String,
|
||||||
|
val enrolled: String
|
||||||
|
)
|
||||||
|
|
||||||
|
class MibProfileClient {
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun fetchPersonalProfile(session: MibSession): MibPersonalProfile? {
|
||||||
|
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
|
||||||
|
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("https://faisamobilex-wv.mib.com.mv/personalProfile")
|
||||||
|
.get()
|
||||||
|
.header("Cookie", cookieHeader)
|
||||||
|
.build()
|
||||||
|
return try {
|
||||||
|
val resp = client.newCall(request).execute()
|
||||||
|
val html = resp.body?.string() ?: return null
|
||||||
|
resp.close()
|
||||||
|
fun scrape(label: String): String {
|
||||||
|
val r = Regex("""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
|
||||||
|
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE))
|
||||||
|
return r.find(html)?.groupValues?.get(1)?.trim() ?: ""
|
||||||
|
}
|
||||||
|
val nameRegex = Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
|
||||||
|
val fullName = nameRegex.find(html)?.groupValues?.get(1)?.trim() ?: return null
|
||||||
|
MibPersonalProfile(
|
||||||
|
fullName = fullName,
|
||||||
|
username = scrape("Username:"),
|
||||||
|
email = scrape("Email:"),
|
||||||
|
mobile = scrape("Mobile no:"),
|
||||||
|
enrolled = scrape("Enrolled:")
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package sh.sar.basedbank.api.mib
|
package sh.sar.basedbank.api.mib
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
import okhttp3.FormBody
|
import okhttp3.FormBody
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
@@ -26,7 +27,7 @@ class MibTransferClient {
|
|||||||
.header("Cookie", cookieHeader(session))
|
.header("Cookie", cookieHeader(session))
|
||||||
.header(
|
.header(
|
||||||
"User-Agent",
|
"User-Agent",
|
||||||
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||||
)
|
)
|
||||||
.header("X-Requested-With", "XMLHttpRequest")
|
.header("X-Requested-With", "XMLHttpRequest")
|
||||||
.header("Accept", "*/*")
|
.header("Accept", "*/*")
|
||||||
@@ -68,6 +69,7 @@ class MibTransferClient {
|
|||||||
.withWvHeaders(session)
|
.withWvHeaders(session)
|
||||||
.build()
|
.build()
|
||||||
return client.newCall(request).execute().use { response ->
|
return client.newCall(request).execute().use { response ->
|
||||||
|
if (response.code == 419) throw SessionExpiredException()
|
||||||
val bodyStr = response.body?.string() ?: ""
|
val bodyStr = response.body?.string() ?: ""
|
||||||
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
|
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
|
||||||
if (json == null || !json.optBoolean("success")) {
|
if (json == null || !json.optBoolean("success")) {
|
||||||
@@ -128,7 +130,10 @@ class MibTransferClient {
|
|||||||
MibIpsAccountInfo(
|
MibIpsAccountInfo(
|
||||||
accountName = json.optString("accountName").trim(),
|
accountName = json.optString("accountName").trim(),
|
||||||
accountNumber = accountNumber,
|
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
|
// accountName may be at root or inside a "data" object
|
||||||
val name = json.optString("accountName").takeIf { it.isNotBlank() }
|
val name = json.optString("accountName").takeIf { it.isNotBlank() }
|
||||||
?: json.optJSONObject("data")?.optString("accountName") ?: ""
|
?: 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(
|
MibIpsAccountInfo(
|
||||||
accountName = name.trim(),
|
accountName = name.trim(),
|
||||||
accountNumber = accountNumber,
|
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,9 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.Transaction
|
import sh.sar.basedbank.api.models.BankTransaction
|
||||||
|
import sh.sar.basedbank.util.AccountHistoryDisplay
|
||||||
import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding
|
import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding
|
||||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||||
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
|
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
|
||||||
@@ -20,27 +21,47 @@ import java.util.Date
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class AccountHistoryAdapter(
|
class AccountHistoryAdapter(
|
||||||
private val account: MibAccount
|
private val account: BankAccount,
|
||||||
|
private val display: AccountHistoryDisplay
|
||||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
private sealed class Item {
|
private sealed class Item {
|
||||||
data class DateHeader(val label: String) : Item()
|
data class DateHeader(val label: String) : Item()
|
||||||
data class Trx(val transaction: Transaction) : Item()
|
data class Trx(val transaction: BankTransaction, val showDate: Boolean = false) : Item()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val pendingItems = mutableListOf<Item>()
|
||||||
private val displayItems = mutableListOf<Item>()
|
private val displayItems = mutableListOf<Item>()
|
||||||
private var lastInsertedDateKey = ""
|
private var lastInsertedDateKey = ""
|
||||||
private val imageCache = mutableMapOf<String, Bitmap>()
|
private val imageCache = mutableMapOf<String, Bitmap>()
|
||||||
private val iconUrlCache = mutableMapOf<String, Bitmap>()
|
private val iconUrlCache = mutableMapOf<String, Bitmap>()
|
||||||
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
||||||
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
||||||
var onTransferClick: ((MibAccount) -> Unit)? = null
|
var onTransferClick: ((BankAccount) -> Unit)? = null
|
||||||
|
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) {
|
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
||||||
imageCache[counterpartyName] = bitmap
|
imageCache[counterpartyName] = bitmap
|
||||||
displayItems.forEachIndexed { i, item ->
|
displayItems.forEachIndexed { i, item ->
|
||||||
if (item is Item.Trx && item.transaction.counterpartyName == counterpartyName)
|
if (item is Item.Trx && item.transaction.counterpartyName == counterpartyName)
|
||||||
notifyItemChanged(i + 1) // +1 for account header at position 0
|
notifyItemChanged(1 + pendingItems.size + i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,10 +69,28 @@ class AccountHistoryAdapter(
|
|||||||
iconUrlCache[url] = bitmap
|
iconUrlCache[url] = bitmap
|
||||||
displayItems.forEachIndexed { i, item ->
|
displayItems.forEachIndexed { i, item ->
|
||||||
if (item is Item.Trx && item.transaction.iconUrl == url)
|
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
|
private var _showLoadingFooter = false
|
||||||
var showLoadingFooter: Boolean
|
var showLoadingFooter: Boolean
|
||||||
get() = _showLoadingFooter
|
get() = _showLoadingFooter
|
||||||
@@ -71,7 +110,7 @@ class AccountHistoryAdapter(
|
|||||||
* Display the given (already sorted + filtered) list with date group headers.
|
* Display the given (already sorted + filtered) list with date group headers.
|
||||||
* Silently resets the loading footer so notifyDataSetChanged covers everything.
|
* Silently resets the loading footer so notifyDataSetChanged covers everything.
|
||||||
*/
|
*/
|
||||||
fun setTransactions(transactions: List<Transaction>) {
|
fun setTransactions(transactions: List<BankTransaction>) {
|
||||||
_showLoadingFooter = false
|
_showLoadingFooter = false
|
||||||
displayItems.clear()
|
displayItems.clear()
|
||||||
lastInsertedDateKey = ""
|
lastInsertedDateKey = ""
|
||||||
@@ -92,7 +131,7 @@ class AccountHistoryAdapter(
|
|||||||
* Appends [newTransactions] (assumed to be older than all existing items) using incremental
|
* Appends [newTransactions] (assumed to be older than all existing items) using incremental
|
||||||
* notifications, so the RecyclerView doesn't reset scroll position.
|
* notifications, so the RecyclerView doesn't reset scroll position.
|
||||||
*/
|
*/
|
||||||
fun appendTransactions(newTransactions: List<Transaction>) {
|
fun appendTransactions(newTransactions: List<BankTransaction>) {
|
||||||
if (newTransactions.isEmpty()) return
|
if (newTransactions.isEmpty()) return
|
||||||
if (_showLoadingFooter) {
|
if (_showLoadingFooter) {
|
||||||
val pos = itemCount - 1
|
val pos = itemCount - 1
|
||||||
@@ -109,18 +148,24 @@ class AccountHistoryAdapter(
|
|||||||
displayItems.add(Item.Trx(trx))
|
displayItems.add(Item.Trx(trx))
|
||||||
}
|
}
|
||||||
val added = displayItems.size - oldCount
|
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
|
// 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
|
// 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 {
|
override fun getItemViewType(position: Int) = when {
|
||||||
position == 0 -> TYPE_HEADER
|
position == 0 -> TYPE_HEADER
|
||||||
_showLoadingFooter && position == itemCount - 1 -> TYPE_LOADING
|
_showLoadingFooter && position == itemCount - 1 -> TYPE_LOADING
|
||||||
else -> when (displayItems[position - 1]) {
|
else -> when (itemAt(position)) {
|
||||||
is Item.DateHeader -> TYPE_DATE_HEADER
|
is Item.DateHeader -> TYPE_DATE_HEADER
|
||||||
is Item.Trx -> TYPE_TRANSACTION
|
is Item.Trx -> TYPE_TRANSACTION
|
||||||
}
|
}
|
||||||
@@ -138,45 +183,45 @@ class AccountHistoryAdapter(
|
|||||||
|
|
||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
when (holder) {
|
when (holder) {
|
||||||
is HeaderVH -> holder.bind(account)
|
is HeaderVH -> holder.bind(display)
|
||||||
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label)
|
is DateHeaderVH -> holder.bind((itemAt(position) as Item.DateHeader).label)
|
||||||
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction)
|
is TransactionVH -> {
|
||||||
|
val item = itemAt(position) as Item.Trx
|
||||||
|
holder.bind(item.transaction, item.showDate)
|
||||||
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class HeaderVH(private val b: ItemAccountHistoryHeaderBinding) :
|
inner class HeaderVH(private val b: ItemAccountHistoryHeaderBinding) :
|
||||||
RecyclerView.ViewHolder(b.root) {
|
RecyclerView.ViewHolder(b.root) {
|
||||||
fun bind(acc: MibAccount) {
|
fun bind(d: AccountHistoryDisplay) {
|
||||||
b.tvHeaderAccountName.text = acc.accountBriefName
|
b.tvHeaderAccountName.text = d.name
|
||||||
b.tvHeaderAccountNumber.text = acc.accountNumber
|
b.tvHeaderAccountNumber.text = d.number
|
||||||
b.tvHeaderPillBank.text = when {
|
b.tvHeaderPillBank.text = d.bankPill
|
||||||
acc.profileType.startsWith("BML") -> "BML"
|
b.tvHeaderPillType.text = d.typeLabel
|
||||||
acc.profileType == "FAHIPAY" -> "FP"
|
b.tvHeaderAvailable.text = if (hideAmounts) maskAmount(d.availableBalance) else d.availableBalance
|
||||||
else -> null
|
b.tvHeaderBalance.text = if (hideAmounts) maskAmount(d.workingBalance) else d.workingBalance
|
||||||
}
|
if (d.blockedBalance != null) {
|
||||||
b.tvHeaderPillType.text = friendlyType(acc.accountTypeName)
|
b.tvHeaderBlocked.text = if (hideAmounts) maskAmount(d.blockedBalance) else d.blockedBalance
|
||||||
b.tvHeaderAvailable.text = "${acc.currencyName} ${acc.availableBalance}"
|
|
||||||
b.tvHeaderBalance.text = "${acc.currencyName} ${acc.currentBalance}"
|
|
||||||
val blocked = acc.blockedAmount.toDoubleOrNull() ?: 0.0
|
|
||||||
if (blocked > 0.0) {
|
|
||||||
b.tvHeaderBlocked.text = "${acc.currencyName} ${acc.blockedAmount}"
|
|
||||||
b.llHeaderBlocked.visibility = View.VISIBLE
|
b.llHeaderBlocked.visibility = View.VISIBLE
|
||||||
} else {
|
} else {
|
||||||
b.llHeaderBlocked.visibility = View.GONE
|
b.llHeaderBlocked.visibility = View.GONE
|
||||||
}
|
}
|
||||||
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(acc) }
|
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||||
}
|
|
||||||
|
|
||||||
private fun friendlyType(raw: String): String {
|
if (showDefaultToggle) {
|
||||||
val u = raw.trim().uppercase()
|
b.dividerDefaultAccount.visibility = View.VISIBLE
|
||||||
return when {
|
b.llDefaultAccountRow.visibility = View.VISIBLE
|
||||||
u.contains("SAVING") -> "Savings"
|
b.switchDefaultAccount.setOnCheckedChangeListener(null)
|
||||||
u.contains("CURRENT") -> "Current"
|
b.switchDefaultAccount.isChecked = isDefaultAccount
|
||||||
u.contains("WADIAH") -> "Islamic"
|
b.switchDefaultAccount.setOnCheckedChangeListener { _, checked ->
|
||||||
u.contains("VISA") || u.contains("MASTERCARD") || u.contains("AMEX") -> "Card"
|
isDefaultAccount = checked
|
||||||
u.contains("PREPAID") -> "Prepaid"
|
onDefaultToggle?.invoke(checked)
|
||||||
else -> raw.trim().take(12)
|
}
|
||||||
|
} else {
|
||||||
|
b.dividerDefaultAccount.visibility = View.GONE
|
||||||
|
b.llDefaultAccountRow.visibility = View.GONE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,7 +233,7 @@ class AccountHistoryAdapter(
|
|||||||
|
|
||||||
inner class TransactionVH(private val b: ItemTransactionBinding) :
|
inner class TransactionVH(private val b: ItemTransactionBinding) :
|
||||||
RecyclerView.ViewHolder(b.root) {
|
RecyclerView.ViewHolder(b.root) {
|
||||||
fun bind(trx: Transaction) {
|
fun bind(trx: BankTransaction, showDate: Boolean = false) {
|
||||||
val isCredit = trx.amount >= 0
|
val isCredit = trx.amount >= 0
|
||||||
val color = sourceColor(trx.source)
|
val color = sourceColor(trx.source)
|
||||||
val name = trx.counterpartyName ?: trx.description
|
val name = trx.counterpartyName ?: trx.description
|
||||||
@@ -224,19 +269,24 @@ class AccountHistoryAdapter(
|
|||||||
b.tvCounterparty.visibility = View.GONE
|
b.tvCounterparty.visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
b.tvDate.text = formatTime(trx.date)
|
b.tvDate.text = if (showDate) formatDateOnly(trx.date) else formatTime(trx.date)
|
||||||
|
|
||||||
|
if (hideAmounts) {
|
||||||
|
b.tvAmount.text = "${trx.currency} ••••••"
|
||||||
|
b.tvAmount.setTextColor(Color.parseColor("#888888"))
|
||||||
|
} else {
|
||||||
val sign = if (isCredit) "+" else "-"
|
val sign = if (isCredit) "+" else "-"
|
||||||
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
||||||
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
||||||
b.tvAmount.setTextColor(
|
b.tvAmount.setTextColor(
|
||||||
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
b.root.setOnClickListener { showDetail(trx) }
|
b.root.setOnClickListener { showDetail(trx) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showDetail(trx: Transaction) {
|
private fun showDetail(trx: BankTransaction) {
|
||||||
val ctx = b.root.context
|
val ctx = b.root.context
|
||||||
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
|
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
|
||||||
val details = buildString {
|
val details = buildString {
|
||||||
@@ -266,6 +316,7 @@ class AccountHistoryAdapter(
|
|||||||
private val MIB_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
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 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_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 TIME_FMT = SimpleDateFormat("h:mm a", Locale.getDefault())
|
||||||
private val FULL_DATE_FMT = SimpleDateFormat("MMM d, yyyy · h:mm a", Locale.getDefault())
|
private val FULL_DATE_FMT = SimpleDateFormat("MMM d, yyyy · h:mm a", Locale.getDefault())
|
||||||
|
|
||||||
@@ -287,6 +338,11 @@ class AccountHistoryAdapter(
|
|||||||
return DATE_HEADER_FMT.format(date)
|
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 {
|
fun formatTime(raw: String): String {
|
||||||
val date = parseDate(raw) ?: return ""
|
val date = parseDate(raw) ?: return ""
|
||||||
return TIME_FMT.format(date)
|
return TIME_FMT.format(date)
|
||||||
@@ -297,6 +353,11 @@ class AccountHistoryAdapter(
|
|||||||
return FULL_DATE_FMT.format(date)
|
return FULL_DATE_FMT.format(date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun maskAmount(formatted: String): String {
|
||||||
|
val currency = formatted.substringBefore(' ', formatted)
|
||||||
|
return "$currency ••••••"
|
||||||
|
}
|
||||||
|
|
||||||
fun sourceColor(source: String) = when (source) {
|
fun sourceColor(source: String) = when (source) {
|
||||||
"MIB" -> "#FE860E"
|
"MIB" -> "#FE860E"
|
||||||
"BML", "BML_CARD" -> "#0066A1"
|
"BML", "BML_CARD" -> "#0066A1"
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import android.util.Base64
|
|||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
@@ -17,23 +19,22 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
|||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
import sh.sar.basedbank.api.models.BankServerException
|
||||||
import sh.sar.basedbank.api.mib.MibAccount
|
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
import sh.sar.basedbank.api.models.BankTransaction
|
||||||
import sh.sar.basedbank.api.mib.Transaction
|
|
||||||
import sh.sar.basedbank.api.mib.TransactionCache
|
import sh.sar.basedbank.api.mib.TransactionCache
|
||||||
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
|
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
|
||||||
|
import sh.sar.basedbank.util.AccountHistoryParser
|
||||||
|
import sh.sar.basedbank.util.AccountListParser
|
||||||
import sh.sar.basedbank.util.ContactImageCache
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.HistoryFetcher
|
||||||
import sh.sar.basedbank.util.MerchantIconCache
|
import sh.sar.basedbank.util.MerchantIconCache
|
||||||
import java.text.SimpleDateFormat
|
|
||||||
import java.util.Calendar
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
class AccountHistoryFragment : Fragment() {
|
class AccountHistoryFragment : Fragment() {
|
||||||
|
|
||||||
@@ -42,29 +43,21 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
private lateinit var adapter: AccountHistoryAdapter
|
private lateinit var adapter: AccountHistoryAdapter
|
||||||
private lateinit var account: MibAccount
|
private lateinit var account: BankAccount
|
||||||
|
private lateinit var fetcher: HistoryFetcher
|
||||||
|
|
||||||
private val allTransactions = mutableListOf<Transaction>()
|
private val allTransactions = mutableListOf<BankTransaction>()
|
||||||
private var searchQuery = ""
|
private var searchQuery = ""
|
||||||
private var firstPageDone = false
|
private var firstPageDone = false
|
||||||
private val pendingImageNames = mutableSetOf<String>()
|
private val pendingImageNames = mutableSetOf<String>()
|
||||||
private val pendingIconUrls = mutableSetOf<String>()
|
private val pendingIconUrls = mutableSetOf<String>()
|
||||||
|
|
||||||
// Pagination state
|
|
||||||
private var mibNextStart = 1
|
|
||||||
private var mibTotalCount = -1 // -1 = unknown; loaded on first fetch
|
|
||||||
private var bmlNextPage = 1
|
|
||||||
private var bmlTotalPages = -1
|
|
||||||
private var cardMonthOffset = 0 // 0 = current month, 1 = prev, etc.
|
|
||||||
private var fahipayNextStart = 0
|
|
||||||
private var fahipayTotal = -1
|
|
||||||
private var isLoading = false
|
private var isLoading = false
|
||||||
private val pageSize = 10
|
private val pageSize = 10
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val ARG_ACCOUNT_NUMBER = "account_number"
|
private const val ARG_ACCOUNT_NUMBER = "account_number"
|
||||||
|
|
||||||
fun newInstance(account: MibAccount) = AccountHistoryFragment().apply {
|
fun newInstance(account: BankAccount) = AccountHistoryFragment().apply {
|
||||||
arguments = Bundle().apply {
|
arguments = Bundle().apply {
|
||||||
putString(ARG_ACCOUNT_NUMBER, account.accountNumber)
|
putString(ARG_ACCOUNT_NUMBER, account.accountNumber)
|
||||||
}
|
}
|
||||||
@@ -79,15 +72,47 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
val accountNumber = requireArguments().getString(ARG_ACCOUNT_NUMBER) ?: return
|
val accountNumber = requireArguments().getString(ARG_ACCOUNT_NUMBER) ?: return
|
||||||
account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return
|
account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return
|
||||||
|
fetcher = HistoryFetcher(account)
|
||||||
|
|
||||||
adapter = AccountHistoryAdapter(account)
|
val historyDisplay = AccountHistoryParser.from(account) ?: return
|
||||||
|
adapter = AccountHistoryAdapter(account, historyDisplay)
|
||||||
adapter.onImageNeeded = { name -> loadContactImage(name) }
|
adapter.onImageNeeded = { name -> loadContactImage(name) }
|
||||||
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
|
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
|
||||||
adapter.onTransferClick = { acc ->
|
adapter.onTransferClick = { acc ->
|
||||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(acc))
|
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(acc))
|
||||||
}
|
}
|
||||||
|
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
|
|
||||||
|
// 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.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||||
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
|
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
|
||||||
if (dy <= 0 || isLoading) return
|
if (dy <= 0 || isLoading) return
|
||||||
@@ -104,11 +129,10 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
override fun afterTextChanged(s: Editable?) {
|
override fun afterTextChanged(s: Editable?) {
|
||||||
searchQuery = s?.toString()?.trim() ?: ""
|
searchQuery = s?.toString()?.trim() ?: ""
|
||||||
filterAndDisplay()
|
filterAndDisplay()
|
||||||
if (searchQuery.isNotBlank() && hasMore() && !isLoading) loadNextPage()
|
if (searchQuery.isNotBlank() && fetcher.hasMore() && !isLoading) loadNextPage()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Load cache immediately, then fetch fresh data in background
|
|
||||||
val cached = TransactionCache.load(requireContext(), account.accountNumber)
|
val cached = TransactionCache.load(requireContext(), account.accountNumber)
|
||||||
if (cached.isNotEmpty()) {
|
if (cached.isNotEmpty()) {
|
||||||
allTransactions.addAll(cached)
|
allTransactions.addAll(cached)
|
||||||
@@ -116,11 +140,25 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
(activity as? HomeActivity)?.setRefreshing(true)
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
loadNextPage()
|
loadNextPage()
|
||||||
|
loadPendingTransactions()
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
|
if (isLoading) {
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
|
} else {
|
||||||
|
resetAndReload()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.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() {
|
private fun filterAndDisplay() {
|
||||||
@@ -133,19 +171,27 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
|
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun isMib() = !account.profileType.startsWith("BML") && account.profileType != "FAHIPAY"
|
private fun resetAndReload() {
|
||||||
private fun isBmlCard() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
|
allTransactions.clear()
|
||||||
private fun isFahipay() = account.profileType == "FAHIPAY"
|
pendingImageNames.clear()
|
||||||
|
pendingIconUrls.clear()
|
||||||
private fun hasMore(): Boolean = when {
|
firstPageDone = false
|
||||||
isFahipay() -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
fetcher = HistoryFetcher(account)
|
||||||
isMib() -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
// Restore cache immediately so data stays visible while refreshing
|
||||||
isBmlCard() -> cardMonthOffset < 3 // load up to 3 months
|
val cached = TransactionCache.load(requireContext(), account.accountNumber)
|
||||||
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
if (cached.isNotEmpty()) {
|
||||||
|
allTransactions.addAll(cached)
|
||||||
|
filterAndDisplay()
|
||||||
|
} else {
|
||||||
|
adapter.setTransactions(emptyList())
|
||||||
|
binding.emptyView.visibility = View.GONE
|
||||||
|
}
|
||||||
|
loadNextPage()
|
||||||
|
loadPendingTransactions()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadNextPage() {
|
private fun loadNextPage() {
|
||||||
if (isLoading || !hasMore()) return
|
if (isLoading || !fetcher.hasMore()) return
|
||||||
isLoading = true
|
isLoading = true
|
||||||
|
|
||||||
if (firstPageDone && allTransactions.isNotEmpty()) {
|
if (firstPageDone && allTransactions.isNotEmpty()) {
|
||||||
@@ -155,74 +201,39 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val transactions: List<Transaction> = withContext(Dispatchers.IO) {
|
val transactions = try {
|
||||||
when {
|
fetcher.fetchNextPage(app, pageSize)
|
||||||
isFahipay() -> {
|
} catch (e: java.io.IOException) {
|
||||||
val session = app.fahipaySession ?: return@withContext emptyList()
|
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
|
||||||
val flow = FahipayLoginFlow()
|
null
|
||||||
flow.setSessionCookie(session.sessionCookie)
|
} catch (e: BankServerException) {
|
||||||
val (list, total) = flow.fetchHistory(
|
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_server_error, e.bankName))
|
||||||
session = session,
|
null
|
||||||
accountDisplayName = account.accountBriefName,
|
} catch (_: Exception) {
|
||||||
accountNumber = account.accountNumber,
|
null
|
||||||
start = fahipayNextStart
|
|
||||||
)
|
|
||||||
if (total > 0) fahipayTotal = total
|
|
||||||
fahipayNextStart += list.size
|
|
||||||
list
|
|
||||||
}
|
|
||||||
isMib() -> {
|
|
||||||
val session = app.mibSession ?: return@withContext emptyList()
|
|
||||||
app.mibMutex.withLock {
|
|
||||||
val profile = app.mibProfiles.firstOrNull { it.profileId == account.profileId }
|
|
||||||
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
|
|
||||||
val (list, total) = MibHistoryClient().fetchHistory(
|
|
||||||
session = session,
|
|
||||||
accountNo = account.accountNumber,
|
|
||||||
accountDisplayName = account.accountBriefName,
|
|
||||||
start = mibNextStart,
|
|
||||||
pageSize = pageSize
|
|
||||||
)
|
|
||||||
if (total > 0) mibTotalCount = total
|
|
||||||
mibNextStart += list.size.coerceAtLeast(pageSize)
|
|
||||||
list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isBmlCard() -> {
|
|
||||||
val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
|
|
||||||
val cal = Calendar.getInstance()
|
|
||||||
cal.add(Calendar.MONTH, -cardMonthOffset)
|
|
||||||
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
|
||||||
cardMonthOffset++
|
|
||||||
BmlLoginFlow().fetchCardHistory(
|
|
||||||
session = session,
|
|
||||||
cardId = account.internalId,
|
|
||||||
accountDisplayName = account.accountBriefName,
|
|
||||||
accountNumber = account.accountNumber,
|
|
||||||
month = month
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
|
|
||||||
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
|
|
||||||
session = session,
|
|
||||||
accountId = account.internalId,
|
|
||||||
accountDisplayName = account.accountBriefName,
|
|
||||||
accountNumber = account.accountNumber,
|
|
||||||
page = bmlNextPage
|
|
||||||
)
|
|
||||||
if (totalPages > 0) bmlTotalPages = totalPages
|
|
||||||
bmlNextPage++
|
|
||||||
list
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoading = false
|
isLoading = false
|
||||||
|
if (_binding == null) return@launch
|
||||||
|
|
||||||
if (!firstPageDone) {
|
if (!firstPageDone) {
|
||||||
firstPageDone = true
|
firstPageDone = true
|
||||||
(activity as? HomeActivity)?.setRefreshing(false)
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transactions == null) {
|
||||||
|
adapter.showLoadingFooter = false
|
||||||
|
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
(activity as? HomeActivity)?.hideConnectivityBanner()
|
||||||
|
|
||||||
|
fetcher.takeCardPendingSections()?.let { (outstanding, unbilled) ->
|
||||||
|
adapter.setLeadingSections(listOf(
|
||||||
|
"Outstanding" to outstanding,
|
||||||
|
"Unbilled" to unbilled
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (transactions.isNotEmpty()) {
|
if (transactions.isNotEmpty()) {
|
||||||
@@ -233,7 +244,6 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
||||||
TransactionCache.save(requireContext(), account.accountNumber, allTransactions)
|
TransactionCache.save(requireContext(), account.accountNumber, allTransactions)
|
||||||
if (searchQuery.isBlank()) {
|
if (searchQuery.isBlank()) {
|
||||||
// Append incrementally to preserve scroll position
|
|
||||||
val sorted = newOnes.sortedByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
val sorted = newOnes.sortedByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
||||||
adapter.appendTransactions(sorted)
|
adapter.appendTransactions(sorted)
|
||||||
binding.emptyView.visibility = View.GONE
|
binding.emptyView.visibility = View.GONE
|
||||||
@@ -243,7 +253,7 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
} else {
|
} else {
|
||||||
adapter.showLoadingFooter = false
|
adapter.showLoadingFooter = false
|
||||||
}
|
}
|
||||||
if (searchQuery.isNotBlank() && hasMore()) loadNextPage()
|
if (searchQuery.isNotBlank() && fetcher.hasMore()) loadNextPage()
|
||||||
} else {
|
} else {
|
||||||
adapter.showLoadingFooter = false
|
adapter.showLoadingFooter = false
|
||||||
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
|
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
|
||||||
@@ -251,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) {
|
private fun loadContactImage(name: String) {
|
||||||
if (!pendingImageNames.add(name)) return
|
if (!pendingImageNames.add(name)) return
|
||||||
val contact = viewModel.contacts.value?.firstOrNull { it.benefNickName == name } ?: return
|
val contact = viewModel.contacts.value?.firstOrNull { it.benefNickName == name } ?: return
|
||||||
@@ -261,7 +291,7 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
val sess = app.mibSession ?: return
|
val sess = app.anyMibSession() ?: return
|
||||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
|
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
|
||||||
@@ -288,8 +318,7 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
val response = client.newCall(Request.Builder().url(url).build()).execute()
|
val response = client.newCall(Request.Builder().url(url).build()).execute()
|
||||||
val bytes = response.body?.bytes() ?: return@launch
|
val bytes = response.body?.bytes() ?: return@launch
|
||||||
response.close()
|
response.close()
|
||||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
||||||
?: return@launch
|
|
||||||
MerchantIconCache.save(requireContext(), url, bitmap)
|
MerchantIconCache.save(requireContext(), url, bitmap)
|
||||||
withContext(Dispatchers.Main) { adapter.updateIconUrl(url, bitmap) }
|
withContext(Dispatchers.Main) { adapter.updateIconUrl(url, bitmap) }
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
|
|||||||
@@ -3,70 +3,87 @@ package sh.sar.basedbank.ui.home
|
|||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.graphics.Color
|
import android.graphics.Bitmap
|
||||||
import android.graphics.drawable.GradientDrawable
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import sh.sar.basedbank.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.ItemAccountBinding
|
||||||
import sh.sar.basedbank.databinding.ItemCardBinding
|
import sh.sar.basedbank.databinding.ItemCardBinding
|
||||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||||
|
import sh.sar.basedbank.util.AccountListDisplay
|
||||||
|
import sh.sar.basedbank.util.AccountListParser
|
||||||
|
|
||||||
class AccountsAdapter(
|
class AccountsAdapter(
|
||||||
accounts: List<MibAccount>,
|
accounts: List<BankAccount>,
|
||||||
private val onAccountClick: (MibAccount) -> Unit = {}
|
private val onAccountClick: (BankAccount) -> Unit = {},
|
||||||
|
/** Optional loader for MIB per-profile images: (hash, onLoaded) */
|
||||||
|
private val profileImageLoader: ((String, (Bitmap) -> Unit) -> Unit)? = null,
|
||||||
|
/** Optional loader for local (BML/Fahipay) profile images: (loginTag, profileId, onLoaded) */
|
||||||
|
private val localProfileImageLoader: ((String, String, (Bitmap) -> Unit) -> Unit)? = null
|
||||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
|
var onTransferClick: ((BankAccount) -> Unit)? = null
|
||||||
|
private var hideAmounts: Boolean = false
|
||||||
|
|
||||||
private sealed class Item {
|
private sealed class Item {
|
||||||
data class SectionTitle(val label: String) : Item()
|
data class SectionTitle(val label: String) : Item()
|
||||||
data class Account(val account: MibAccount) : Item()
|
data class Account(val account: BankAccount, val display: AccountListDisplay) : Item()
|
||||||
data class Card(val account: MibAccount) : Item()
|
data class Card(val account: BankAccount, val display: AccountListDisplay) : Item()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val items: MutableList<Item> = buildItems(accounts).toMutableList()
|
private val items: MutableList<Item> = buildItems(accounts).toMutableList()
|
||||||
|
|
||||||
fun updateAccounts(accounts: List<MibAccount>) {
|
fun updateAccounts(accounts: List<BankAccount>) {
|
||||||
items.clear()
|
items.clear()
|
||||||
items.addAll(buildItems(accounts))
|
items.addAll(buildItems(accounts))
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
|
fun setHideAmounts(hide: Boolean) {
|
||||||
val nonPrepaid = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
|
if (hideAmounts == hide) return
|
||||||
val prepaid = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
|
hideAmounts = hide
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
// Group non-prepaid accounts by their derived section title, preserving order
|
private fun buildItems(accounts: List<BankAccount>): List<Item> = buildList {
|
||||||
val groups = LinkedHashMap<String, MutableList<MibAccount>>()
|
val displayed = accounts.mapNotNull { acc -> AccountListParser.from(acc)?.let { acc to it } }
|
||||||
for (acc in nonPrepaid) {
|
val nonCards = displayed.filter { !it.second.isCard }
|
||||||
|
val cards = displayed.filter { it.second.isCard }
|
||||||
|
|
||||||
|
val groups = LinkedHashMap<String, MutableList<Pair<BankAccount, AccountListDisplay>>>()
|
||||||
|
for ((acc, display) in nonCards) {
|
||||||
val title = sectionTitle(acc)
|
val title = sectionTitle(acc)
|
||||||
groups.getOrPut(title) { mutableListOf() }.add(acc)
|
groups.getOrPut(title) { mutableListOf() }.add(acc to display)
|
||||||
}
|
}
|
||||||
for ((title, group) in groups) {
|
for ((title, group) in groups) {
|
||||||
add(Item.SectionTitle(title))
|
add(Item.SectionTitle(title))
|
||||||
group.forEach { add(Item.Account(it)) }
|
group.forEach { (acc, display) -> add(Item.Account(acc, display)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prepaid.isNotEmpty()) {
|
if (cards.isNotEmpty()) {
|
||||||
add(Item.SectionTitle("Cards · Bank of Maldives"))
|
add(Item.SectionTitle("Cards · Bank of Maldives"))
|
||||||
prepaid.forEach { add(Item.Card(it)) }
|
cards.forEach { (acc, display) -> add(Item.Card(acc, display)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sectionTitle(account: MibAccount): String {
|
private fun sectionTitle(account: BankAccount): String {
|
||||||
val profileLabel = when (account.profileType) {
|
val bankName = when (account.bank) {
|
||||||
"0" -> "Personal"
|
"BML" -> "Bank of Maldives"
|
||||||
"1" -> "Business"
|
"FAHIPAY" -> "Fahipay"
|
||||||
|
"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.productCode.ifBlank { account.profileName }
|
||||||
|
"MFAISA" -> "" // bank-level grouping is already specific (M-Faisa / PayPal · M-Faisa)
|
||||||
else -> account.profileName
|
else -> account.profileName
|
||||||
}
|
}
|
||||||
val bank = when {
|
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
|
||||||
account.profileType.startsWith("BML") -> "Bank of Maldives"
|
|
||||||
account.profileType == "FAHIPAY" -> "Fahipay"
|
|
||||||
else -> "Maldives Islamic Bank"
|
|
||||||
}
|
|
||||||
return if (profileLabel.isNotBlank()) "$profileLabel · $bank" else bank
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getItemViewType(position: Int) = when (items[position]) {
|
override fun getItemViewType(position: Int) = when (items[position]) {
|
||||||
@@ -87,8 +104,8 @@ class AccountsAdapter(
|
|||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
when (val item = items[position]) {
|
when (val item = items[position]) {
|
||||||
is Item.SectionTitle -> (holder as SectionViewHolder).bind(item)
|
is Item.SectionTitle -> (holder as SectionViewHolder).bind(item)
|
||||||
is Item.Account -> (holder as AccountViewHolder).bind(item.account)
|
is Item.Account -> (holder as AccountViewHolder).bind(item.account, item.display)
|
||||||
is Item.Card -> (holder as CardViewHolder).bind(item.account)
|
is Item.Card -> (holder as CardViewHolder).bind(item.account, item.display)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,43 +120,73 @@ class AccountsAdapter(
|
|||||||
|
|
||||||
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
|
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
fun bind(account: MibAccount) {
|
private var boundHash: String? = null
|
||||||
binding.tvAccountName.text = account.accountBriefName
|
|
||||||
binding.tvAccountNumber.text = account.accountNumber
|
fun bind(account: BankAccount, display: AccountListDisplay) {
|
||||||
binding.tvPillType.text = friendlyAccountType(account.accountTypeName)
|
binding.tvAccountName.text = display.name
|
||||||
binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
|
binding.tvAccountNumber.text = display.number
|
||||||
|
binding.tvAccountType.text = display.typeLabel
|
||||||
|
binding.tvBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
|
||||||
|
val blocked = display.blockedBalance
|
||||||
|
if (blocked != null) {
|
||||||
|
val shown = if (hideAmounts) maskAmount(blocked) else blocked
|
||||||
|
binding.tvBlocked.text = binding.root.context.getString(R.string.account_blocked_label, shown)
|
||||||
|
binding.tvBlocked.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
binding.tvBlocked.visibility = View.GONE
|
||||||
|
}
|
||||||
|
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||||
binding.root.setOnClickListener { onAccountClick(account) }
|
binding.root.setOnClickListener { onAccountClick(account) }
|
||||||
binding.root.setOnLongClickListener {
|
binding.root.setOnLongClickListener {
|
||||||
copyToClipboard(it.context, account.accountNumber)
|
copyToClipboard(it.context, display.number)
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val staticLogo = when (account.bank) {
|
||||||
|
"BML" -> R.drawable.bml_logo_vector
|
||||||
|
"FAHIPAY" -> R.drawable.fahipay_logo
|
||||||
|
"MIB" -> R.drawable.mib_logo
|
||||||
|
"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) :
|
private inner class CardViewHolder(private val binding: ItemCardBinding) :
|
||||||
RecyclerView.ViewHolder(binding.root) {
|
RecyclerView.ViewHolder(binding.root) {
|
||||||
fun bind(account: MibAccount) {
|
fun bind(account: BankAccount, display: AccountListDisplay) {
|
||||||
val brand = cardBrand(account.accountTypeName)
|
binding.ivCardBrand.setImageResource(display.cardBrandIcon)
|
||||||
binding.tvCardBrand.text = brand.label
|
binding.tvCardName.text = display.name
|
||||||
binding.tvCardBrand.background = GradientDrawable().apply {
|
binding.tvCardNumber.text = display.number
|
||||||
shape = GradientDrawable.RECTANGLE
|
binding.tvCardProduct.text = display.typeLabel
|
||||||
cornerRadius = 100f
|
|
||||||
setColor(Color.parseColor(brand.color))
|
|
||||||
}
|
|
||||||
binding.tvCardName.text = account.accountBriefName
|
|
||||||
binding.tvCardNumber.text = account.accountNumber
|
|
||||||
binding.layoutCardBalance.visibility = View.VISIBLE
|
binding.layoutCardBalance.visibility = View.VISIBLE
|
||||||
binding.tvCardBalance.text = "${account.currencyName} ${account.availableBalance}"
|
binding.tvCardBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
|
||||||
|
if (display.statusLabel != null) {
|
||||||
val isActive = account.statusDesc.equals("Active", ignoreCase = true)
|
binding.tvCardStatus.text = display.statusLabel
|
||||||
if (isActive) {
|
|
||||||
binding.tvCardStatus.visibility = View.GONE
|
|
||||||
binding.root.alpha = 1f
|
|
||||||
} else {
|
|
||||||
binding.tvCardStatus.text = account.statusDesc
|
|
||||||
binding.tvCardStatus.visibility = View.VISIBLE
|
binding.tvCardStatus.visibility = View.VISIBLE
|
||||||
binding.root.alpha = 0.45f
|
binding.root.alpha = 0.45f
|
||||||
|
} else {
|
||||||
|
binding.tvCardStatus.visibility = View.GONE
|
||||||
|
binding.root.alpha = 1f
|
||||||
}
|
}
|
||||||
|
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||||
binding.root.setOnClickListener { onAccountClick(account) }
|
binding.root.setOnClickListener { onAccountClick(account) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,35 +196,15 @@ class AccountsAdapter(
|
|||||||
private const val TYPE_ACCOUNT = 1
|
private const val TYPE_ACCOUNT = 1
|
||||||
private const val TYPE_CARD = 2
|
private const val TYPE_CARD = 2
|
||||||
|
|
||||||
|
fun maskAmount(formatted: String): String {
|
||||||
|
val currency = formatted.substringBefore(' ', formatted)
|
||||||
|
return "$currency ••••••"
|
||||||
|
}
|
||||||
|
|
||||||
private fun copyToClipboard(context: Context, accountNumber: String) {
|
private fun copyToClipboard(context: Context, accountNumber: String) {
|
||||||
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber))
|
cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber))
|
||||||
Toast.makeText(context, "Account number copied", Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, "Account number copied", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun friendlyAccountType(raw: String): String {
|
|
||||||
val u = raw.trim().uppercase()
|
|
||||||
return when {
|
|
||||||
u == "SAVINGS ACCOUNT" ||
|
|
||||||
u == "SAVING ACCOUNT" -> "Savings"
|
|
||||||
u == "CURRENT ACCOUNT" ||
|
|
||||||
u == "CURRENT ACCOUNT(PERSONAL)" ||
|
|
||||||
u == "CURRENT ACCOUNT(BUSINESS)" -> "Current"
|
|
||||||
u == "WADIAH RETAIL CURRENT ACCOUNT" ||
|
|
||||||
u == "WADIAH BUSINESS CURRENT ACCOUNT" -> "Islamic Current"
|
|
||||||
u == "BML ISLAMIC SAVINGS ACCOUNT" -> "Islamic Savings"
|
|
||||||
else -> raw.trim()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class Brand(val label: String, val color: String)
|
|
||||||
|
|
||||||
private fun cardBrand(productName: String): Brand = when {
|
|
||||||
productName.contains("AMEX", ignoreCase = true) ||
|
|
||||||
productName.contains("AMERICAN EXPRESS", ignoreCase = true) -> Brand("AMEX", "#016FD0")
|
|
||||||
productName.contains("VISA", ignoreCase = true) -> Brand("VISA", "#1A1F71")
|
|
||||||
productName.contains("MASTERCARD", ignoreCase = true) -> Brand("MC", "#FF5F00")
|
|
||||||
else -> Brand("CARD", "#555555")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.util.Base64
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.databinding.FragmentAccountsBinding
|
import sh.sar.basedbank.databinding.FragmentAccountsBinding
|
||||||
|
import sh.sar.basedbank.util.ProfileImageStore
|
||||||
|
|
||||||
class AccountsFragment : Fragment() {
|
class AccountsFragment : Fragment() {
|
||||||
|
|
||||||
@@ -16,6 +27,7 @@ class AccountsFragment : Fragment() {
|
|||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
private lateinit var adapter: AccountsAdapter
|
private lateinit var adapter: AccountsAdapter
|
||||||
|
private val profileImageCache = mutableMapOf<String, Bitmap>()
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentAccountsBinding.inflate(inflater, container, false)
|
_binding = FragmentAccountsBinding.inflate(inflater, container, false)
|
||||||
@@ -23,13 +35,74 @@ class AccountsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
adapter = AccountsAdapter(emptyList()) { account ->
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
adapter = AccountsAdapter(
|
||||||
|
accounts = emptyList(),
|
||||||
|
onAccountClick = { account ->
|
||||||
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
|
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
|
||||||
|
},
|
||||||
|
profileImageLoader = { hash, onLoaded ->
|
||||||
|
profileImageCache[hash]?.let { onLoaded(it); return@AccountsAdapter }
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val bitmap = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val session = app.anyMibSession() ?: return@withContext null
|
||||||
|
val b64 = app.anyMibFlow()?.fetchProfileImage(session, hash) ?: return@withContext null
|
||||||
|
val bytes = Base64.decode(b64, Base64.DEFAULT)
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
if (bitmap != null) {
|
||||||
|
profileImageCache[hash] = bitmap
|
||||||
|
onLoaded(bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
localProfileImageLoader = { loginTag, profileId, onLoaded ->
|
||||||
|
val cacheKey = "$loginTag|$profileId"
|
||||||
|
profileImageCache[cacheKey]?.let { onLoaded(it); return@AccountsAdapter }
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val bitmap = withContext(Dispatchers.IO) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
if (loginTag.startsWith("bml_") && profileId.isNotBlank()) {
|
||||||
|
ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(profileId))
|
||||||
|
} else if (loginTag.startsWith("fahipay_")) {
|
||||||
|
val loginId = ProfileImageStore.loginIdFromTag(loginTag)
|
||||||
|
ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
if (bitmap != null) {
|
||||||
|
profileImageCache[cacheKey] = bitmap
|
||||||
|
onLoaded(bitmap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
adapter.onTransferClick = { account ->
|
||||||
|
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(account))
|
||||||
}
|
}
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.accounts.observe(viewLifecycleOwner) {
|
||||||
|
adapter.updateAccounts(it)
|
||||||
|
binding.emptyView.visibility = if (it.isEmpty()) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
|
(activity as? HomeActivity)?.triggerRefresh()
|
||||||
|
binding.swipeRefresh.isRefreshing = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||||
|
import sh.sar.basedbank.databinding.ItemTransactionBinding
|
||||||
|
import sh.sar.basedbank.util.ReceiptStore
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class ActivitiesAdapter(
|
||||||
|
private val onItemClick: (ReceiptStore.Entry) -> Unit
|
||||||
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
|
private sealed class Item {
|
||||||
|
data class DateHeader(val label: String) : Item()
|
||||||
|
data class ReceiptItem(val entry: ReceiptStore.Entry) : Item()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val displayItems = mutableListOf<Item>()
|
||||||
|
|
||||||
|
fun setEntries(entries: List<ReceiptStore.Entry>) {
|
||||||
|
displayItems.clear()
|
||||||
|
var lastDateKey = ""
|
||||||
|
for (entry in entries) {
|
||||||
|
val dateKey = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date(entry.savedAt))
|
||||||
|
if (dateKey != lastDateKey) {
|
||||||
|
displayItems.add(Item.DateHeader(formatDateHeader(entry.savedAt)))
|
||||||
|
lastDateKey = dateKey
|
||||||
|
}
|
||||||
|
displayItems.add(Item.ReceiptItem(entry))
|
||||||
|
}
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount() = displayItems.size
|
||||||
|
|
||||||
|
override fun getItemViewType(position: Int) =
|
||||||
|
if (displayItems[position] is Item.DateHeader) TYPE_DATE_HEADER else TYPE_RECEIPT
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
|
return if (viewType == TYPE_DATE_HEADER)
|
||||||
|
DateHeaderVH(ItemDateHeaderBinding.inflate(inflater, parent, false))
|
||||||
|
else
|
||||||
|
ReceiptVH(ItemTransactionBinding.inflate(inflater, parent, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
when (holder) {
|
||||||
|
is DateHeaderVH -> holder.bind((displayItems[position] as Item.DateHeader).label)
|
||||||
|
is ReceiptVH -> holder.bind((displayItems[position] as Item.ReceiptItem).entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class DateHeaderVH(private val b: ItemDateHeaderBinding) :
|
||||||
|
RecyclerView.ViewHolder(b.root) {
|
||||||
|
fun bind(label: String) { b.tvDateHeader.text = label }
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class ReceiptVH(private val b: ItemTransactionBinding) :
|
||||||
|
RecyclerView.ViewHolder(b.root) {
|
||||||
|
fun bind(entry: ReceiptStore.Entry) {
|
||||||
|
val d = entry.data
|
||||||
|
val colorHex = d.fromColorHex.takeIf { it.isNotBlank() } ?: "#607D8B"
|
||||||
|
val initial = d.toLabel.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||||
|
|
||||||
|
b.fvAvatar.background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.OVAL
|
||||||
|
setColor(try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY })
|
||||||
|
}
|
||||||
|
b.tvInitial.visibility = android.view.View.VISIBLE
|
||||||
|
b.tvInitial.text = initial
|
||||||
|
|
||||||
|
b.tvCounterparty.text = d.toLabel
|
||||||
|
b.tvCounterparty.visibility = android.view.View.VISIBLE
|
||||||
|
b.tvDescription.text = buildString {
|
||||||
|
append(d.fromLabel)
|
||||||
|
if (d.toBank.isNotBlank()) append(" · ${d.toBank}")
|
||||||
|
}
|
||||||
|
b.tvDate.text = formatTime(entry.savedAt)
|
||||||
|
|
||||||
|
b.tvAmount.text = "- ${d.currency} ${d.amount}"
|
||||||
|
b.tvAmount.setTextColor(Color.parseColor("#FF7043"))
|
||||||
|
|
||||||
|
b.root.setOnClickListener { onItemClick(entry) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDateHeader(millis: Long): String {
|
||||||
|
val sdf = SimpleDateFormat("EEEE, d MMMM yyyy", Locale.US)
|
||||||
|
return sdf.format(Date(millis))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatTime(millis: Long): String {
|
||||||
|
val sdf = SimpleDateFormat("HH:mm", Locale.US)
|
||||||
|
return sdf.format(Date(millis))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TYPE_DATE_HEADER = 0
|
||||||
|
private const val TYPE_RECEIPT = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.FragmentActivitiesBinding
|
||||||
|
import sh.sar.basedbank.util.ReceiptStore
|
||||||
|
|
||||||
|
class ActivitiesFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentActivitiesBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
private lateinit var adapter: ActivitiesAdapter
|
||||||
|
private val allEntries = mutableListOf<ReceiptStore.Entry>()
|
||||||
|
private var searchQuery = ""
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentActivitiesBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
adapter = ActivitiesAdapter { entry ->
|
||||||
|
(activity as? HomeActivity)?.showWithBackStack(
|
||||||
|
TransferReceiptFragment.newInstance(entry.data, null)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||||
|
val isBottomNav = requireContext()
|
||||||
|
.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
.getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.etSearch.addTextChangedListener(object : TextWatcher {
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||||
|
override fun afterTextChanged(s: Editable?) {
|
||||||
|
searchQuery = s?.toString()?.trim() ?: ""
|
||||||
|
filterAndDisplay()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.nav_activities)
|
||||||
|
// Reload in case a new receipt was added while we were away
|
||||||
|
loadEntries()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadEntries() {
|
||||||
|
allEntries.clear()
|
||||||
|
allEntries.addAll(ReceiptStore.loadAll(requireContext()))
|
||||||
|
filterAndDisplay()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun filterAndDisplay() {
|
||||||
|
val filtered = if (searchQuery.isBlank()) allEntries
|
||||||
|
else allEntries.filter { entry ->
|
||||||
|
entry.data.toLabel.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.data.fromLabel.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.data.toAccount.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.data.toBank.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.data.mibReferenceNo.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
entry.data.bmlReference.contains(searchQuery, ignoreCase = true)
|
||||||
|
}
|
||||||
|
adapter.setEntries(filtered)
|
||||||
|
binding.emptyView.visibility = if (filtered.isEmpty()) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,20 +12,27 @@ import android.view.LayoutInflater
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ArrayAdapter
|
import android.widget.ArrayAdapter
|
||||||
|
import android.widget.Filter
|
||||||
|
import android.widget.Filterable
|
||||||
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
import sh.sar.basedbank.util.ContactsCache
|
import sh.sar.basedbank.util.ContactsCache
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlAccountValidation
|
import sh.sar.basedbank.api.bml.BmlAccountValidation
|
||||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
import sh.sar.basedbank.api.bml.BmlContactsClient
|
||||||
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
|
import sh.sar.basedbank.api.bml.BmlValidateClient
|
||||||
|
import sh.sar.basedbank.api.models.BankContactCategory
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.api.mib.MibProfile
|
import sh.sar.basedbank.api.mib.MibProfile
|
||||||
import sh.sar.basedbank.api.mib.MibTransferClient
|
import sh.sar.basedbank.api.mib.MibTransferClient
|
||||||
@@ -43,7 +50,10 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
private data class DestinationOption(
|
private data class DestinationOption(
|
||||||
val label: String,
|
val label: String,
|
||||||
val isBml: Boolean,
|
val isBml: Boolean,
|
||||||
val mibProfile: MibProfile? = null
|
val mibProfile: MibProfile? = null,
|
||||||
|
val mibLoginId: String? = null,
|
||||||
|
val bmlLoginId: String? = null,
|
||||||
|
val subtitle: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
private var destinations: List<DestinationOption> = emptyList()
|
private var destinations: List<DestinationOption> = emptyList()
|
||||||
@@ -55,7 +65,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
private var selectedImageBase64: String = ""
|
private var selectedImageBase64: String = ""
|
||||||
private var selectedCategoryId: String = "0"
|
private var selectedCategoryId: String = "0"
|
||||||
private var categories: List<MibBeneficiaryCategory> = emptyList()
|
private var categories: List<BankContactCategory> = emptyList()
|
||||||
|
|
||||||
private val imagePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
private val imagePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||||
uri ?: return@registerForActivityResult
|
uri ?: return@registerForActivityResult
|
||||||
@@ -79,25 +89,102 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
categories = cats.filter { it.id != "BML" }
|
categories = cats.filter { it.id != "BML" }
|
||||||
if (selectedDest?.isBml == false) setupCategoryDropdown()
|
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> {
|
private fun buildDestinations(): List<DestinationOption> {
|
||||||
val list = mutableListOf<DestinationOption>()
|
val list = mutableListOf<DestinationOption>()
|
||||||
for (profile in app.mibProfiles) {
|
for ((loginId, profiles) in app.mibProfilesMap) {
|
||||||
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile))
|
for (profile in profiles) {
|
||||||
|
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile, mibLoginId = loginId, subtitle = profile.cifType))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val store = CredentialStore(requireContext())
|
||||||
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (app.anyBmlSession() != null) {
|
|
||||||
list.add(DestinationOption("BML · Personal", isBml = true))
|
|
||||||
}
|
}
|
||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupDestinationDropdown() {
|
private fun setupDestinationDropdown() {
|
||||||
val labels = destinations.map { it.label }
|
val adapter = object : ArrayAdapter<DestinationOption>(requireContext(), android.R.layout.simple_list_item_2, destinations) {
|
||||||
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, labels)
|
override fun getView(position: Int, convertView: View?, parent: ViewGroup) =
|
||||||
|
getDropDownView(position, convertView, parent)
|
||||||
|
|
||||||
|
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||||
|
val view = convertView ?: LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_2, parent, false)
|
||||||
|
val opt = destinations[position]
|
||||||
|
view.findViewById<TextView>(android.R.id.text1).text = opt.label
|
||||||
|
val text2 = view.findViewById<TextView>(android.R.id.text2)
|
||||||
|
if (opt.subtitle.isNotBlank()) {
|
||||||
|
text2.text = opt.subtitle
|
||||||
|
text2.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
text2.visibility = View.GONE
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFilter() = object : Filter() {
|
||||||
|
override fun performFiltering(c: CharSequence?) = FilterResults().apply { values = destinations; count = destinations.size }
|
||||||
|
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
|
||||||
|
override fun convertResultToString(r: Any?) = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
binding.actvDestination.setAdapter(adapter)
|
binding.actvDestination.setAdapter(adapter)
|
||||||
binding.actvDestination.setOnItemClickListener { _, _, position, _ ->
|
binding.actvDestination.setOnItemClickListener { _, _, position, _ ->
|
||||||
selectedDest = destinations[position]
|
selectedDest = destinations[position]
|
||||||
|
binding.actvDestination.setText(destinations[position].label, false)
|
||||||
clearLookupResult()
|
clearLookupResult()
|
||||||
updateMibOnlyVisibility()
|
updateMibOnlyVisibility()
|
||||||
binding.btnSave.isEnabled = false
|
binding.btnSave.isEnabled = false
|
||||||
@@ -129,6 +216,22 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startLookupLoading() {
|
||||||
|
val spinner = CircularProgressDrawable(requireContext()).apply {
|
||||||
|
setStyle(CircularProgressDrawable.DEFAULT)
|
||||||
|
setColorSchemeColors(com.google.android.material.color.MaterialColors.getColor(
|
||||||
|
requireView(), com.google.android.material.R.attr.colorPrimary, Color.GRAY))
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
binding.tilAccount.endIconDrawable = spinner
|
||||||
|
binding.tilAccount.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopLookupLoading() {
|
||||||
|
binding.tilAccount.isEnabled = true
|
||||||
|
binding.tilAccount.endIconDrawable = ContextCompat.getDrawable(requireContext(), android.R.drawable.ic_menu_search)
|
||||||
|
}
|
||||||
|
|
||||||
private fun setupAccountSearch() {
|
private fun setupAccountSearch() {
|
||||||
binding.tilAccount.setEndIconOnClickListener { performLookup() }
|
binding.tilAccount.setEndIconOnClickListener { performLookup() }
|
||||||
}
|
}
|
||||||
@@ -167,7 +270,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.tilAccount.isEnabled = false
|
startLookupLoading()
|
||||||
binding.tilDestination.isEnabled = false
|
binding.tilDestination.isEnabled = false
|
||||||
binding.btnSave.isEnabled = false
|
binding.btnSave.isEnabled = false
|
||||||
|
|
||||||
@@ -175,7 +278,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
val result = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
if (dest.isBml) lookupForBml(input) else lookupForMib(dest, input)
|
if (dest.isBml) lookupForBml(input) else lookupForMib(dest, input)
|
||||||
}
|
}
|
||||||
binding.tilAccount.isEnabled = true
|
stopLookupLoading()
|
||||||
binding.tilDestination.isEnabled = true
|
binding.tilDestination.isEnabled = true
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
showLookupResult(result, input)
|
showLookupResult(result, input)
|
||||||
@@ -186,19 +289,18 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun lookupForBml(input: String): BmlAccountValidation? {
|
private fun lookupForBml(input: String): BmlAccountValidation? {
|
||||||
val bmlSess = app.anyBmlSession() ?: return null
|
val loginId = selectedDest?.bmlLoginId ?: return null
|
||||||
val bmlFlow = BmlLoginFlow()
|
val bmlSess = app.bmlSessions[loginId] ?: return null
|
||||||
|
|
||||||
// 1) Try BML validate
|
// 1) Try BML validate
|
||||||
val validated = try { bmlFlow.validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
val validated = try { BmlValidateClient().validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||||
if (validated != null) return validated
|
if (validated != null) return validated
|
||||||
|
|
||||||
// 2) Try BML MIB verify
|
// 2) Try BML MIB verify
|
||||||
val mibVerified = try { bmlFlow.verifyMibAccount(bmlSess, input) } catch (_: Exception) { null }
|
val mibVerified = try { BmlValidateClient().verifyMibAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||||
if (mibVerified != null) return mibVerified
|
if (mibVerified != null) return mibVerified
|
||||||
|
|
||||||
// 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML)
|
// 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML)
|
||||||
val mibSess = app.mibSession ?: return null
|
val mibSess = app.anyMibSession() ?: return null
|
||||||
return try {
|
return try {
|
||||||
val info = MibTransferClient().lookup(mibSess, input)
|
val info = MibTransferClient().lookup(mibSess, input)
|
||||||
BmlAccountValidation(
|
BmlAccountValidation(
|
||||||
@@ -215,11 +317,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun lookupForMib(dest: DestinationOption, input: String): BmlAccountValidation? {
|
private fun lookupForMib(dest: DestinationOption, input: String): BmlAccountValidation? {
|
||||||
val mibSess = app.mibSession ?: return null
|
val loginId = dest.mibLoginId ?: return null
|
||||||
|
val mibSess = app.mibSessions[loginId] ?: return null
|
||||||
val profile = dest.mibProfile ?: return null
|
val profile = dest.mibProfile ?: return null
|
||||||
|
|
||||||
val mibResult = try {
|
val mibResult = try {
|
||||||
app.mibLoginFlow.switchProfile(mibSess, profile)
|
app.mibFlowFor(loginId).switchProfile(mibSess, profile)
|
||||||
val info = MibTransferClient().lookup(mibSess, input)
|
val info = MibTransferClient().lookup(mibSess, input)
|
||||||
BmlAccountValidation(
|
BmlAccountValidation(
|
||||||
trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL",
|
trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL",
|
||||||
@@ -237,7 +340,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
// MIB lookup failed (e.g. BML USD account) — fall back to BML validate
|
// MIB lookup failed (e.g. BML USD account) — fall back to BML validate
|
||||||
val bmlSess = app.anyBmlSession() ?: return null
|
val bmlSess = app.anyBmlSession() ?: return null
|
||||||
return try { BmlLoginFlow().validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
return try { BmlValidateClient().validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showLookupResult(validation: BmlAccountValidation, input: String) {
|
private fun showLookupResult(validation: BmlAccountValidation, input: String) {
|
||||||
@@ -341,6 +444,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
binding.tilAlias.error = null
|
binding.tilAlias.error = null
|
||||||
|
|
||||||
binding.btnSave.isEnabled = false
|
binding.btnSave.isEnabled = false
|
||||||
|
binding.btnSave.text = "Saving..."
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val success = withContext(Dispatchers.IO) {
|
val success = withContext(Dispatchers.IO) {
|
||||||
@@ -352,31 +456,33 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
dismiss()
|
dismiss()
|
||||||
} else {
|
} else {
|
||||||
binding.btnSave.isEnabled = true
|
binding.btnSave.isEnabled = true
|
||||||
|
binding.btnSave.text = "Save"
|
||||||
Toast.makeText(requireContext(), R.string.contact_save_failed, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.contact_save_failed, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveToBml(alias: String): Boolean {
|
private fun saveToBml(alias: String): Boolean {
|
||||||
val bmlSess = app.anyBmlSession() ?: return false
|
val loginId = selectedDest?.bmlLoginId ?: return false
|
||||||
|
val bmlSess = app.bmlSessions[loginId] ?: return false
|
||||||
val lookup = bmlLookup ?: return false
|
val lookup = bmlLookup ?: return false
|
||||||
val bmlFlow = BmlLoginFlow()
|
|
||||||
val account = lookup.account
|
val account = lookup.account
|
||||||
return when {
|
return when {
|
||||||
account.matches(Regex("^7\\d{12}$")) ->
|
account.matches(Regex("^7\\d{12}$")) ->
|
||||||
// BML account → IAT
|
// BML account → IAT
|
||||||
bmlFlow.saveContact(bmlSess, "IAT", account, alias)
|
BmlContactsClient().saveContact(bmlSess, "IAT", account, alias)
|
||||||
account.matches(Regex("^9\\d{16}$")) ->
|
account.matches(Regex("^9\\d{16}$")) ->
|
||||||
// MIB internal → DOT; swift is BML's internal UUID for MIB bank
|
// MIB internal → DOT; swift is BML's internal UUID for MIB bank
|
||||||
bmlFlow.saveContact(bmlSess, "DOT", account, alias,
|
BmlContactsClient().saveContact(bmlSess, "DOT", account, alias,
|
||||||
currency = lookup.currency, name = lookup.name, swift = MIB_SWIFT_ON_BML)
|
currency = lookup.currency, name = lookup.name, swift = MIB_SWIFT_ON_BML)
|
||||||
else -> false
|
else -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun saveToMib(alias: String): Boolean {
|
private fun saveToMib(alias: String): Boolean {
|
||||||
val mibSess = app.mibSession ?: return false
|
|
||||||
val dest = selectedDest ?: return false
|
val dest = selectedDest ?: return false
|
||||||
|
val loginId = dest.mibLoginId ?: return false
|
||||||
|
val mibSess = app.mibSessions[loginId] ?: return false
|
||||||
val profile = dest.mibProfile ?: return false
|
val profile = dest.mibProfile ?: return false
|
||||||
val account = mibLookupAccount ?: return false
|
val account = mibLookupAccount ?: return false
|
||||||
val currency = binding.etCurrency.text?.toString()?.trim() ?: "MVR"
|
val currency = binding.etCurrency.text?.toString()?.trim() ?: "MVR"
|
||||||
@@ -388,7 +494,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
val name = bmlLookup?.name ?: ""
|
val name = bmlLookup?.name ?: ""
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
app.mibLoginFlow.switchProfile(mibSess, profile)
|
app.mibFlowFor(loginId).switchProfile(mibSess, profile)
|
||||||
MibContactsClient().createContact(
|
MibContactsClient().createContact(
|
||||||
session = mibSess,
|
session = mibSess,
|
||||||
benefType = benefType,
|
benefType = benefType,
|
||||||
@@ -425,17 +531,18 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
requireActivity().lifecycleScope.launch(Dispatchers.IO) {
|
requireActivity().lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
if (dest.isBml) {
|
if (dest.isBml) {
|
||||||
val bmlSess = app.anyBmlSession() ?: return@launch
|
val loginId = dest.bmlLoginId ?: return@launch
|
||||||
val loginId = app.bmlSessions.entries.firstOrNull { it.value == bmlSess }?.key ?: ""
|
val bmlSess = app.bmlSessions[loginId] ?: return@launch
|
||||||
val fresh = BmlLoginFlow().fetchContacts(bmlSess, loginId)
|
val fresh = BmlContactsClient().fetchContacts(bmlSess, loginId)
|
||||||
val existing = viewModel.contacts.value ?: emptyList()
|
val existing = viewModel.contacts.value ?: emptyList()
|
||||||
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
|
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
|
||||||
viewModel.contacts.postValue(merged)
|
viewModel.contacts.postValue(merged)
|
||||||
if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, fresh)
|
if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, fresh)
|
||||||
} else {
|
} else {
|
||||||
val profile = dest.mibProfile ?: return@launch
|
val profile = dest.mibProfile ?: return@launch
|
||||||
val mibSess = app.mibSession ?: return@launch
|
val mibLoginId = dest.mibLoginId ?: return@launch
|
||||||
app.mibLoginFlow.switchProfile(mibSess, profile)
|
val mibSess = app.mibSessions[mibLoginId] ?: return@launch
|
||||||
|
app.mibFlowFor(mibLoginId).switchProfile(mibSess, profile)
|
||||||
val fresh = MibContactsClient().fetchContacts(mibSess)
|
val fresh = MibContactsClient().fetchContacts(mibSess)
|
||||||
.map { it.copy(profileId = profile.profileId) }
|
.map { it.copy(profileId = profile.profileId) }
|
||||||
val existing = viewModel.contacts.value ?: emptyList()
|
val existing = viewModel.contacts.value ?: emptyList()
|
||||||
@@ -456,5 +563,24 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
companion object {
|
companion object {
|
||||||
// BML's internal UUID for MIB bank — used as the "swift" field when saving DOT contacts
|
// 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 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()
|
||||||
|
)
|
||||||