Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4805219f4
|
||
|
|
4c680ad22c
|
||
|
|
494a42c1a8
|
||
|
|
9ace8dd724
|
||
|
|
a3449167db
|
||
|
|
1612e21471
|
||
|
|
f02b05b180
|
||
|
|
5abd981096
|
||
|
|
bb76f4e591
|
||
|
|
8795d5f758
|
||
|
|
0d4f0074c7
|
||
|
|
237d25af67
|
||
|
|
a0c515103a
|
||
|
|
56b464b58e
|
||
|
|
9ab7f979fa
|
||
|
|
7a4b7a1712
|
||
|
|
c0944d3809
|
||
|
|
b05ee44715
|
||
|
|
877147959a
|
||
|
|
68583465de
|
||
|
|
1bf63ed55b
|
||
|
|
3350c84a33
|
||
|
|
449c27ced2
|
||
|
|
3c5d3ff883
|
||
|
|
25b5c80c49
|
||
|
|
2b2fd59543
|
||
|
|
b97d0c5a18
|
||
|
|
4ac328cf52
|
||
|
|
c2f473a6a3
|
||
|
|
9f40c56b49
|
||
|
|
0747fbdbfd
|
||
|
|
528e9eeef8
|
||
|
|
0e7f329a4b
|
||
|
|
2082e8fd0c
|
||
|
|
97a0cb218f
|
||
|
|
0158d6dcd8
|
||
|
|
320eaa2ffb
|
||
|
|
1886113ae7
|
||
|
|
41e7bc70e9
|
||
|
|
d786609bd1
|
||
|
|
2246e5929f
|
||
|
|
d0eee817ec
|
||
|
|
af414914c7
|
||
|
|
ec5b791a45
|
||
|
|
dd4aed0f94
|
||
|
|
fd4cdfecac
|
||
|
|
92f5af76e6
|
||
|
|
bc81255b31
|
||
|
|
acd11ef3eb
|
||
|
|
fc778f2a90
|
||
|
|
778bcc4d75
|
||
|
|
97c8033014
|
||
|
|
58d43f33d6
|
||
|
|
af791fc5ad
|
||
|
|
d8ef3a63c6
|
||
|
|
fbbfdd8537
|
||
|
|
169c3c144c
|
||
|
|
71ef7a5b55
|
||
|
|
32563ea398
|
||
|
|
41ce8a65ad
|
||
|
|
c7d3efa64e
|
||
|
|
2688118f52
|
||
|
|
e717360d00
|
||
|
|
2f26599931
|
||
|
|
f8f9d82440
|
||
|
|
a37354fc82
|
||
|
|
66568a464a
|
||
|
|
5210e6207f
|
||
|
|
369c6e4a37
|
||
|
|
fbedc631c5
|
||
|
|
7d6c192a63
|
||
|
|
e7f7590654
|
||
|
|
a048be939c
|
||
|
|
102a986201
|
||
|
|
440db13a0f
|
||
|
|
0dc81fef62
|
||
|
|
7e52b510a1
|
||
|
|
0d7833af5c
|
||
|
|
9e45e36c0a
|
||
|
|
c883b6b521
|
||
|
|
5dc37aadf0
|
||
|
|
1e3cf3420b
|
||
|
|
d98dcfd407
|
||
|
|
3b55fa30a8
|
||
|
|
7f0f2707a1
|
||
|
|
117733b766
|
||
|
|
d769df6f6b
|
||
|
|
caf0db60da
|
||
|
|
b48022d249
|
||
|
|
7574a5e8b0
|
||
|
|
992c8d4364
|
||
|
|
80fd238195
|
||
|
|
0357d7e0bc
|
||
|
|
71dd654edc
|
||
|
|
a75d8e420a
|
||
|
|
ca6ecc4283
|
||
|
|
f59c2be6c4
|
||
|
|
eb019a20a0
|
||
|
|
c356762baa
|
||
|
|
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
|
@@ -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
|
||||||
@@ -25,24 +27,21 @@ jobs:
|
|||||||
- name: Rename APK
|
- name: Rename APK
|
||||||
run: |
|
run: |
|
||||||
APP_NAME="${{ gitea.repository }}"
|
APP_NAME="${{ gitea.repository }}"
|
||||||
APP_NAME="${APP_NAME##*/}"
|
APP_NAME="${APP_NAME//\//-}"
|
||||||
TAG="${{ gitea.ref_name }}"
|
TAG="${{ gitea.ref_name }}"
|
||||||
find .build/release/release/ -maxdepth 1 -name "*.apk" | head -1 | xargs -I{} mv {} ".build/release/release/${APP_NAME}-${TAG}.apk"
|
find .build/release/release/ -maxdepth 1 -name "*.apk" | head -1 | xargs -I{} mv {} ".build/release/release/${APP_NAME}-${TAG}.apk"
|
||||||
|
|
||||||
- name: Extract release notes
|
- name: Extract release notes
|
||||||
run: |
|
run: |
|
||||||
|
VERSION_CODE=$(grep 'versionCode = ' app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
|
||||||
|
CHANGELOG="fastlane/metadata/android/en-US/changelogs/${VERSION_CODE}.txt"
|
||||||
|
|
||||||
|
if [ -f "$CHANGELOG" ]; then
|
||||||
|
cp "$CHANGELOG" release_notes.md
|
||||||
|
else
|
||||||
|
echo "No changelog found at $CHANGELOG"
|
||||||
echo "No release notes" > release_notes.md
|
echo "No release notes" > release_notes.md
|
||||||
# VERSION="${{ gitea.ref_name }}"
|
fi
|
||||||
# VERSION="${VERSION#v}"
|
|
||||||
#
|
|
||||||
# awk -v ver="$VERSION" '
|
|
||||||
# BEGIN { found=0 }
|
|
||||||
# /^## \[/ {
|
|
||||||
# if (found) exit
|
|
||||||
# if ($0 ~ "\\[" ver "\\]") { found=1; next }
|
|
||||||
# }
|
|
||||||
# found { print }
|
|
||||||
# ' CHANGELOG.md > release_notes.md
|
|
||||||
|
|
||||||
- name: Create Gitea Release
|
- name: Create Gitea Release
|
||||||
env:
|
env:
|
||||||
@@ -51,7 +50,7 @@ jobs:
|
|||||||
GITEA_TOKEN: ${{ secrets.PAT_GITEA }}
|
GITEA_TOKEN: ${{ secrets.PAT_GITEA }}
|
||||||
run: |
|
run: |
|
||||||
APP_NAME="${{ gitea.repository }}"
|
APP_NAME="${{ gitea.repository }}"
|
||||||
APP_NAME="${APP_NAME##*/}"
|
APP_NAME="${APP_NAME//\//-}"
|
||||||
TAG="${{ gitea.ref_name }}"
|
TAG="${{ gitea.ref_name }}"
|
||||||
TITLE="${APP_NAME} ${TAG}"
|
TITLE="${APP_NAME} ${TAG}"
|
||||||
NOTES_FILE="release_notes.md"
|
NOTES_FILE="release_notes.md"
|
||||||
@@ -99,12 +98,12 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
APP_NAME="${{ gitea.repository }}"
|
APP_NAME="${{ gitea.repository }}"
|
||||||
APP_NAME="${APP_NAME##*/}"
|
APP_NAME="${APP_NAME//\//-}"
|
||||||
TAG="${{ gitea.ref_name }}"
|
TAG="${{ gitea.ref_name }}"
|
||||||
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
|
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
|
||||||
CAPTION="${APP_NAME} ${TAG}"
|
CAPTION="$(printf '%s\n\n%s' "${APP_NAME} ${TAG}" "$(cat release_notes.md)" | head -c 1024)"
|
||||||
|
|
||||||
curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument" \
|
curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument" \
|
||||||
-F "chat_id=${TG_CHAT_ID}" \
|
-F "chat_id=${TG_CHAT_ID}" \
|
||||||
-F "document=@${ASSET_PATH}" \
|
-F "document=@${ASSET_PATH}" \
|
||||||
-F "caption=${CAPTION}"
|
--form-string "caption=${CAPTION}"
|
||||||
|
|||||||
@@ -16,5 +16,10 @@ local.properties
|
|||||||
docs/mibapi/tmp
|
docs/mibapi/tmp
|
||||||
docs/bmlapi/tmp
|
docs/bmlapi/tmp
|
||||||
docs/fahipayapi/tmp
|
docs/fahipayapi/tmp
|
||||||
|
docs/mfaisaapi/tmp
|
||||||
|
docs/dhiraaguapi/tmp
|
||||||
|
docs/ooredooapi/tmp
|
||||||
|
docs/ooredooapi/tmp
|
||||||
tmp
|
tmp
|
||||||
app/key.jks
|
app/key.jks
|
||||||
|
.kotlin/*
|
||||||
|
|||||||
Generated
+2
-2
@@ -4,10 +4,10 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2026-05-22T00:11:32.873305232Z">
|
<DropdownSelection timestamp="2026-06-13T17:53:06.478193524Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=683a9830" />
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
|
|||||||
@@ -12,19 +12,31 @@ A native Android client for Maldivian banking services. It is a pure client: req
|
|||||||
|
|
||||||
- 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. See [how to get your TOTP seed](docs/thijooree/faq/totpseed/README.md)
|
||||||
|
|
||||||
## Download
|
## Download APK
|
||||||
[Download latest APK](https://git.shihaam.dev/shihaam/ISODroid/releases/latest)
|
[Gitea Releases](https://git.shihaam.dev/shihaam/thijooree/releases)
|
||||||
|
[Telegram Channel](https://t.me/s/thijooreeapks)
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
Common questions and setup guides are in the [FAQ](docs/thijooree/faq/README.md).
|
||||||
|
|
||||||
## 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
|
## License
|
||||||
|
|
||||||
|
|||||||
+19
-2
@@ -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 = 9
|
versionCode = 35
|
||||||
versionName = "1.0.10"
|
versionName = "1.0.35"
|
||||||
|
|
||||||
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 {
|
||||||
@@ -49,6 +62,7 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding = true
|
viewBinding = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,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,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_logo_background">#CC0000</color>
|
||||||
|
</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,6 +7,14 @@
|
|||||||
<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"
|
||||||
@@ -24,6 +32,7 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:label="@string/app_name">
|
android:label="@string/app_name">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -37,20 +46,24 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".LockActivity"
|
android:name=".LockActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.onboarding.OnboardingActivity"
|
android:name=".ui.onboarding.OnboardingActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.login.LoginActivity"
|
android:name=".ui.login.LoginActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:windowSoftInputMode="adjustResize" />
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ui.home.HomeActivity"
|
android:name=".ui.home.HomeActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation|uiMode|locale|layoutDirection|fontScale|density"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:windowSoftInputMode="adjustPan" />
|
android:windowSoftInputMode="adjustPan" />
|
||||||
|
|
||||||
@@ -59,6 +72,45 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:screenOrientation="portrait" />
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".nfc.BmlTapToPayActivity"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||||
|
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"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import sh.sar.basedbank.api.bml.BmlLoginFlow
|
|||||||
import sh.sar.basedbank.api.bml.BmlProfile
|
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.mfaisa.MfaisaSession
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
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
|
||||||
@@ -48,6 +49,10 @@ class BasedBankApp : Application() {
|
|||||||
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
|
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
|
||||||
var fahipayAccounts: List<BankAccount> = emptyList()
|
var fahipayAccounts: List<BankAccount> = emptyList()
|
||||||
|
|
||||||
|
/** Active M-Faisa sessions keyed by loginId (= msisdn). */
|
||||||
|
val mfaisaSessions: MutableMap<String, MfaisaSession> = mutableMapOf()
|
||||||
|
var mfaisaAccounts: List<BankAccount> = emptyList()
|
||||||
|
|
||||||
// ─── MIB helpers ──────────────────────────────────────────────────────────
|
// ─── MIB helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Returns the MIB session for the given account (matched via loginTag). */
|
/** Returns the MIB session for the given account (matched via loginTag). */
|
||||||
@@ -110,6 +115,26 @@ class BasedBankApp : Application() {
|
|||||||
fun fahipaySessionFor(account: BankAccount): FahipaySession? =
|
fun fahipaySessionFor(account: BankAccount): FahipaySession? =
|
||||||
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
|
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()
|
||||||
|
|
||||||
|
|||||||
@@ -124,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
|
||||||
@@ -270,7 +279,8 @@ class LockActivity : AppCompatActivity() {
|
|||||||
finish()
|
finish()
|
||||||
} else {
|
} else {
|
||||||
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()
|
||||||
if (!hasCredentials) {
|
if (!hasCredentials) {
|
||||||
startActivity(Intent(this, sh.sar.basedbank.ui.login.LoginActivity::class.java))
|
startActivity(Intent(this, sh.sar.basedbank.ui.login.LoginActivity::class.java))
|
||||||
finish()
|
finish()
|
||||||
@@ -278,9 +288,13 @@ class LockActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
val navDest = intent.getIntExtra("nav_destination", -1)
|
val navDest = intent.getIntExtra("nav_destination", -1)
|
||||||
val autoScan = intent.getBooleanExtra("auto_scan", false)
|
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 {
|
startActivity(Intent(this, HomeActivity::class.java).apply {
|
||||||
if (navDest != -1) putExtra("nav_destination", navDest)
|
if (navDest != -1) putExtra("nav_destination", navDest)
|
||||||
if (autoScan) putExtra("auto_scan", true)
|
if (autoScan) putExtra("auto_scan", true)
|
||||||
|
if (autoTapMode) putExtra("auto_tap_mode", true)
|
||||||
|
if (shareQrText != null) putExtra("share_qr_text", shareQrText)
|
||||||
})
|
})
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,28 @@ 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)
|
||||||
|
|
||||||
@@ -19,15 +41,29 @@ class MainActivity : AppCompatActivity() {
|
|||||||
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) {
|
val navDestination = when (intent?.action) {
|
||||||
"sh.sar.basedbank.OPEN_TRANSFER" -> R.id.nav_transfer
|
"sh.sar.basedbank.OPEN_TRANSFER" -> R.id.nav_transfer
|
||||||
"sh.sar.basedbank.OPEN_SCAN_QR" -> 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.OPEN_PAY_WITH_CARD" -> R.id.nav_pay_with_card
|
||||||
|
"sh.sar.basedbank.TAP_TO_PAY" -> R.id.nav_pay_with_card
|
||||||
else -> -1
|
else -> -1
|
||||||
}
|
}
|
||||||
val autoScan = intent?.action == "sh.sar.basedbank.OPEN_SCAN_QR"
|
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
|
||||||
@@ -43,6 +79,8 @@ class MainActivity : AppCompatActivity() {
|
|||||||
startActivity(Intent(this, target).apply {
|
startActivity(Intent(this, target).apply {
|
||||||
if (navDestination != -1) putExtra("nav_destination", navDestination)
|
if (navDestination != -1) putExtra("nav_destination", navDestination)
|
||||||
if (autoScan) putExtra("auto_scan", true)
|
if (autoScan) putExtra("auto_scan", true)
|
||||||
|
if (autoTapMode) putExtra("auto_tap_mode", true)
|
||||||
|
if (shareQrText != null) putExtra("share_qr_text", shareQrText)
|
||||||
})
|
})
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import java.util.concurrent.TimeUnit
|
|||||||
|
|
||||||
internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
||||||
internal val BML_USER_AGENT = "bml-mobile-banking/348 (${Build.MANUFACTURER}; Android ${Build.VERSION.RELEASE}; ${Build.MODEL})"
|
internal val BML_USER_AGENT = "bml-mobile-banking/348 (${Build.MANUFACTURER}; Android ${Build.VERSION.RELEASE}; ${Build.MODEL})"
|
||||||
|
/** Browser User-Agent used for BML's web/Cloudflare-fronted endpoints (login, merchant pay page, ACS). */
|
||||||
|
internal val BML_WEB_USER_AGENT = "Mozilla/5.0 (Android ${Build.VERSION.RELEASE}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
|
||||||
internal const val BML_APP_VERSION = "2.1.44.348"
|
internal const val BML_APP_VERSION = "2.1.44.348"
|
||||||
|
|
||||||
internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
||||||
@@ -14,9 +16,15 @@ internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
|||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headers are sent in the order (and with the names) BML's own app uses. `accept` is not optional:
|
||||||
|
* handled errors come back as JSON either way, but when a route throws (HTTP 500) the server
|
||||||
|
* renders the Internet Banking HTML page — under HTTP 200 — unless the request asks for JSON.
|
||||||
|
*/
|
||||||
internal fun bmlApiRequest(session: BmlSession, url: String): Request =
|
internal fun bmlApiRequest(session: BmlSession, url: String): Request =
|
||||||
Request.Builder().url(url)
|
Request.Builder().url(url)
|
||||||
.header("Authorization", "Bearer ${session.accessToken}")
|
.header("accept", "application/json")
|
||||||
.header("User-Agent", BML_USER_AGENT)
|
|
||||||
.header("x-app-version", BML_APP_VERSION)
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.header("user-agent", BML_USER_AGENT)
|
||||||
|
.header("authorization", "Bearer ${session.accessToken}")
|
||||||
.build()
|
.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 = "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,12 @@ import sh.sar.basedbank.api.models.BankTransaction
|
|||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
|
data class BmlCardHistoryResult(
|
||||||
|
val statement: List<BankTransaction>,
|
||||||
|
val outstanding: List<BankTransaction>,
|
||||||
|
val unbilled: List<BankTransaction>
|
||||||
|
)
|
||||||
|
|
||||||
class BmlHistoryClient {
|
class BmlHistoryClient {
|
||||||
|
|
||||||
private val client = newBmlApiClient()
|
private val client = newBmlApiClient()
|
||||||
@@ -70,7 +76,7 @@ class BmlHistoryClient {
|
|||||||
accountDisplayName: String,
|
accountDisplayName: String,
|
||||||
accountNumber: String,
|
accountNumber: String,
|
||||||
month: String
|
month: String
|
||||||
): List<BankTransaction> {
|
): BmlCardHistoryResult {
|
||||||
val body = """{"card":"$cardId","month":"$month"}"""
|
val body = """{"card":"$cardId","month":"$month"}"""
|
||||||
.toRequestBody("application/json".toMediaType())
|
.toRequestBody("application/json".toMediaType())
|
||||||
val resp = client.newCall(
|
val resp = client.newCall(
|
||||||
@@ -81,6 +87,72 @@ class BmlHistoryClient {
|
|||||||
.build()
|
.build()
|
||||||
).execute()
|
).execute()
|
||||||
val code = resp.code
|
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()
|
val json = resp.body?.string() ?: return emptyList()
|
||||||
resp.close()
|
resp.close()
|
||||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||||
@@ -88,68 +160,22 @@ class BmlHistoryClient {
|
|||||||
return try {
|
return try {
|
||||||
val root = JSONObject(json)
|
val root = JSONObject(json)
|
||||||
if (!root.optBoolean("success")) return emptyList()
|
if (!root.optBoolean("success")) return emptyList()
|
||||||
val payload = root.optJSONObject("payload") ?: return emptyList()
|
val payload = root.optJSONArray("payload") ?: return emptyList()
|
||||||
val result = mutableListOf<BankTransaction>()
|
(0 until payload.length()).map { i ->
|
||||||
|
val item = payload.getJSONObject(i)
|
||||||
val authDetails = payload.optJSONObject("outstanding")
|
BankTransaction(
|
||||||
?.optJSONArray("CardOutStdAuthDetails")
|
id = item.optString("LockedID"),
|
||||||
if (authDetails != null) {
|
date = item.optString("FromDate"),
|
||||||
for (i in 0 until authDetails.length()) {
|
description = "Pending",
|
||||||
val item = authDetails.getJSONObject(i)
|
amount = -item.optDouble("LockedAmount", 0.0),
|
||||||
result.add(BankTransaction(
|
currency = "MVR",
|
||||||
id = "auth_${item.optString("TranApprCode")}_$i",
|
counterpartyName = item.optString("Description").trim().takeIf { it.isNotBlank() },
|
||||||
date = item.optString("DateTime"),
|
reference = null,
|
||||||
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,
|
accountNumber = accountNumber,
|
||||||
accountDisplayName = accountDisplayName,
|
accountDisplayName = accountDisplayName,
|
||||||
source = "BML_CARD"
|
source = "BML"
|
||||||
))
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
val unbilled = payload.optJSONObject("unbilled")
|
|
||||||
?.optJSONArray("CardUnbillTxnDetails")
|
|
||||||
if (unbilled != null) {
|
|
||||||
for (i in 0 until unbilled.length()) {
|
|
||||||
val item = unbilled.getJSONObject(i)
|
|
||||||
result.add(BankTransaction(
|
|
||||||
id = "unbilled_${item.optString("TranApprCode")}_$i",
|
|
||||||
date = item.optString("DateTime"),
|
|
||||||
description = item.optString("TranDesc").trim(),
|
|
||||||
amount = item.optDouble("BillingAmount", 0.0),
|
|
||||||
currency = item.optString("BillingCcy", "MVR"),
|
|
||||||
counterpartyName = null,
|
|
||||||
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
|
|
||||||
accountNumber = accountNumber,
|
|
||||||
accountDisplayName = accountDisplayName,
|
|
||||||
source = "BML_CARD"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val statement = payload.optJSONArray("cardstatement")
|
|
||||||
if (statement != null) {
|
|
||||||
for (i in 0 until statement.length()) {
|
|
||||||
val item = statement.getJSONObject(i)
|
|
||||||
result.add(BankTransaction(
|
|
||||||
id = "stmt_${item.optString("TranRef", i.toString())}",
|
|
||||||
date = item.optString("TransDate", item.optString("TranDate", "")),
|
|
||||||
description = item.optString("TranDesc", item.optString("Description", "")).trim(),
|
|
||||||
amount = -item.optDouble("TranAmount", 0.0),
|
|
||||||
currency = item.optString("TranCcy", "MVR"),
|
|
||||||
counterpartyName = null,
|
|
||||||
reference = item.optString("TranRef").takeIf { it.isNotBlank() },
|
|
||||||
accountNumber = accountNumber,
|
|
||||||
accountDisplayName = accountDisplayName,
|
|
||||||
source = "BML_CARD"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result
|
|
||||||
} catch (_: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ class BmlLoginFlow {
|
|||||||
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
|
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
|
||||||
private val APP_USER_AGENT = "bml-mobile-banking/348 (${android.os.Build.MANUFACTURER}; Android ${android.os.Build.VERSION.RELEASE}; ${android.os.Build.MODEL})"
|
private val APP_USER_AGENT = "bml-mobile-banking/348 (${android.os.Build.MANUFACTURER}; Android ${android.os.Build.VERSION.RELEASE}; ${android.os.Build.MODEL})"
|
||||||
private val APP_VERSION = "2.1.44.348"
|
private val APP_VERSION = "2.1.44.348"
|
||||||
private val WEB_USER_AGENT = "Mozilla/5.0 (Android ${android.os.Build.VERSION.RELEASE}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
|
private val WEB_USER_AGENT = BML_WEB_USER_AGENT
|
||||||
|
|
||||||
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
||||||
private val cookieJar = object : CookieJar {
|
private val cookieJar = object : CookieJar {
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import okhttp3.Cookie
|
||||||
|
import okhttp3.CookieJar
|
||||||
|
import okhttp3.FormBody
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient.Companion.API_BASE
|
||||||
|
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient.Companion.PAGE_ORIGIN
|
||||||
|
import java.security.KeyFactory
|
||||||
|
import java.security.spec.MGF1ParameterSpec
|
||||||
|
import java.security.spec.X509EncodedKeySpec
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.spec.OAEPParameterSpec
|
||||||
|
import javax.crypto.spec.PSource
|
||||||
|
import android.util.Base64
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pays a BML Merchant Services payment link by card, for merchants that don't have BML Pay
|
||||||
|
* enabled. It performs the same request sequence the link's own card form (Pomelo JS) and the
|
||||||
|
* issuer's 3-D Secure page perform in a browser:
|
||||||
|
*
|
||||||
|
* 1. `GET public-client/credentials/<id>` (auth header: the page's `pomeloJsKey`) → RSA public
|
||||||
|
* key + Pomelo API key.
|
||||||
|
* 2. `POST api.pay.pomelopay.com/bin-lookup` with the PAN, CVV and `YYMM` expiry, each
|
||||||
|
* RSA-OAEP(SHA-1) encrypted with that key → a card `tokenId`.
|
||||||
|
* 3. `POST public-client/transactions/next-action` RATE_OPTIONS, polling while the server says
|
||||||
|
* WAIT, until it returns a `3dsUrl`.
|
||||||
|
* 4. The 3-D Secure challenge on BML's Wibmo ACS: the render page auto-posts the `creq`, we pick
|
||||||
|
* the "Authenticator" channel and submit the BML token's TOTP. The ACS then auto-posts the
|
||||||
|
* result to the Mastercard gateway, which posts it back to BML's `mpgsNotification`.
|
||||||
|
* 5. Poll next-action until TRANSACTION_CONFIRMED. A decline after 3-D Secure (e.g. insufficient
|
||||||
|
* funds) doesn't show up there: it's a new `paymentErrorHistory` entry on the transaction,
|
||||||
|
* checked alongside.
|
||||||
|
* 6. Return to the merchant: `GET <txn>?wait=1` redirects to the merchant's `redirectUrl` with a
|
||||||
|
* signed `state=CONFIRMED` — the browser's last hop, and how merchants (Dhiraagu, Ooredoo)
|
||||||
|
* learn they were paid. Without it the card is charged but the merchant never delivers.
|
||||||
|
*
|
||||||
|
* Every call blocks, so run it on an IO thread. Use one instance per payment — it keeps the ACS
|
||||||
|
* session cookies.
|
||||||
|
*/
|
||||||
|
class BmlMerchantCardPayClient {
|
||||||
|
|
||||||
|
data class Card(
|
||||||
|
val pan: String,
|
||||||
|
val expiryMonth: String, // "07"
|
||||||
|
val expiryYear: String, // "28"
|
||||||
|
val cvv: String,
|
||||||
|
val holderName: String
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class Result {
|
||||||
|
/** Paid. [merchantNotified] is false when the return to the merchant (step 6) failed. */
|
||||||
|
data class Success(val merchantNotified: Boolean) : Result()
|
||||||
|
data class Failure(val message: String) : Result()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cookies = mutableMapOf<String, MutableList<Cookie>>()
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(45, TimeUnit.SECONDS)
|
||||||
|
// Credentials/next-action tolerate okhttp, but the Cloudflare-fronted ACS does not — send a
|
||||||
|
// browser UA on everything (only when the caller didn't set one).
|
||||||
|
.addInterceptor { chain ->
|
||||||
|
val req = chain.request()
|
||||||
|
chain.proceed(
|
||||||
|
if (req.header("User-Agent") == null)
|
||||||
|
req.newBuilder().header("User-Agent", BML_WEB_USER_AGENT).build()
|
||||||
|
else req
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.cookieJar(object : CookieJar {
|
||||||
|
override fun saveFromResponse(url: HttpUrl, newCookies: List<Cookie>) {
|
||||||
|
val list = cookies.getOrPut(url.host) { mutableListOf() }
|
||||||
|
for (c in newCookies) { list.removeAll { it.name == c.name }; list.add(c) }
|
||||||
|
}
|
||||||
|
override fun loadForRequest(url: HttpUrl): List<Cookie> =
|
||||||
|
cookies.values.flatten().filter { it.matches(url) }
|
||||||
|
})
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs the whole payment. [otp] returns the current BML token code; it is called again with
|
||||||
|
* `retry = true` if the ACS rejects a code (it can expire between generating and submitting).
|
||||||
|
*/
|
||||||
|
fun pay(page: BmlMerchantTxnClient.PayPage, card: Card, otp: (retry: Boolean) -> String): Result {
|
||||||
|
val pk = page.pomeloKey ?: return Result.Failure("This merchant doesn't accept card payments")
|
||||||
|
val txnId = page.transactionId
|
||||||
|
|
||||||
|
val txnClient = BmlMerchantTxnClient()
|
||||||
|
val browserId = runCatching { txnClient.announceBrowser(txnId) }.getOrNull()
|
||||||
|
// Earlier attempts' declines are already in the history; only newer ones are ours
|
||||||
|
val priorErrors = browserId?.let { id -> runCatching { txnClient.paymentErrors(txnId, id).size }.getOrNull() }
|
||||||
|
|
||||||
|
// 1-2. Credentials, then tokenise the card with Pomelo
|
||||||
|
val creds = getJson("$API_BASE/public-client/credentials/$txnId", pk)
|
||||||
|
val keyInfo = creds.getJSONObject("publicKey")
|
||||||
|
val publicKey = parsePublicKey(keyInfo.getString("publicKeyPem"))
|
||||||
|
val binBody = JSONObject()
|
||||||
|
.put("encryptedCardNumber", encrypt(publicKey, card.pan))
|
||||||
|
.put("encryptedCardSecurityCode", encrypt(publicKey, card.cvv))
|
||||||
|
.put("encryptedCardExpiry", encrypt(publicKey, card.expiryYear + card.expiryMonth))
|
||||||
|
.put("externalId", txnId)
|
||||||
|
.put("cardHolderName", card.holderName)
|
||||||
|
.put("encryptedCardExpiryMonth", card.expiryMonth)
|
||||||
|
.put("encryptedCardExpiryYear", card.expiryYear)
|
||||||
|
.put("encSerialId", keyInfo.getString("publicKeyId"))
|
||||||
|
val binReq = Request.Builder()
|
||||||
|
.url(creds.optString("binLookupUrl").ifBlank { "https://api.pay.pomelopay.com/bin-lookup" })
|
||||||
|
.post(binBody.toString().toRequestBody(JSON))
|
||||||
|
.header("tenant", "bankofmaldives")
|
||||||
|
.header("x-api-key", creds.getString("apiKey"))
|
||||||
|
.header("x-tenant-id", creds.optString("tid"))
|
||||||
|
.build()
|
||||||
|
val bin = execJson(binReq)
|
||||||
|
val tokenId = bin.optString("tokenId").ifBlank { return Result.Failure("Card was not accepted") }
|
||||||
|
|
||||||
|
// 3. Rate options → poll while WAIT → 3-D Secure URL
|
||||||
|
val cardFields = JSONObject()
|
||||||
|
.put("transactionId", txnId)
|
||||||
|
.put("tokenId", tokenId)
|
||||||
|
.put("bin8", bin.optString("bin8"))
|
||||||
|
.put("cardBrand", bin.optString("brand"))
|
||||||
|
for ((from, to) in listOf("issuer" to "cardIssuer", "country" to "cardCountry",
|
||||||
|
"cardCategory" to "cardCategory", "isCommercial" to "isCommercial",
|
||||||
|
"isPrepaid" to "isPrepaid", "isReloadable" to "isReloadable", "paddedPan" to "paddedPan")) {
|
||||||
|
if (bin.has(from) && !bin.isNull(from)) cardFields.put(to, bin.get(from))
|
||||||
|
}
|
||||||
|
var action = nextAction(pk, copy(cardFields).put("action", "RATE_OPTIONS").withBrowserInfo())
|
||||||
|
val resolved = setOf("WAIT", "POLL", "TRANSACTION_CONFIRMED", "TRANSACTION_FAILED")
|
||||||
|
if (action.optString("action") !in resolved && action.optString("3dsUrl").isBlank()) {
|
||||||
|
action = nextAction(pk, copy(cardFields).put("action", "THREEDS").withBrowserInfo())
|
||||||
|
}
|
||||||
|
|
||||||
|
var threeDsUrl: String? = null
|
||||||
|
for (attempt in 0..MAX_POLLS) {
|
||||||
|
when (action.optString("action")) {
|
||||||
|
"TRANSACTION_CONFIRMED" -> return confirmed(txnId)
|
||||||
|
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
|
||||||
|
}
|
||||||
|
threeDsUrl = action.optString("3dsUrl").ifBlank { null }
|
||||||
|
if (threeDsUrl != null) break
|
||||||
|
if (attempt == MAX_POLLS) return Result.Failure("Timed out waiting for the bank")
|
||||||
|
Thread.sleep(POLL_MS)
|
||||||
|
action = poll(pk, txnId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 3-D Secure challenge (handles the authenticator channel + TOTP)
|
||||||
|
runThreeDs(threeDsUrl!!, otp)?.let { return it }
|
||||||
|
|
||||||
|
// 5. Wait for the gateway's verdict to reach BML
|
||||||
|
repeat(MAX_POLLS * 2) {
|
||||||
|
when (poll(pk, txnId).optString("action")) {
|
||||||
|
"TRANSACTION_CONFIRMED" -> return confirmed(txnId)
|
||||||
|
"TRANSACTION_FAILED" -> return Result.Failure("The bank declined the payment")
|
||||||
|
}
|
||||||
|
if (browserId != null && priorErrors != null) {
|
||||||
|
runCatching { txnClient.paymentErrors(txnId, browserId) }.getOrNull()
|
||||||
|
?.drop(priorErrors)?.lastOrNull()
|
||||||
|
?.let { return Result.Failure(it.message.ifBlank { "The bank declined the payment" }) }
|
||||||
|
}
|
||||||
|
Thread.sleep(POLL_MS / 2)
|
||||||
|
}
|
||||||
|
return Result.Failure("Payment status unknown — check with the merchant before retrying")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The payment went through: return to the merchant, then report success either way. */
|
||||||
|
private fun confirmed(txnId: String) = Result.Success(merchantNotified = returnToMerchant(txnId))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the browser does once the payment page sees the confirmation: loads `<txn>?wait=1`,
|
||||||
|
* which 302s to the merchant's `redirectUrl` (`…?transactionId=<id>&state=CONFIRMED&signature=…`)
|
||||||
|
* and on to its receipt page. Some merchants' callback page instead auto-submits a form on
|
||||||
|
* load (Ooredoo's posts the result on to its own site), so those forms are submitted too.
|
||||||
|
* Retries a couple of times; true when the chain ended on a 2xx page.
|
||||||
|
*/
|
||||||
|
private fun returnToMerchant(txnId: String): Boolean {
|
||||||
|
repeat(RETURN_ATTEMPTS) { attempt ->
|
||||||
|
if (attempt > 0) Thread.sleep(RETURN_RETRY_MS)
|
||||||
|
val ok = runCatching {
|
||||||
|
var request = browserNav(Request.Builder().url("$PAGE_ORIGIN/$txnId?wait=1"))
|
||||||
|
for (hop in 0..MAX_AUTO_SUBMITS) {
|
||||||
|
val (code, html, url) = client.newCall(request).execute().use {
|
||||||
|
Triple(it.code, it.body?.string().orEmpty(), it.request.url.toString())
|
||||||
|
}
|
||||||
|
if (code !in 200..299) return@runCatching false
|
||||||
|
// A page that only exists to post itself onward, like the ACS's own hops
|
||||||
|
if (hop == MAX_AUTO_SUBMITS || !AUTO_SUBMIT.containsMatchIn(html)) return@runCatching true
|
||||||
|
val form = AcsForm.parse(html, url) ?: return@runCatching true
|
||||||
|
request = browserNav(form.toRequest().newBuilder())
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}.getOrDefault(false)
|
||||||
|
if (ok) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun browserNav(builder: Request.Builder): Request = builder
|
||||||
|
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||||
|
.header("Accept-Language", "en-US,en;q=0.9")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/** Drives the ACS challenge. Returns null on success, or a Failure to stop the payment. */
|
||||||
|
private fun runThreeDs(threeDsUrl: String, otp: (Boolean) -> String): Result? {
|
||||||
|
// render-tds: an auto-submitting form (with an explicit action) that posts the creq to the
|
||||||
|
// issuer's ACS. The ACS's own channel/OTP forms carry no action attribute — their JS posts
|
||||||
|
// back to this same creq URL — so it is the fallback action for everything that follows.
|
||||||
|
var form = AcsForm.parse(execText(get(threeDsUrl)), null)
|
||||||
|
?: return Result.Failure("Couldn't start card authentication")
|
||||||
|
val acsUrl = form.action
|
||||||
|
var html = execText(form.toRequest())
|
||||||
|
|
||||||
|
// Channel picker (Mobile / Email / Authenticator). The BML token is the "token" channel.
|
||||||
|
if (html.contains("name=\"destValue\"")) {
|
||||||
|
form = AcsForm.parse(html, acsUrl) ?: return Result.Failure("Unexpected authentication page")
|
||||||
|
form.fields["destValue"] = "token"
|
||||||
|
form.fields["selectChannel"] = "token"
|
||||||
|
form.fields["authMethod"] = "OOB"
|
||||||
|
form.fields["otpDest"] = ""
|
||||||
|
form.fields["formReqType"] = "SUBMIT"
|
||||||
|
html = execText(form.toRequest())
|
||||||
|
}
|
||||||
|
|
||||||
|
// OTP entry. Submit the token code; if it expired, ask for a fresh one once and retry.
|
||||||
|
// A rejected code comes back as the OTP page again with "The OTP code you entered is
|
||||||
|
// incorrect Please try again."
|
||||||
|
var retry = false
|
||||||
|
for (attempt in 0..1) {
|
||||||
|
form = AcsForm.parse(html, acsUrl) ?: break
|
||||||
|
if (!form.fields.containsKey("otpValue")) break
|
||||||
|
form.fields["otpValue"] = otp(retry)
|
||||||
|
form.fields["formReqType"] = "SUBMIT"
|
||||||
|
html = execText(form.toRequest())
|
||||||
|
if (!otpRejected(html)) break
|
||||||
|
retry = true
|
||||||
|
}
|
||||||
|
if (otpRejected(html)) return Result.Failure("The bank rejected the BML token code. Check the phone's clock and try again.")
|
||||||
|
// On success the ACS returns an auto-posting form to the gateway; follow it (and the
|
||||||
|
// gateway's own auto-post back to BML) so the verdict is recorded before we poll.
|
||||||
|
repeat(3) {
|
||||||
|
val next = AcsForm.parse(html, acsUrl) ?: return null
|
||||||
|
if (next.fields.keys.none { it == "cres" || it == "order.id" }) return null
|
||||||
|
html = execText(next.toRequest())
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun otpRejected(html: String) = html.contains("name=\"otpValue\"") &&
|
||||||
|
(html.contains("incorrect", true) || html.contains("expired", true))
|
||||||
|
|
||||||
|
// ── next-action helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun nextAction(pk: String, body: JSONObject): JSONObject =
|
||||||
|
execJson(Request.Builder()
|
||||||
|
.url("$API_BASE/public-client/transactions/next-action")
|
||||||
|
.post(body.toString().toRequestBody(JSON))
|
||||||
|
.header("Authorization", pk)
|
||||||
|
.build())
|
||||||
|
|
||||||
|
private fun poll(pk: String, txnId: String): JSONObject =
|
||||||
|
nextAction(pk, JSONObject().put("action", "POLL").put("transactionId", txnId))
|
||||||
|
|
||||||
|
private fun JSONObject.withBrowserInfo(): JSONObject = this
|
||||||
|
.put("javaEnabled", false).put("javascriptEnabled", true)
|
||||||
|
.put("language", "en-US").put("colorDepth", 24)
|
||||||
|
.put("screenHeight", 1850).put("screenWidth", 1080)
|
||||||
|
.put("tz", java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis()) / -60000)
|
||||||
|
.put("userAgent", "Mozilla/5.0 (Android ${android.os.Build.VERSION.RELEASE}; Mobile)")
|
||||||
|
|
||||||
|
private fun copy(o: JSONObject) = JSONObject(o.toString())
|
||||||
|
|
||||||
|
// ── HTTP ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun get(url: String) = Request.Builder().url(url).build()
|
||||||
|
|
||||||
|
private fun getJson(url: String, auth: String): JSONObject =
|
||||||
|
execJson(Request.Builder().url(url).header("Authorization", auth).header("Accept", "application/json").build())
|
||||||
|
|
||||||
|
private fun execJson(request: Request): JSONObject = client.newCall(request).execute().use { r ->
|
||||||
|
val text = r.body?.string().orEmpty()
|
||||||
|
if (!r.isSuccessful) throw Exception("Request failed (HTTP ${r.code})")
|
||||||
|
if (text.isBlank()) JSONObject() else JSONObject(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun execText(request: Request): String = client.newCall(request).execute().use { r ->
|
||||||
|
r.body?.string().orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── RSA-OAEP(SHA-1), matching the Pomelo JS crypto.subtle config ──────────
|
||||||
|
|
||||||
|
private fun parsePublicKey(pem: String): java.security.PublicKey {
|
||||||
|
val der = Base64.decode(pem
|
||||||
|
.replace("-----BEGIN PUBLIC KEY-----", "")
|
||||||
|
.replace("-----END PUBLIC KEY-----", "")
|
||||||
|
.replace(Regex("\\s"), ""), Base64.DEFAULT)
|
||||||
|
return KeyFactory.getInstance("RSA").generatePublic(X509EncodedKeySpec(der))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encrypt(key: java.security.PublicKey, value: String): String {
|
||||||
|
val cipher = Cipher.getInstance("RSA/ECB/OAEPPadding")
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, key, OAEPParameterSpec(
|
||||||
|
"SHA-1", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT))
|
||||||
|
return Base64.encodeToString(cipher.doFinal(value.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One `application/x-www-form-urlencoded` form scraped from an ACS HTML page: its POST target
|
||||||
|
* plus every `<input>` name/value. [fields] is mutable so the caller can fill in the chosen
|
||||||
|
* channel and the OTP before re-submitting.
|
||||||
|
*/
|
||||||
|
private class AcsForm(val action: String, val fields: MutableMap<String, String>) {
|
||||||
|
fun toRequest(): Request {
|
||||||
|
val body = FormBody.Builder()
|
||||||
|
for ((k, v) in fields) body.add(k, v)
|
||||||
|
return Request.Builder().url(action).post(body.build()).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val FORM = Regex("<form\\b[^>]*>", RegexOption.IGNORE_CASE)
|
||||||
|
private val ACTION = Regex("action\\s*=\\s*[\"']([^\"']+)[\"']", RegexOption.IGNORE_CASE)
|
||||||
|
private val INPUT = Regex("<input\\b[^>]*>", RegexOption.IGNORE_CASE)
|
||||||
|
private val NAME = Regex("name\\s*=\\s*[\"']([^\"']+)[\"']", RegexOption.IGNORE_CASE)
|
||||||
|
private val VALUE = Regex("value\\s*=\\s*[\"']([^\"']*)[\"']", RegexOption.IGNORE_CASE)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first `<form>` and its inputs. The form's `action` is used when present;
|
||||||
|
* otherwise [defaultAction] (the ACS pages set it via JS to the current creq URL).
|
||||||
|
* Null only when there is no form, or no action at all.
|
||||||
|
*/
|
||||||
|
fun parse(html: String, defaultAction: String?): AcsForm? {
|
||||||
|
val form = FORM.find(html) ?: return null
|
||||||
|
val action = ACTION.find(form.value)?.groupValues?.get(1)?.let { unescape(it) }
|
||||||
|
?: defaultAction ?: return null
|
||||||
|
val fields = linkedMapOf<String, String>()
|
||||||
|
for (m in INPUT.findAll(html)) {
|
||||||
|
val name = NAME.find(m.value)?.groupValues?.get(1) ?: continue
|
||||||
|
fields[unescape(name)] = unescape(VALUE.find(m.value)?.groupValues?.get(1) ?: "")
|
||||||
|
}
|
||||||
|
return AcsForm(action, fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unescape(s: String) = s
|
||||||
|
.replace("&", "&").replace(""", "\"")
|
||||||
|
.replace(""", "\"").replace("'", "'").replace("<", "<").replace(">", ">")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val JSON = "application/json".toMediaType()
|
||||||
|
private const val POLL_MS = 5_000L
|
||||||
|
private const val MAX_POLLS = 10
|
||||||
|
private const val RETURN_ATTEMPTS = 3
|
||||||
|
private const val RETURN_RETRY_MS = 2_000L
|
||||||
|
/** Auto-submitting merchant pages followed on the way back; Ooredoo has one. */
|
||||||
|
private const val MAX_AUTO_SUBMITS = 2
|
||||||
|
/** `<body onload="document.forms['x'].submit()">` and the like. */
|
||||||
|
private val AUTO_SUBMIT = Regex("""onload\s*=\s*("[^"]*|'[^']*)\.submit\(\)""", RegexOption.IGNORE_CASE)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/<id>`),
|
||||||
|
* e.g. the bill links Fenaka sends. Merchants with BML Pay enabled get their QR's text fetched so it
|
||||||
|
* can go through the regular BML QR payment flow; card-only merchants are paid by
|
||||||
|
* [BmlMerchantCardPayClient] instead — [fetchPayPage] tells the two apart.
|
||||||
|
*/
|
||||||
|
class BmlMerchantTxnClient {
|
||||||
|
|
||||||
|
private val client = newBmlApiClient()
|
||||||
|
|
||||||
|
/** What the payment page knows about a transaction, from its embedded `window.appData`. */
|
||||||
|
data class PayPage(
|
||||||
|
val transactionId: String,
|
||||||
|
val merchantName: String,
|
||||||
|
val merchantAddress: String,
|
||||||
|
/** Major units (the page's amounts are in cents). */
|
||||||
|
val amount: Double,
|
||||||
|
val currency: String,
|
||||||
|
val state: String,
|
||||||
|
/** BML Pay (`bml_mpos`) is offered: pay through [fetchQrPayload] and the QR flow. */
|
||||||
|
val supportsBmlPay: Boolean,
|
||||||
|
/** Card entry (MPGS via Pomelo) is offered: pay with [BmlMerchantCardPayClient]. */
|
||||||
|
val supportsCard: Boolean,
|
||||||
|
/** `pk_production_…` key the page's card form authenticates with. */
|
||||||
|
val pomeloKey: String?
|
||||||
|
) {
|
||||||
|
val isPaid get() = state == "CONFIRMED"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads `/<id>/paynow`. The page is server-rendered with everything inline: the transaction,
|
||||||
|
* the merchant, `availableProviders` (lists `bml_mpos` when BML Pay is enabled — empty for
|
||||||
|
* card-only merchants) and the card form's `pomeloJsKey` / `pomeloJsProviders`.
|
||||||
|
*/
|
||||||
|
fun fetchPayPage(transactionId: String): PayPage {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$PAGE_ORIGIN/$transactionId/paynow")
|
||||||
|
// The page host is behind Cloudflare, which 403s non-browser User-Agents.
|
||||||
|
.header("User-Agent", BML_WEB_USER_AGENT)
|
||||||
|
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||||
|
.header("Accept-Language", "en-US,en;q=0.9")
|
||||||
|
.build()
|
||||||
|
val html = client.newCall(request).execute().use { response ->
|
||||||
|
if (!response.isSuccessful) throw Exception("Payment page failed (HTTP ${response.code})")
|
||||||
|
response.body?.string().orEmpty()
|
||||||
|
}
|
||||||
|
val start = html.indexOf(APP_DATA_PREFIX).takeIf { it >= 0 }
|
||||||
|
?.let { it + APP_DATA_PREFIX.length } ?: throw Exception("Payment page has no app data")
|
||||||
|
val end = html.indexOf("</script>", start).takeIf { it >= 0 } ?: throw Exception("Payment page has no app data")
|
||||||
|
val data = JSONObject(html.substring(start, end))
|
||||||
|
|
||||||
|
val txn = data.optJSONObject("transaction") ?: throw Exception("Payment page has no transaction")
|
||||||
|
val merchant = data.optJSONObject("merchant")
|
||||||
|
val providers = data.optJSONArray("availableProviders") ?: JSONArray()
|
||||||
|
val bmlPay = (0 until providers.length()).any {
|
||||||
|
val p = providers.optJSONObject(it)
|
||||||
|
p?.optString("value") == PROVIDER_BML && p.optBoolean("enabled", true)
|
||||||
|
}
|
||||||
|
val pomeloProviders = data.optJSONArray("pomeloJsProviders") ?: JSONArray()
|
||||||
|
val pomeloKey = data.optString("pomeloJsKey").ifBlank { null }
|
||||||
|
val card = pomeloKey != null && (0 until pomeloProviders.length()).any { pomeloProviders.optString(it) == "mpgs" }
|
||||||
|
val cents = if (txn.isNull("payAmount")) txn.optLong("amount") else txn.optLong("payAmount")
|
||||||
|
|
||||||
|
return PayPage(
|
||||||
|
transactionId = transactionId,
|
||||||
|
merchantName = merchant?.optString("tradingName")?.ifBlank { null }
|
||||||
|
?: merchant?.optString("registeredName").orEmpty(),
|
||||||
|
merchantAddress = listOfNotNull(
|
||||||
|
merchant?.optString("address1")?.ifBlank { null },
|
||||||
|
merchant?.optString("city")?.ifBlank { null }
|
||||||
|
).joinToString(", "),
|
||||||
|
amount = cents / 100.0,
|
||||||
|
currency = txn.optString("payCurrency").ifBlank { txn.optString("currency", "MVR") },
|
||||||
|
state = txn.optString("state"),
|
||||||
|
supportsBmlPay = bmlPay,
|
||||||
|
supportsCard = card,
|
||||||
|
pomeloKey = pomeloKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the transaction's EMV QR payload (`vendorQrCode`).
|
||||||
|
*
|
||||||
|
* A GET on the transaction is 401 without the page's Cognito credentials, but the PATCHes the
|
||||||
|
* page itself sends need no auth and return the full transaction:
|
||||||
|
* - on load, `activeBrowserId` (`<id>_<epoch millis>`);
|
||||||
|
* - on picking "BML" as the payment method, `provider: bml_mpos`.
|
||||||
|
*
|
||||||
|
* A fresh link has no provider yet, so `vendorQrCode` is null until the second PATCH selects
|
||||||
|
* one. Links already opened with BML chosen return it from the first.
|
||||||
|
*/
|
||||||
|
fun fetchQrPayload(transactionId: String): String {
|
||||||
|
val browserId = JSONObject()
|
||||||
|
.put("activeBrowserId", "${transactionId}_${System.currentTimeMillis()}")
|
||||||
|
patch(transactionId, browserId).vendorQrCode()?.let { return it }
|
||||||
|
// Whether the provider PATCH returns the QR itself or it is generated a moment later has
|
||||||
|
// not been observed, so re-read a few times before giving up. Re-reads use the load PATCH:
|
||||||
|
// each provider PATCH counts as another payment attempt.
|
||||||
|
var txn = patch(transactionId, JSONObject().put("provider", PROVIDER_BML))
|
||||||
|
repeat(3) {
|
||||||
|
txn.vendorQrCode()?.let { return it }
|
||||||
|
Thread.sleep(1000)
|
||||||
|
txn = patch(transactionId, browserId)
|
||||||
|
}
|
||||||
|
return txn.vendorQrCode() ?: throw Exception("Transaction has no QR")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The PATCHes the page sends on load: register this "browser" and clear any FX selection.
|
||||||
|
* Returns the browser id, for [paymentErrors].
|
||||||
|
*/
|
||||||
|
fun announceBrowser(transactionId: String): String {
|
||||||
|
val browserId = "${transactionId}_${System.currentTimeMillis()}"
|
||||||
|
patch(transactionId, JSONObject().put("activeBrowserId", browserId))
|
||||||
|
patch(transactionId, JSONObject().put("fx", "reset"))
|
||||||
|
return browserId
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The transaction's failed payment attempts, oldest first, read with the page's load PATCH
|
||||||
|
* as [browserId]. A declined card (e.g. `INSUFFICIENT_FUNDS`) lands here while the
|
||||||
|
* transaction stays payable — `state` doesn't change, `hasError` turns true.
|
||||||
|
*/
|
||||||
|
fun paymentErrors(transactionId: String, browserId: String): List<PaymentError> {
|
||||||
|
val history = patch(transactionId, JSONObject().put("activeBrowserId", browserId))
|
||||||
|
.optJSONArray("paymentErrorHistory") ?: return emptyList()
|
||||||
|
return (0 until history.length()).mapNotNull { history.optJSONObject(it) }.map {
|
||||||
|
PaymentError(
|
||||||
|
code = it.optString("code"),
|
||||||
|
message = it.optString("customerVisibleDescription").ifBlank { it.optString("reason") }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One entry of `paymentErrorHistory`: the gateway's code and the wording BML shows for it. */
|
||||||
|
data class PaymentError(val code: String, val message: String)
|
||||||
|
|
||||||
|
private fun patch(transactionId: String, body: JSONObject): JSONObject {
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("$API_BASE/transactions/$transactionId")
|
||||||
|
.patch(body.toString().toRequestBody("application/json".toMediaType()))
|
||||||
|
.header("Accept", "*/*")
|
||||||
|
.header("Origin", PAGE_ORIGIN)
|
||||||
|
.header("Referer", "$PAGE_ORIGIN/")
|
||||||
|
.build()
|
||||||
|
return client.newCall(request).execute().use { response ->
|
||||||
|
val text = response.body?.string().orEmpty()
|
||||||
|
if (!response.isSuccessful || !text.trimStart().startsWith("{"))
|
||||||
|
throw Exception("Transaction lookup failed (HTTP ${response.code})")
|
||||||
|
JSONObject(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No state check: only QR_CODE_GENERATED has been observed, and BML's payrequest lookup
|
||||||
|
* already rejects a paid or expired QR with its own message. `isNull` first — `optString`
|
||||||
|
* turns a JSON null into the string "null".
|
||||||
|
*/
|
||||||
|
private fun JSONObject.vendorQrCode(): String? =
|
||||||
|
if (isNull("vendorQrCode")) null else optString("vendorQrCode").ifBlank { null }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
internal const val API_BASE = "https://api.merchants.bankofmaldives.com.mv"
|
||||||
|
internal const val PAGE_ORIGIN = "https://transaction.merchants.bankofmaldives.com.mv"
|
||||||
|
private const val APP_DATA_PREFIX = "window.appData = "
|
||||||
|
private const val PROVIDER_BML = "bml_mpos"
|
||||||
|
private val TXN_URL = Regex("^https?://transaction\\.merchants\\.bankofmaldives\\.com\\.mv/([0-9a-fA-F]{24})(?:[/?#].*)?$")
|
||||||
|
private val TXN_ID = Regex("^[0-9a-fA-F]{24}$")
|
||||||
|
|
||||||
|
/** The transaction ID from a bare 24-hex ID or a pasted payment link, else null. */
|
||||||
|
fun parseTransactionId(input: String): String? {
|
||||||
|
val s = input.trim()
|
||||||
|
val id = if (TXN_ID.matches(s)) s else TXN_URL.find(s)?.groupValues?.get(1)
|
||||||
|
return id?.lowercase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,6 +74,13 @@ data class BmlQrPayInfo(
|
|||||||
val currency: String
|
val currency: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pay-request lookup answered `success: false` — [message] is BML's own wording, safe to show
|
||||||
|
* (e.g. code 103 "The payment request has expired", 112 "Unsupported payment link"). Network and
|
||||||
|
* parse failures stay plain exceptions so the generic message is used for those instead.
|
||||||
|
*/
|
||||||
|
class BmlQrPayLookupException(val code: Int, message: String) : Exception(message)
|
||||||
|
|
||||||
data class BmlQrPayResult(
|
data class BmlQrPayResult(
|
||||||
val success: Boolean,
|
val success: Boolean,
|
||||||
val merchant: String = "",
|
val merchant: String = "",
|
||||||
@@ -82,6 +89,15 @@ data class BmlQrPayResult(
|
|||||||
val errorMessage: 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) }
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package sh.sar.basedbank.api.bml
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import android.util.Base64
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
@@ -10,17 +11,27 @@ class BmlQrPayClient {
|
|||||||
private val client = newBmlApiClient()
|
private val client = newBmlApiClient()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a BML QR URL to merchant details.
|
* Resolves a BML QR to merchant details. [payTarget] is the QR URL, or for POS QRs the bare
|
||||||
* [base64Url] is the full QR URL Base64-encoded (standard, with padding).
|
* `35` → `20` → `01` reference.
|
||||||
|
*
|
||||||
|
* The key is Base64-encoded here without padding, as BML's own app sends it. The padded form
|
||||||
|
* resolves too, so this is parity rather than a requirement.
|
||||||
*/
|
*/
|
||||||
fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo {
|
fun lookupPayRequest(session: BmlSession, payTarget: String): BmlQrPayInfo {
|
||||||
|
val key = Base64.encodeToString(
|
||||||
|
payTarget.toByteArray(Charsets.UTF_8), Base64.NO_WRAP or Base64.NO_PADDING)
|
||||||
val request = bmlApiRequest(session,
|
val request = bmlApiRequest(session,
|
||||||
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url")
|
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$key")
|
||||||
return client.newCall(request).execute().use { response ->
|
return client.newCall(request).execute().use { response ->
|
||||||
val body = response.body?.string() ?: throw Exception("No response")
|
val body = response.body?.string() ?: throw Exception("No response")
|
||||||
|
// A server-side error renders an HTML page under HTTP 200 rather than an error JSON.
|
||||||
|
if (!body.trimStart().startsWith("{"))
|
||||||
|
throw Exception("Unexpected non-JSON response (HTTP ${response.code})")
|
||||||
val json = JSONObject(body)
|
val json = JSONObject(body)
|
||||||
if (!json.optBoolean("success"))
|
if (!json.optBoolean("success"))
|
||||||
throw Exception(json.optString("message").ifBlank { "Lookup failed" })
|
throw BmlQrPayLookupException(
|
||||||
|
json.optInt("code"),
|
||||||
|
json.optString("message").ifBlank { "Lookup failed" })
|
||||||
val payload = json.getJSONObject("payload")
|
val payload = json.getJSONObject("payload")
|
||||||
val addr2 = payload.optString("narrative2").trim()
|
val addr2 = payload.optString("narrative2").trim()
|
||||||
val addr3 = payload.optString("narrative3").trim()
|
val addr3 = payload.optString("narrative3").trim()
|
||||||
|
|||||||
@@ -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", "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,7 +84,8 @@ class BmlTransferClient {
|
|||||||
try {
|
try {
|
||||||
val json = JSONObject(bodyStr)
|
val json = JSONObject(bodyStr)
|
||||||
if (!json.optBoolean("success")) {
|
if (!json.optBoolean("success")) {
|
||||||
BmlTransferResult(false, errorMessage = json.optString("message").ifBlank { "Transfer failed" })
|
val payloadStr = json.optString("payload").takeIf { it.isNotBlank() && it != "null" }
|
||||||
|
BmlTransferResult(false, errorMessage = payloadStr ?: json.optString("message").ifBlank { "Transfer failed" })
|
||||||
} else {
|
} else {
|
||||||
val payload = json.optJSONObject("payload")
|
val payload = json.optJSONObject("payload")
|
||||||
BmlTransferResult(
|
BmlTransferResult(
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ class BmlValidateClient {
|
|||||||
originalInput = account,
|
originalInput = account,
|
||||||
name = root.optString("name"),
|
name = root.optString("name"),
|
||||||
alias = null,
|
alias = null,
|
||||||
currency = "MVR",
|
// BML's MIB verify endpoint doesn't return the MIB account's currency.
|
||||||
|
currency = "",
|
||||||
agnt = root.optString("agnt").takeIf { it.isNotBlank() }
|
agnt = root.optString("agnt").takeIf { it.isNotBlank() }
|
||||||
)
|
)
|
||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package sh.sar.basedbank.api.dhiraagu
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.models.BankServerException
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dhiraagu prepaid reload ("Easy TopUp") and bill payment ("Easy Pay") through dhiraagu.com.mv,
|
||||||
|
* paid by card on BML's merchant gateway. Dhiraagu only builds the order; the money moves on the
|
||||||
|
* BML Merchant Services transaction handed back, which is paid like any card-only BML merchant
|
||||||
|
* link. See `docs/dhiraaguapi/02-reload.md` and `docs/dhiraaguapi/03-bill-pay.md`.
|
||||||
|
*
|
||||||
|
* Every call blocks, so run it on an IO thread.
|
||||||
|
*/
|
||||||
|
class DhiraaguPaymentClient {
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the reload order for [number] and the BML transaction paying for it: cart →
|
||||||
|
* merchant (the BML gateway entry) → payment → BML transaction. [amount] is the whole MVR
|
||||||
|
* amount paid, GST included. Returns the 24-hex BML transaction id. Throws with Dhiraagu's
|
||||||
|
* wording when a step is refused.
|
||||||
|
*/
|
||||||
|
fun createReloadTransaction(number: String, amount: Int): String {
|
||||||
|
val topupNonce = pageNonce("$BASE/services/easy-topup")
|
||||||
|
val gst = gstOf(amount)
|
||||||
|
val cart = api("cart", "recharge", topupNonce, JSONObject()
|
||||||
|
.put("formId", FORM_RELOAD)
|
||||||
|
.put("serviceNumber", number)
|
||||||
|
.put("amount", amount)
|
||||||
|
.put("amountGST", gst.toDouble())
|
||||||
|
.put("amountRecharge", (BigDecimal(amount) - gst).toDouble())
|
||||||
|
.put("gstRate", GST_RATE)
|
||||||
|
.put("memberId", "").put("memberName", "").put("memberNId", "")
|
||||||
|
.put("customerId", "").put("customerCode", "")
|
||||||
|
.put("version", 2))
|
||||||
|
return payCart(FORM_RELOAD, cart, BigDecimal(amount))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the bill payment order for postpaid [number] and the BML transaction paying for
|
||||||
|
* it: lookup (for the billing account) → cart → merchant → payment → BML transaction.
|
||||||
|
* [amount] is MVR, up to 2 decimal places. Returns the 24-hex BML transaction id. Throws
|
||||||
|
* with Dhiraagu's wording when a step is refused.
|
||||||
|
*/
|
||||||
|
fun createBillPayTransaction(number: String, amount: BigDecimal): String {
|
||||||
|
val easyPayNonce = pageNonce("$BASE/services/easy-pay")
|
||||||
|
// The cart needs the billing account the number belongs to, which only the lookup gives
|
||||||
|
val info = call("dhiraaguIO", "infoUnlisted", easyPayNonce, JSONObject().put("number", number))
|
||||||
|
if (info.optJSONArray("serviceDetails")?.optJSONObject(0)?.optString("prepaidIndicator") == "Y") {
|
||||||
|
throw Exception("Prepaid number is not allowed.")
|
||||||
|
}
|
||||||
|
val accountNumber = info.optString("accountNumber").ifBlank { throw Exception("Invalid account/service number") }
|
||||||
|
|
||||||
|
// The page refuses some account statuses and customer types before ordering; so do we
|
||||||
|
val rules = runCatching { get("setting", "bill", easyPayNonce).getJSONObject("resp").getJSONObject("settingAppJson1") }.getOrNull()
|
||||||
|
val status = info.optString("accountStatus")
|
||||||
|
if (rules != null && status in rules.blockedValues("accountStatus")) {
|
||||||
|
throw Exception("Dhiraagu can't accept payment for this service. Contact Dhiraagu customer service. [Account Status: $status]")
|
||||||
|
}
|
||||||
|
val customerType = info.optString("customerType")
|
||||||
|
if (rules != null && customerType in rules.blockedValues("customerType")) {
|
||||||
|
throw Exception("The number is not allowed. [Customer Type: $customerType]")
|
||||||
|
}
|
||||||
|
|
||||||
|
val cart = api("cart", "easyPay", easyPayNonce, JSONObject()
|
||||||
|
.put("formId", FORM_BILL)
|
||||||
|
.put("serviceNumber", number)
|
||||||
|
.put("accountNumber", accountNumber)
|
||||||
|
.put("amount", money(amount))
|
||||||
|
.put("memberId", "").put("memberName", "").put("memberNId", "")
|
||||||
|
.put("billRef", "")
|
||||||
|
.put("billType", if (info.optString("type") == BILL_WRITE_OFF) BILL_WRITE_OFF else BILL_PAYMENT))
|
||||||
|
return payCart(FORM_BILL, cart, amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The payment page's half, shared by every form: picks the BML gateway, creates the payment
|
||||||
|
* for [cart] and has Dhiraagu create the BML transaction. Returns its 24-hex id.
|
||||||
|
*/
|
||||||
|
private fun payCart(formId: Int, cart: JSONObject, amount: BigDecimal): String {
|
||||||
|
val cartId = cart.optString("cartId").ifBlank { throw Exception("Dhiraagu didn't create the order") }
|
||||||
|
|
||||||
|
// The payment page carries its own nonce, used for the rest of the order
|
||||||
|
val paymentNonce = pageNonce("$BASE/services/payment-v2?cartid=$cartId")
|
||||||
|
val merchants = apiList("merchant", "form", paymentNonce, JSONObject().put("formId", formId))
|
||||||
|
val bml = (0 until merchants.length()).map { merchants.getJSONObject(it) }
|
||||||
|
.firstOrNull { it.optInt("gatewayId") == GATEWAY_BML }
|
||||||
|
?: throw Exception("Dhiraagu isn't taking BML card payments right now")
|
||||||
|
|
||||||
|
val payment = api("payment", "create", paymentNonce, JSONObject()
|
||||||
|
.put("formId", formId)
|
||||||
|
.put("cartId", cartId)
|
||||||
|
.put("gatewayId", GATEWAY_BML)
|
||||||
|
.put("dhiraaguPayNumber", "")
|
||||||
|
.put("amount", money(amount))
|
||||||
|
.put("paymentMerchantId", bml.getString("merchantId"))
|
||||||
|
.put("memberId", "").put("tokenize", "").put("paymentType", "")
|
||||||
|
.put("recurringFrequency", "").put("bmlTokenId", ""))
|
||||||
|
val paymentId = payment.optString("paymentId").ifBlank { throw Exception("Dhiraagu didn't create the payment") }
|
||||||
|
|
||||||
|
val txn = api("bml", "createV2", paymentNonce, JSONObject().put("paymentId", paymentId))
|
||||||
|
return TXN_URL.find(txn.optString("url"))?.groupValues?.get(1)
|
||||||
|
?: throw Exception("BML didn't create the transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun money(amount: BigDecimal) =
|
||||||
|
String.format(Locale.US, "%.2f", amount.setScale(2, RoundingMode.HALF_UP))
|
||||||
|
|
||||||
|
/** The `val` list of a `setting` rule, e.g. `{"accountStatus":{"val":["F"]}}`. */
|
||||||
|
private fun JSONObject.blockedValues(rule: String): Set<String> {
|
||||||
|
val vals = optJSONObject(rule)?.optJSONArray("val") ?: return emptySet()
|
||||||
|
return (0 until vals.length()).map { vals.optString(it) }.toSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTTP ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Every page embeds a `var nonce = "…"` that its API calls send as the `nonce` header. */
|
||||||
|
private fun pageNonce(url: String): String =
|
||||||
|
NONCE.find(page(url))?.groupValues?.get(1) ?: throw Exception("Dhiraagu page didn't load")
|
||||||
|
|
||||||
|
private fun page(url: String): String = client.newCall(
|
||||||
|
Request.Builder().url(url)
|
||||||
|
.header("User-Agent", UA)
|
||||||
|
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||||
|
.build()
|
||||||
|
).execute().use { r ->
|
||||||
|
if (r.code in 500..599) throw BankServerException("Dhiraagu")
|
||||||
|
r.body?.string().orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun api(sub: String, act: String, nonce: String, body: JSONObject): JSONObject =
|
||||||
|
call(sub, act, nonce, body).getJSONObject("resp")
|
||||||
|
|
||||||
|
private fun apiList(sub: String, act: String, nonce: String, body: JSONObject): JSONArray =
|
||||||
|
call(sub, act, nonce, body).getJSONArray("resp")
|
||||||
|
|
||||||
|
/** POSTs to `sdk-dhr-webapi.ashx`; throws unless `respStatus` is OK. */
|
||||||
|
private fun call(sub: String, act: String, nonce: String, body: JSONObject): JSONObject =
|
||||||
|
send(sub, act, nonce, body.toString().toRequestBody(JSON))
|
||||||
|
|
||||||
|
/** GETs from `sdk-dhr-webapi.ashx` (the settings calls); throws unless `respStatus` is OK. */
|
||||||
|
private fun get(sub: String, act: String, nonce: String): JSONObject = send(sub, act, nonce, null)
|
||||||
|
|
||||||
|
private fun send(sub: String, act: String, nonce: String, body: okhttp3.RequestBody?): JSONObject {
|
||||||
|
val text = client.newCall(
|
||||||
|
Request.Builder().url("$API?website_id=$WEBSITE_ID&sub=$sub&act=$act")
|
||||||
|
.apply { if (body != null) post(body) }
|
||||||
|
.header("User-Agent", UA)
|
||||||
|
.header("Accept", "application/json, text/javascript, */*; q=0.01")
|
||||||
|
.header("X-Requested-With", "XMLHttpRequest")
|
||||||
|
.header("Origin", BASE)
|
||||||
|
.header("nonce", nonce)
|
||||||
|
.build()
|
||||||
|
).execute().use { r ->
|
||||||
|
if (r.code in 500..599) throw BankServerException("Dhiraagu")
|
||||||
|
r.body?.string().orEmpty()
|
||||||
|
}
|
||||||
|
val obj = try { JSONObject(text) } catch (_: Exception) {
|
||||||
|
throw Exception("Unexpected response from Dhiraagu")
|
||||||
|
}
|
||||||
|
if (obj.optString("respStatus") != "OK") {
|
||||||
|
throw Exception(obj.optString("respMsg").ifBlank { obj.optString("resp") }.ifBlank { "Dhiraagu refused the payment" })
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val BASE = "https://www.dhiraagu.com.mv"
|
||||||
|
private const val API = "$BASE/api/sdk-dhr-webapi.ashx"
|
||||||
|
private const val WEBSITE_ID = "CA2BB809-3A22-485B-A518-DA6B6DE653A5"
|
||||||
|
private const val UA = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
|
||||||
|
private val JSON = "application/json".toMediaType()
|
||||||
|
private val NONCE = Regex("""var nonce = "([^"]+)"""")
|
||||||
|
private val TXN_URL = Regex("""transaction\.merchants\.bankofmaldives\.com\.mv/([0-9a-fA-F]{24})""")
|
||||||
|
|
||||||
|
/** Easy Pay's (bill payment) form id across cart / merchant / payment. */
|
||||||
|
private const val FORM_BILL = 1
|
||||||
|
/** Easy TopUp's form id across cart / merchant / payment. */
|
||||||
|
private const val FORM_RELOAD = 2
|
||||||
|
/** Bank of Maldives in `merchant&act=form` (1 = BML, 2 = MIB, 3 = DhiraaguPay). */
|
||||||
|
private const val GATEWAY_BML = 1
|
||||||
|
private const val GST_RATE = 0.08
|
||||||
|
|
||||||
|
/** Easy Pay's `billType`s; the lookup's `type` says which applies. */
|
||||||
|
private const val BILL_PAYMENT = "BillPayment"
|
||||||
|
private const val BILL_WRITE_OFF = "writeOffPayments"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The GST inside a GST-inclusive reload [amount], as the page works it out:
|
||||||
|
* `amount × rate / (1 + rate)`, to 2 places. The number is credited `amount − gst`.
|
||||||
|
*/
|
||||||
|
fun gstOf(amount: Int): BigDecimal {
|
||||||
|
val rate = BigDecimal(GST_RATE.toString())
|
||||||
|
return (BigDecimal(amount) * rate).divide(BigDecimal.ONE + rate, 2, RoundingMode.HALF_UP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package sh.sar.basedbank.api.fahipay
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.RequestBody
|
||||||
|
import okio.Buffer
|
||||||
|
|
||||||
|
/** Form-body helpers shared by the Fahipay POST endpoints (login, OTP, payments). */
|
||||||
|
internal object FahipayForm {
|
||||||
|
|
||||||
|
/** The `device[...]` fields every Fahipay POST carries. */
|
||||||
|
fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
|
||||||
|
"device[available]" to "true",
|
||||||
|
"device[platform]" to "Android",
|
||||||
|
"device[uuid]" to deviceUuid,
|
||||||
|
"device[model]" to Build.MODEL,
|
||||||
|
"device[manufacturer]" to Build.MANUFACTURER,
|
||||||
|
"device[isVirtual]" to "false",
|
||||||
|
"device[serial]" to "unknown"
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a multipart/form-data body with lowercase "content-disposition" headers,
|
||||||
|
* which is what the Fahipay server requires.
|
||||||
|
*/
|
||||||
|
fun body(vararg parts: Pair<String, String>): RequestBody {
|
||||||
|
val boundary = java.util.UUID.randomUUID().toString()
|
||||||
|
val buf = Buffer()
|
||||||
|
for ((name, value) in parts) {
|
||||||
|
val valueBytes = value.toByteArray(Charsets.UTF_8)
|
||||||
|
buf.writeUtf8("--$boundary\r\n")
|
||||||
|
buf.writeUtf8("content-disposition: form-data; name=\"$name\"\r\n")
|
||||||
|
buf.writeUtf8("Content-Length: ${valueBytes.size}\r\n")
|
||||||
|
buf.writeUtf8("\r\n")
|
||||||
|
buf.write(valueBytes)
|
||||||
|
buf.writeUtf8("\r\n")
|
||||||
|
}
|
||||||
|
buf.writeUtf8("--$boundary--\r\n")
|
||||||
|
val snapshot = buf.readByteString()
|
||||||
|
val mediaType = "multipart/form-data; boundary=$boundary".toMediaType()
|
||||||
|
return object : RequestBody() {
|
||||||
|
override fun contentType() = mediaType
|
||||||
|
override fun contentLength() = snapshot.size.toLong()
|
||||||
|
override fun writeTo(sink: okio.BufferedSink) { sink.write(snapshot) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,11 +4,8 @@ import android.os.Build
|
|||||||
import okhttp3.Cookie
|
import okhttp3.Cookie
|
||||||
import okhttp3.CookieJar
|
import okhttp3.CookieJar
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.RequestBody
|
|
||||||
import okio.Buffer
|
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -74,14 +71,14 @@ class FahipayLoginFlow {
|
|||||||
*/
|
*/
|
||||||
fun login(idCard: String, password: String, deviceUuid: String): FahipayLoginStep {
|
fun login(idCard: String, password: String, deviceUuid: String): FahipayLoginStep {
|
||||||
initSession()
|
initSession()
|
||||||
val body = buildFormBody(
|
val body = FahipayForm.body(
|
||||||
"email" to idCard,
|
"email" to idCard,
|
||||||
"password" to password,
|
"password" to password,
|
||||||
"grant_type" to "auth_id",
|
"grant_type" to "auth_id",
|
||||||
"lang" to "en",
|
"lang" to "en",
|
||||||
"version" to "2.0.0",
|
"version" to "2.0.0",
|
||||||
"platform" to "BasedBank",
|
"platform" to "thijooree",
|
||||||
*deviceParts(deviceUuid)
|
*FahipayForm.deviceParts(deviceUuid)
|
||||||
)
|
)
|
||||||
|
|
||||||
val resp = client.newCall(
|
val resp = client.newCall(
|
||||||
@@ -109,15 +106,15 @@ class FahipayLoginFlow {
|
|||||||
* Returns authId.
|
* Returns authId.
|
||||||
*/
|
*/
|
||||||
fun verifyTotp(code: String, deviceUuid: String): String {
|
fun verifyTotp(code: String, deviceUuid: String): String {
|
||||||
val body = buildFormBody(
|
val body = FahipayForm.body(
|
||||||
"code" to code,
|
"code" to code,
|
||||||
"channel" to "totp",
|
"channel" to "totp",
|
||||||
"action" to "login",
|
"action" to "login",
|
||||||
"grant_type" to "auth_id",
|
"grant_type" to "auth_id",
|
||||||
"lang" to "en",
|
"lang" to "en",
|
||||||
"version" to "2.0.0",
|
"version" to "2.0.0",
|
||||||
"platform" to "BasedBank",
|
"platform" to "thijooree",
|
||||||
*deviceParts(deviceUuid)
|
*FahipayForm.deviceParts(deviceUuid)
|
||||||
)
|
)
|
||||||
|
|
||||||
val resp = client.newCall(
|
val resp = client.newCall(
|
||||||
@@ -138,42 +135,6 @@ class FahipayLoginFlow {
|
|||||||
?: throw Exception("No authID in OTP response")
|
?: throw Exception("No authID in OTP response")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
|
|
||||||
"device[available]" to "true",
|
|
||||||
"device[platform]" to "Android",
|
|
||||||
"device[uuid]" to deviceUuid,
|
|
||||||
"device[model]" to Build.MODEL,
|
|
||||||
"device[manufacturer]" to Build.MANUFACTURER,
|
|
||||||
"device[isVirtual]" to "false",
|
|
||||||
"device[serial]" to "unknown"
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a multipart/form-data body with lowercase "content-disposition" headers,
|
|
||||||
* which is what the Fahipay server requires.
|
|
||||||
*/
|
|
||||||
private fun buildFormBody(vararg parts: Pair<String, String>): RequestBody {
|
|
||||||
val boundary = java.util.UUID.randomUUID().toString()
|
|
||||||
val buf = Buffer()
|
|
||||||
for ((name, value) in parts) {
|
|
||||||
val valueBytes = value.toByteArray(Charsets.UTF_8)
|
|
||||||
buf.writeUtf8("--$boundary\r\n")
|
|
||||||
buf.writeUtf8("content-disposition: form-data; name=\"$name\"\r\n")
|
|
||||||
buf.writeUtf8("Content-Length: ${valueBytes.size}\r\n")
|
|
||||||
buf.writeUtf8("\r\n")
|
|
||||||
buf.write(valueBytes)
|
|
||||||
buf.writeUtf8("\r\n")
|
|
||||||
}
|
|
||||||
buf.writeUtf8("--$boundary--\r\n")
|
|
||||||
val snapshot = buf.readByteString()
|
|
||||||
val mediaType = "multipart/form-data; boundary=$boundary".toMediaType()
|
|
||||||
return object : RequestBody() {
|
|
||||||
override fun contentType() = mediaType
|
|
||||||
override fun contentLength() = snapshot.size.toLong()
|
|
||||||
override fun writeTo(sink: okio.BufferedSink) { sink.write(snapshot) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun generateDeviceUuid(): String {
|
fun generateDeviceUuid(): String {
|
||||||
val bytes = ByteArray(8)
|
val bytes = ByteArray(8)
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package sh.sar.basedbank.api.fahipay
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.models.BankServerException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pays a phone number from the Fahipay wallet: Ooredoo Raastas, Ooredoo bill pay, Dhiraagu
|
||||||
|
* reload and Dhiraagu bill pay. All four are the same POST, only the path differs — see
|
||||||
|
* `docs/fahipayapi/09-payments.md`.
|
||||||
|
*/
|
||||||
|
class FahipayPaymentClient {
|
||||||
|
|
||||||
|
private val BASE_URL = "https://fahipay.mv"
|
||||||
|
private val UA = "okhttp/4.12.0"
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A payment the server accepted. [message] is its wording, e.g. "Transaction successful." or,
|
||||||
|
* for Dhiraagu bill pay, "Transaction will be processed shortly.". [transactionId] (`tid`) is
|
||||||
|
* only returned by the reload / Raastas endpoints.
|
||||||
|
*/
|
||||||
|
data class Result(val message: String, val transactionId: String?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POSTs the payment to [path] (e.g. `actions/payment/ooredoo/recharge/`). [amount] is sent as
|
||||||
|
* typed — the caller checks the service's limits first. Returns on success; throws with the
|
||||||
|
* server's message when it refuses, [BankServerException] on a 5xx, and IOException when the
|
||||||
|
* request doesn't get through. Blocking — call from IO.
|
||||||
|
*/
|
||||||
|
fun pay(session: FahipaySession, path: String, number: String, amount: String, deviceUuid: String): Result {
|
||||||
|
val body = FahipayForm.body(
|
||||||
|
"number" to number,
|
||||||
|
"amount" to amount,
|
||||||
|
"lang" to "en",
|
||||||
|
"version" to "2.0.2",
|
||||||
|
"build" to "329",
|
||||||
|
"platform" to "thijooree",
|
||||||
|
*FahipayForm.deviceParts(deviceUuid)
|
||||||
|
)
|
||||||
|
val resp = client.newCall(
|
||||||
|
Request.Builder().url("$BASE_URL/$path")
|
||||||
|
.post(body)
|
||||||
|
.header("authid", session.authId)
|
||||||
|
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
|
||||||
|
.header("User-Agent", UA)
|
||||||
|
.header("accept", "application/json")
|
||||||
|
.build()
|
||||||
|
).execute()
|
||||||
|
val code = resp.code
|
||||||
|
val json = resp.body?.string().orEmpty()
|
||||||
|
resp.close()
|
||||||
|
if (code in 500..599) throw BankServerException("Fahipay")
|
||||||
|
|
||||||
|
val obj = try { JSONObject(json) } catch (_: Exception) {
|
||||||
|
throw Exception("Unexpected response from Fahipay (HTTP $code)")
|
||||||
|
}
|
||||||
|
val message = obj.optString("msg").ifBlank { obj.optString("title") }
|
||||||
|
if (obj.optString("type") != "success") throw Exception(message.ifBlank { "Payment failed" })
|
||||||
|
return Result(
|
||||||
|
message = message,
|
||||||
|
transactionId = obj.optString("tid").takeIf { it.isNotBlank() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,10 +7,18 @@ import okhttp3.Request
|
|||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
data class MibCardActionResult(
|
||||||
|
val success: Boolean,
|
||||||
|
val message: String,
|
||||||
|
val currentStatusCode: String
|
||||||
|
)
|
||||||
|
|
||||||
class MibCardsClient {
|
class MibCardsClient {
|
||||||
|
|
||||||
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
|
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()
|
private val client = OkHttpClient.Builder()
|
||||||
.connectTimeout(20, TimeUnit.SECONDS)
|
.connectTimeout(20, TimeUnit.SECONDS)
|
||||||
.readTimeout(20, TimeUnit.SECONDS)
|
.readTimeout(20, TimeUnit.SECONDS)
|
||||||
@@ -20,7 +28,7 @@ class MibCardsClient {
|
|||||||
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
|
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
|
||||||
"mbnonce=${session.nonceGenerator}; time-tracker=597"
|
"mbnonce=${session.nonceGenerator}; time-tracker=597"
|
||||||
|
|
||||||
fun fetchCards(session: MibSession, loginTag: String): List<MibCard> {
|
fun fetchCards(session: MibSession, loginTag: String, profileId: String = ""): List<MibCard> {
|
||||||
val body = FormBody.Builder()
|
val body = FormBody.Builder()
|
||||||
.add("name", "")
|
.add("name", "")
|
||||||
.add("start", "1")
|
.add("start", "1")
|
||||||
@@ -32,7 +40,7 @@ class MibCardsClient {
|
|||||||
.url("$BASE_WV_URL/ajaxDebitCard/fetchCardInfos")
|
.url("$BASE_WV_URL/ajaxDebitCard/fetchCardInfos")
|
||||||
.post(body)
|
.post(body)
|
||||||
.header("Cookie", cookieHeader(session))
|
.header("Cookie", cookieHeader(session))
|
||||||
.header("User-Agent", "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36")
|
.header("User-Agent", USER_AGENT)
|
||||||
.header("X-Requested-With", "XMLHttpRequest")
|
.header("X-Requested-With", "XMLHttpRequest")
|
||||||
.header("Accept", "*/*")
|
.header("Accept", "*/*")
|
||||||
.header("Origin", BASE_WV_URL)
|
.header("Origin", BASE_WV_URL)
|
||||||
@@ -55,9 +63,42 @@ class MibCardsClient {
|
|||||||
customerId = item.optString("customerId"),
|
customerId = item.optString("customerId"),
|
||||||
phoneNumber = item.optString("phoneNumber"),
|
phoneNumber = item.optString("phoneNumber"),
|
||||||
cardHolderName = item.optString("cardHolderName"),
|
cardHolderName = item.optString("cardHolderName"),
|
||||||
loginTag = loginTag
|
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")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,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"
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
}
|
}
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
/** Swap the seed used for silent re-login after the user replaces it on the OTP screen. */
|
||||||
|
fun updateOtpSeed(otpSeed: String) {
|
||||||
|
if (storedOtpSeed != null) storedOtpSeed = otpSeed
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Public entry point ───────────────────────────────────────────────────
|
// ─── Public entry point ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ data class MibTransferResult(
|
|||||||
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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -55,7 +56,8 @@ data class MibCard(
|
|||||||
val customerId: String,
|
val customerId: String,
|
||||||
val phoneNumber: String,
|
val phoneNumber: String,
|
||||||
val cardHolderName: String,
|
val cardHolderName: String,
|
||||||
val loginTag: String
|
val loginTag: String,
|
||||||
|
val profileId: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
data class MibFinanceDeal(
|
data class MibFinanceDeal(
|
||||||
|
|||||||
@@ -130,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"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,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,90 @@
|
|||||||
|
package sh.sar.basedbank.api.ooredoo
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
import sh.sar.basedbank.api.models.BankServerException
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ooredoo prepaid recharge (Raastas) and bill payment through the ooredoo.mv Quick Pay pages,
|
||||||
|
* paid by card on BML's merchant gateway. Ooredoo only creates the order; the money moves on the
|
||||||
|
* BML Merchant Services transaction it hands back, which is paid like any card-only BML merchant
|
||||||
|
* link. See `docs/ooredooapi/02-raastas.md` and `docs/ooredooapi/03-bill-pay.md`.
|
||||||
|
*
|
||||||
|
* Every call blocks, so run it on an IO thread.
|
||||||
|
*/
|
||||||
|
class OoredooPaymentClient {
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the recharge order for [number] (7 digits) and the BML transaction paying for it.
|
||||||
|
* [amount] is what the number is credited, in whole MVR; the card pays [charged], which is
|
||||||
|
* that plus GST. Returns the 24-hex BML transaction id. Throws with Ooredoo's wording when
|
||||||
|
* the order is refused.
|
||||||
|
*/
|
||||||
|
fun createRaastasTransaction(number: String, amount: Int, charged: BigDecimal): String =
|
||||||
|
createOrder(number, charged, amount.toString(), transType = "recharge", serviceType = "prepaid")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates the bill payment order for postpaid [number] (7 digits) and the BML transaction
|
||||||
|
* paying for it. [amount] is MVR, up to 2 decimal places; no GST. Returns the 24-hex BML
|
||||||
|
* transaction id. Throws with Ooredoo's wording when the order is refused.
|
||||||
|
*/
|
||||||
|
fun createBillPayTransaction(number: String, amount: BigDecimal): String =
|
||||||
|
createOrder(number, amount, money(amount), transType = "billpay", serviceType = "Mobile")
|
||||||
|
|
||||||
|
/** `POST PaymentGateway/bml` — one call makes the order and its BML transaction. */
|
||||||
|
private fun createOrder(
|
||||||
|
number: String, charged: BigDecimal, amountWithoutGst: String, transType: String, serviceType: String,
|
||||||
|
): String {
|
||||||
|
val msisdn = "960$number"
|
||||||
|
val body = JSONObject()
|
||||||
|
.put("msisdn", msisdn)
|
||||||
|
.put("purchaseAmount", money(charged))
|
||||||
|
.put("amountWithoutGst", amountWithoutGst)
|
||||||
|
.put("receiverMsisdn", msisdn)
|
||||||
|
.put("transType", transType)
|
||||||
|
.put("serviceType", serviceType)
|
||||||
|
.put("serviceTypeDisplayName", "Mobile")
|
||||||
|
val text = client.newCall(
|
||||||
|
Request.Builder().url("$BASE/ooredoo-prod/PaymentGateway/bml")
|
||||||
|
.post(body.toString().toRequestBody(JSON))
|
||||||
|
.header("User-Agent", UA)
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.header("Origin", BASE)
|
||||||
|
.build()
|
||||||
|
).execute().use { r ->
|
||||||
|
if (r.code in 500..599) throw BankServerException("Ooredoo")
|
||||||
|
r.body?.string().orEmpty()
|
||||||
|
}
|
||||||
|
val obj = try { JSONObject(text) } catch (_: Exception) {
|
||||||
|
throw Exception("Unexpected response from Ooredoo")
|
||||||
|
}
|
||||||
|
if (obj.optString("status") != "OK" || obj.optString("code") != "2000") {
|
||||||
|
throw Exception(obj.optString("msg").ifBlank { "Ooredoo refused the payment" })
|
||||||
|
}
|
||||||
|
val data = obj.optJSONObject("data") ?: throw Exception("Ooredoo didn't create the order")
|
||||||
|
return TXN_URL.find(data.optString("bmlUrl"))?.groupValues?.get(1)
|
||||||
|
?: throw Exception("BML didn't create the transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun money(amount: BigDecimal) =
|
||||||
|
String.format(Locale.US, "%.2f", amount.setScale(2, RoundingMode.HALF_UP))
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val BASE = "https://www.ooredoo.mv"
|
||||||
|
private const val UA = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
|
||||||
|
private val JSON = "application/json".toMediaType()
|
||||||
|
private val TXN_URL = Regex("""transaction\.merchants\.bankofmaldives\.com\.mv/([0-9a-fA-F]{24})""")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,186 @@
|
|||||||
|
package sh.sar.basedbank.nfc
|
||||||
|
|
||||||
|
import android.nfc.Tag
|
||||||
|
import android.nfc.tech.IsoDep
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal contactless EMV reader: selects the payment app, runs GPO and reads the
|
||||||
|
* AFL records until it finds the PAN (tag 5A / Track 2 tag 57) and expiry (5F24 / Track 2).
|
||||||
|
*/
|
||||||
|
object EmvCardReader {
|
||||||
|
|
||||||
|
/** [expiry] is "MM/YY". */
|
||||||
|
data class CardData(val pan: String, val expiry: String?)
|
||||||
|
|
||||||
|
private class Collected {
|
||||||
|
var pan: String? = null
|
||||||
|
var expiry: String? = null
|
||||||
|
val complete get() = pan != null && expiry != null
|
||||||
|
fun result() = pan?.let { CardData(it, expiry) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the card data, or null if the PAN couldn't be read. Blocking — call off the main thread. */
|
||||||
|
fun read(tag: Tag): CardData? {
|
||||||
|
val iso = IsoDep.get(tag) ?: return null
|
||||||
|
val c = Collected()
|
||||||
|
iso.use {
|
||||||
|
it.connect()
|
||||||
|
it.timeout = 5000
|
||||||
|
|
||||||
|
val aids = selectPpse(it).ifEmpty { KNOWN_AIDS }
|
||||||
|
for (aid in aids) {
|
||||||
|
val fci = transceive(it, selectApdu(aid)) ?: continue
|
||||||
|
val pdol = findTag(fci, 0x9F38)
|
||||||
|
val gpo = transceive(it, gpoApdu(pdol)) ?: continue
|
||||||
|
collect(gpo, c)
|
||||||
|
if (c.complete) return c.result()
|
||||||
|
|
||||||
|
// Format 1 (tag 80): AIP (2 bytes) + AFL. Format 2 (tag 77): AFL in tag 94.
|
||||||
|
val afl = findTag(gpo, 0x94)
|
||||||
|
?: findTag(gpo, 0x80)?.let { b -> if (b.size > 2) b.copyOfRange(2, b.size) else null }
|
||||||
|
?: continue
|
||||||
|
for (i in 0 until afl.size / 4) {
|
||||||
|
val sfi = (afl[i * 4].toInt() and 0xFF) shr 3
|
||||||
|
val first = afl[i * 4 + 1].toInt() and 0xFF
|
||||||
|
val last = afl[i * 4 + 2].toInt() and 0xFF
|
||||||
|
for (rec in first..last) {
|
||||||
|
val data = transceive(it, readRecordApdu(sfi, rec)) ?: continue
|
||||||
|
collect(data, c)
|
||||||
|
if (c.complete) return c.result()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (c.pan != null) return c.result()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c.result()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val KNOWN_AIDS = listOf(
|
||||||
|
"A0000000031010", // Visa
|
||||||
|
"A0000000041010", // Mastercard
|
||||||
|
"A0000000043060", // Maestro
|
||||||
|
"A000000025010801", // Amex
|
||||||
|
"A0000003330101", // UnionPay
|
||||||
|
).map { hex(it) }
|
||||||
|
|
||||||
|
private fun selectPpse(iso: IsoDep): List<ByteArray> {
|
||||||
|
val resp = transceive(iso, selectApdu("2PAY.SYS.DDF01".toByteArray())) ?: return emptyList()
|
||||||
|
return findAllTags(resp, 0x4F)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun collect(data: ByteArray, c: Collected) {
|
||||||
|
findTag(data, 0x5A)?.let { c.pan = c.pan ?: toHex(it).trimEnd('F') }
|
||||||
|
findTag(data, 0x57)?.let { raw ->
|
||||||
|
val t2 = toHex(raw)
|
||||||
|
c.pan = c.pan ?: t2.substringBefore('D')
|
||||||
|
// Track 2: PAN 'D' YYMM service-code ...
|
||||||
|
val yymm = t2.substringAfter('D', "").take(4)
|
||||||
|
if (c.expiry == null && yymm.length == 4) c.expiry = "${yymm.substring(2, 4)}/${yymm.substring(0, 2)}"
|
||||||
|
}
|
||||||
|
findTag(data, 0x5F24)?.let { raw ->
|
||||||
|
val yymmdd = toHex(raw)
|
||||||
|
if (yymmdd.length >= 4) c.expiry = "${yymmdd.substring(2, 4)}/${yymmdd.substring(0, 2)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectApdu(aid: ByteArray): ByteArray =
|
||||||
|
byteArrayOf(0x00, 0xA4.toByte(), 0x04, 0x00, aid.size.toByte()) + aid + byteArrayOf(0x00)
|
||||||
|
|
||||||
|
private fun readRecordApdu(sfi: Int, rec: Int): ByteArray =
|
||||||
|
byteArrayOf(0x00, 0xB2.toByte(), rec.toByte(), ((sfi shl 3) or 0x04).toByte(), 0x00)
|
||||||
|
|
||||||
|
/** Builds GPO with the PDOL filled in: sensible TTQ/country/currency/date, zeros otherwise. */
|
||||||
|
private fun gpoApdu(pdol: ByteArray?): ByteArray {
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
if (pdol != null) {
|
||||||
|
var i = 0
|
||||||
|
while (i < pdol.size) {
|
||||||
|
var tag = pdol[i].toInt() and 0xFF
|
||||||
|
i++
|
||||||
|
if (tag and 0x1F == 0x1F) {
|
||||||
|
do {
|
||||||
|
tag = (tag shl 8) or (pdol[i].toInt() and 0xFF)
|
||||||
|
} while (pdol[i++].toInt() and 0x80 != 0 && i < pdol.size)
|
||||||
|
}
|
||||||
|
if (i >= pdol.size) break
|
||||||
|
val len = pdol[i++].toInt() and 0xFF
|
||||||
|
val value = when (tag) {
|
||||||
|
0x9F66 -> hex("B620C000") // TTQ: contactless qVSDC, online capable
|
||||||
|
0x9F1A, 0x5F2A -> hex("0462") // Maldives / MVR
|
||||||
|
0x9A -> hex("260101")
|
||||||
|
0x9C -> hex("00")
|
||||||
|
0x9F37 -> hex("12345678")
|
||||||
|
else -> ByteArray(len)
|
||||||
|
}
|
||||||
|
out.write(value.copyOf(len))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val pdolData = out.toByteArray()
|
||||||
|
val body = byteArrayOf(0x83.toByte(), pdolData.size.toByte()) + pdolData
|
||||||
|
return byteArrayOf(0x80.toByte(), 0xA8.toByte(), 0x00, 0x00, body.size.toByte()) + body + byteArrayOf(0x00)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends an APDU, returning the response data on 9000 (following 61xx / 6Cxx), else null. */
|
||||||
|
private fun transceive(iso: IsoDep, apdu: ByteArray): ByteArray? {
|
||||||
|
var resp = iso.transceive(apdu)
|
||||||
|
if (resp.size < 2) return null
|
||||||
|
var sw1 = resp[resp.size - 2].toInt() and 0xFF
|
||||||
|
if (sw1 == 0x6C) {
|
||||||
|
val retry = apdu.copyOf()
|
||||||
|
retry[retry.size - 1] = resp[resp.size - 1]
|
||||||
|
resp = iso.transceive(retry)
|
||||||
|
sw1 = resp[resp.size - 2].toInt() and 0xFF
|
||||||
|
}
|
||||||
|
if (sw1 == 0x61) {
|
||||||
|
resp = iso.transceive(byteArrayOf(0x00, 0xC0.toByte(), 0x00, 0x00, resp[resp.size - 1]))
|
||||||
|
sw1 = resp[resp.size - 2].toInt() and 0xFF
|
||||||
|
}
|
||||||
|
val sw2 = resp[resp.size - 1].toInt() and 0xFF
|
||||||
|
return if (sw1 == 0x90 && sw2 == 0x00) resp.copyOf(resp.size - 2) else null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── BER-TLV ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun findTag(data: ByteArray, target: Int): ByteArray? = findAllTags(data, target).firstOrNull()
|
||||||
|
|
||||||
|
private fun findAllTags(data: ByteArray, target: Int): List<ByteArray> {
|
||||||
|
val found = mutableListOf<ByteArray>()
|
||||||
|
walk(data, 0, data.size, target, found)
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun walk(data: ByteArray, start: Int, end: Int, target: Int, found: MutableList<ByteArray>) {
|
||||||
|
var i = start
|
||||||
|
while (i < end) {
|
||||||
|
val b0 = data[i].toInt() and 0xFF
|
||||||
|
if (b0 == 0x00 || b0 == 0xFF) { i++; continue } // padding
|
||||||
|
val constructed = b0 and 0x20 != 0
|
||||||
|
var tag = b0
|
||||||
|
i++
|
||||||
|
if (b0 and 0x1F == 0x1F) {
|
||||||
|
while (i < end) {
|
||||||
|
val b = data[i++].toInt() and 0xFF
|
||||||
|
tag = (tag shl 8) or b
|
||||||
|
if (b and 0x80 == 0) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (i >= end) return
|
||||||
|
var len = data[i++].toInt() and 0xFF
|
||||||
|
if (len and 0x80 != 0) {
|
||||||
|
val n = len and 0x7F
|
||||||
|
len = 0
|
||||||
|
repeat(n) { if (i < end) len = (len shl 8) or (data[i++].toInt() and 0xFF) }
|
||||||
|
}
|
||||||
|
if (len < 0 || i + len > end) return
|
||||||
|
if (tag == target) found.add(data.copyOfRange(i, i + len))
|
||||||
|
if (constructed) walk(data, i, i + len, target, found)
|
||||||
|
i += len
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hex(s: String): ByteArray =
|
||||||
|
ByteArray(s.length / 2) { s.substring(it * 2, it * 2 + 2).toInt(16).toByte() }
|
||||||
|
|
||||||
|
private fun toHex(b: ByteArray): String = b.joinToString("") { "%02X".format(it) }
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,9 +27,10 @@ class AccountHistoryAdapter(
|
|||||||
|
|
||||||
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: BankTransaction) : 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>()
|
||||||
@@ -37,15 +38,22 @@ class AccountHistoryAdapter(
|
|||||||
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: ((BankAccount) -> Unit)? = null
|
var onTransferClick: ((BankAccount) -> Unit)? = null
|
||||||
|
var onDefaultToggle: ((Boolean) -> Unit)? = null
|
||||||
private var hideAmounts: Boolean = false
|
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) {
|
fun setHideAmounts(hide: Boolean) {
|
||||||
if (hideAmounts == hide) return
|
if (hideAmounts == hide) return
|
||||||
hideAmounts = hide
|
hideAmounts = hide
|
||||||
notifyItemChanged(0) // refresh header card
|
notifyItemChanged(0) // refresh header card
|
||||||
// refresh all transaction rows
|
for (i in pendingItems.indices) {
|
||||||
|
if (pendingItems[i] is Item.Trx) notifyItemChanged(i + 1)
|
||||||
|
}
|
||||||
for (i in displayItems.indices) {
|
for (i in displayItems.indices) {
|
||||||
if (displayItems[i] is Item.Trx) notifyItemChanged(i + 1)
|
if (displayItems[i] is Item.Trx) notifyItemChanged(1 + pendingItems.size + i)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +61,7 @@ class AccountHistoryAdapter(
|
|||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,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
|
||||||
@@ -122,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
|
||||||
}
|
}
|
||||||
@@ -152,8 +184,11 @@ 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(display)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,6 +209,20 @@ class AccountHistoryAdapter(
|
|||||||
b.llHeaderBlocked.visibility = View.GONE
|
b.llHeaderBlocked.visibility = View.GONE
|
||||||
}
|
}
|
||||||
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||||
|
|
||||||
|
if (showDefaultToggle) {
|
||||||
|
b.dividerDefaultAccount.visibility = View.VISIBLE
|
||||||
|
b.llDefaultAccountRow.visibility = View.VISIBLE
|
||||||
|
b.switchDefaultAccount.setOnCheckedChangeListener(null)
|
||||||
|
b.switchDefaultAccount.isChecked = isDefaultAccount
|
||||||
|
b.switchDefaultAccount.setOnCheckedChangeListener { _, checked ->
|
||||||
|
isDefaultAccount = checked
|
||||||
|
onDefaultToggle?.invoke(checked)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
b.dividerDefaultAccount.visibility = View.GONE
|
||||||
|
b.llDefaultAccountRow.visibility = View.GONE
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,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: BankTransaction) {
|
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
|
||||||
@@ -220,7 +269,7 @@ 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) {
|
if (hideAmounts) {
|
||||||
b.tvAmount.text = "${trx.currency} ••••••"
|
b.tvAmount.text = "${trx.currency} ••••••"
|
||||||
@@ -267,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())
|
||||||
|
|
||||||
@@ -288,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)
|
||||||
|
|||||||
@@ -24,12 +24,15 @@ import sh.sar.basedbank.BasedBankApp
|
|||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.models.BankServerException
|
import sh.sar.basedbank.api.models.BankServerException
|
||||||
|
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.models.BankTransaction
|
import sh.sar.basedbank.api.models.BankTransaction
|
||||||
import sh.sar.basedbank.api.mib.TransactionCache
|
import sh.sar.basedbank.api.mib.TransactionCache
|
||||||
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
|
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
|
||||||
import sh.sar.basedbank.util.AccountHistoryParser
|
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.HistoryFetcher
|
||||||
import sh.sar.basedbank.util.MerchantIconCache
|
import sh.sar.basedbank.util.MerchantIconCache
|
||||||
|
|
||||||
@@ -80,6 +83,24 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
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
|
||||||
|
|
||||||
@@ -119,6 +140,7 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
(activity as? HomeActivity)?.setRefreshing(true)
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
loadNextPage()
|
loadNextPage()
|
||||||
|
loadPendingTransactions()
|
||||||
|
|
||||||
binding.swipeRefresh.setOnRefreshListener {
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -131,7 +153,12 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
|
|
||||||
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() {
|
||||||
@@ -160,6 +187,7 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
binding.emptyView.visibility = View.GONE
|
binding.emptyView.visibility = View.GONE
|
||||||
}
|
}
|
||||||
loadNextPage()
|
loadNextPage()
|
||||||
|
loadPendingTransactions()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadNextPage() {
|
private fun loadNextPage() {
|
||||||
@@ -201,6 +229,13 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
(activity as? HomeActivity)?.hideConnectivityBanner()
|
(activity as? HomeActivity)?.hideConnectivityBanner()
|
||||||
|
|
||||||
|
fetcher.takeCardPendingSections()?.let { (outstanding, unbilled) ->
|
||||||
|
adapter.setLeadingSections(listOf(
|
||||||
|
"Outstanding" to outstanding,
|
||||||
|
"Unbilled" to unbilled
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
if (transactions.isNotEmpty()) {
|
if (transactions.isNotEmpty()) {
|
||||||
val existingIds = allTransactions.map { it.id }.toHashSet()
|
val existingIds = allTransactions.map { it.id }.toHashSet()
|
||||||
val newOnes = transactions.filter { it.id !in existingIds }
|
val newOnes = transactions.filter { it.id !in existingIds }
|
||||||
@@ -226,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
|
||||||
|
|||||||
@@ -75,10 +75,12 @@ class AccountsAdapter(
|
|||||||
"BML" -> "Bank of Maldives"
|
"BML" -> "Bank of Maldives"
|
||||||
"FAHIPAY" -> "Fahipay"
|
"FAHIPAY" -> "Fahipay"
|
||||||
"MIB" -> "Maldives Islamic Bank"
|
"MIB" -> "Maldives Islamic Bank"
|
||||||
|
"MFAISA" -> if (account.profileType == "MFAISA_PAYPAL") "PayPal · M-Faisa" else "M-Faisa"
|
||||||
else -> account.bank
|
else -> account.bank
|
||||||
}
|
}
|
||||||
val profileLabel = when (account.bank) {
|
val profileLabel = when (account.bank) {
|
||||||
"MIB" -> account.productCode.ifBlank { account.profileName }
|
"MIB" -> account.productCode.ifBlank { account.profileName }
|
||||||
|
"MFAISA" -> "" // bank-level grouping is already specific (M-Faisa / PayPal · M-Faisa)
|
||||||
else -> account.profileName
|
else -> account.profileName
|
||||||
}
|
}
|
||||||
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
|
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
|
||||||
@@ -144,6 +146,7 @@ class AccountsAdapter(
|
|||||||
"BML" -> R.drawable.bml_logo_vector
|
"BML" -> R.drawable.bml_logo_vector
|
||||||
"FAHIPAY" -> R.drawable.fahipay_logo
|
"FAHIPAY" -> R.drawable.fahipay_logo
|
||||||
"MIB" -> R.drawable.mib_logo
|
"MIB" -> R.drawable.mib_logo
|
||||||
|
"MFAISA" -> R.drawable.ooredoo_logo
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (staticLogo != null) binding.ivBankLogo.setImageResource(staticLogo)
|
if (staticLogo != null) binding.ivBankLogo.setImageResource(staticLogo)
|
||||||
|
|||||||
@@ -89,6 +89,52 @@ 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> {
|
||||||
@@ -188,6 +234,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
private fun setupAccountSearch() {
|
private fun setupAccountSearch() {
|
||||||
binding.tilAccount.setEndIconOnClickListener { performLookup() }
|
binding.tilAccount.setEndIconOnClickListener { performLookup() }
|
||||||
|
binding.etAccount.setOnEditorActionListener { _, actionId, _ ->
|
||||||
|
if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH) {
|
||||||
|
performLookup()
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun performLookup() {
|
private fun performLookup() {
|
||||||
@@ -341,7 +393,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
|||||||
|
|
||||||
// Auto-fill alias with existing alias or name
|
// Auto-fill alias with existing alias or name
|
||||||
if (binding.etAlias.text.isNullOrBlank()) {
|
if (binding.etAlias.text.isNullOrBlank()) {
|
||||||
binding.etAlias.setText(validation.name)
|
binding.etAlias.setText(sh.sar.basedbank.util.bmlapi.BmlDashboardParser.toTitleCase(validation.name))
|
||||||
}
|
}
|
||||||
binding.etCurrency.setText(validation.currency)
|
binding.etCurrency.setText(validation.currency)
|
||||||
|
|
||||||
@@ -517,5 +569,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()
|
||||||
|
)
|
||||||
@@ -5,7 +5,6 @@ import android.graphics.Canvas
|
|||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Base64
|
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
@@ -33,6 +32,8 @@ import sh.sar.basedbank.api.models.BankAccount
|
|||||||
import sh.sar.basedbank.databinding.FragmentBmlQrPayBinding
|
import sh.sar.basedbank.databinding.FragmentBmlQrPayBinding
|
||||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.RecentPick
|
||||||
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
|
|
||||||
class BmlQrPayFragment : Fragment() {
|
class BmlQrPayFragment : Fragment() {
|
||||||
@@ -126,7 +127,6 @@ class BmlQrPayFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun lookupMerchant(qrUrl: String) {
|
private fun lookupMerchant(qrUrl: String) {
|
||||||
val base64Url = Base64.encodeToString(qrUrl.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
|
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
val session = app.anyBmlSession() ?: run {
|
val session = app.anyBmlSession() ?: run {
|
||||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
@@ -139,7 +139,7 @@ class BmlQrPayFragment : Fragment() {
|
|||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val info = withContext(Dispatchers.IO) {
|
val info = withContext(Dispatchers.IO) {
|
||||||
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
|
try { BmlQrPayClient().lookupPayRequest(session, qrUrl) }
|
||||||
catch (_: Exception) { null }
|
catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
if (_binding == null) return@launch
|
if (_binding == null) return@launch
|
||||||
@@ -150,6 +150,19 @@ class BmlQrPayFragment : Fragment() {
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
merchantInfo = info
|
merchantInfo = info
|
||||||
|
if (info.amount == 0.0) {
|
||||||
|
val qrUrl = arguments?.getString(ARG_QR_URL)
|
||||||
|
if (qrUrl != null) {
|
||||||
|
RecentsCache.save(requireContext(), RecentPick(
|
||||||
|
accountNumber = "bmlqr:$qrUrl",
|
||||||
|
displayName = info.merchantName,
|
||||||
|
subtitle = info.merchantAddress.ifBlank { "BML Merchant" },
|
||||||
|
colorHex = "#0066A1",
|
||||||
|
imageHash = null,
|
||||||
|
isProfileImage = false
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
populateMerchant(info)
|
populateMerchant(info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,6 +328,7 @@ class BmlQrPayFragment : Fragment() {
|
|||||||
.setTitle(R.string.bml_qr_payment_success)
|
.setTitle(R.string.bml_qr_payment_success)
|
||||||
.setView(container)
|
.setView(container)
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
(activity as? HomeActivity)?.triggerRefresh()
|
||||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||||
}
|
}
|
||||||
.setCancelable(false)
|
.setCancelable(false)
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.animation.ValueAnimator
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Path
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.view.View
|
||||||
|
import android.view.animation.AccelerateDecelerateInterpolator
|
||||||
|
import android.view.animation.OvershootInterpolator
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Tap card to verify" animation: a bank card swings onto the back of a phone, NFC waves
|
||||||
|
* ripple out from the contact point, then it lifts away and repeats. Has reading / success /
|
||||||
|
* error states so the fragment can reflect what the reader is doing.
|
||||||
|
*/
|
||||||
|
class CardVerifyAnimationView(context: Context) : View(context) {
|
||||||
|
|
||||||
|
enum class State { WAITING, READING, SUCCESS, ERROR }
|
||||||
|
|
||||||
|
private var state = State.WAITING
|
||||||
|
private var stateStart = SystemClock.uptimeMillis()
|
||||||
|
private var label: String = ""
|
||||||
|
|
||||||
|
/** Text shown under the animation while waiting (and restored after an error). */
|
||||||
|
var waitingLabel: String = ""
|
||||||
|
set(value) { field = value; if (state == State.WAITING) label = value; invalidate() }
|
||||||
|
|
||||||
|
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER }
|
||||||
|
private val rect = RectF()
|
||||||
|
private val path = Path()
|
||||||
|
private val easeInOut = AccelerateDecelerateInterpolator()
|
||||||
|
private val overshoot = OvershootInterpolator(2.2f)
|
||||||
|
|
||||||
|
// Drives redraws only; all motion is derived from elapsed time in the current state.
|
||||||
|
private val ticker = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||||
|
duration = 1000
|
||||||
|
repeatCount = ValueAnimator.INFINITE
|
||||||
|
addUpdateListener { invalidate() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val revertToWaiting = Runnable { setState(State.WAITING) }
|
||||||
|
|
||||||
|
fun setState(newState: State, text: String? = null) {
|
||||||
|
removeCallbacks(revertToWaiting)
|
||||||
|
state = newState
|
||||||
|
stateStart = SystemClock.uptimeMillis()
|
||||||
|
label = text ?: if (newState == State.WAITING) waitingLabel else label
|
||||||
|
if (newState == State.ERROR) postDelayed(revertToWaiting, ERROR_HOLD_MS)
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAttachedToWindow() {
|
||||||
|
super.onAttachedToWindow()
|
||||||
|
ticker.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDetachedFromWindow() {
|
||||||
|
ticker.cancel()
|
||||||
|
removeCallbacks(revertToWaiting)
|
||||||
|
super.onDetachedFromWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
val w = width.toFloat(); val h = height.toFloat()
|
||||||
|
if (w <= 0f || h <= 0f) return
|
||||||
|
val dp = resources.displayMetrics.density
|
||||||
|
|
||||||
|
val colorOnSurface = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurface, 0xFF000000.toInt())
|
||||||
|
val colorPrimary = MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimary, 0xFF3F51B5.toInt())
|
||||||
|
val colorOnPrimary = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnPrimary, 0xFFFFFFFF.toInt())
|
||||||
|
val colorSurfaceVariant = MaterialColors.getColor(this, com.google.android.material.R.attr.colorSurfaceVariant, 0xFFDDDDDD.toInt())
|
||||||
|
val colorError = MaterialColors.getColor(this, com.google.android.material.R.attr.colorError, 0xFFB3261E.toInt())
|
||||||
|
|
||||||
|
// Artwork is laid out in a DESIGN_W x DESIGN_H dp box, scaled to fit the available area.
|
||||||
|
val textArea = 36 * dp
|
||||||
|
val scale = min(min(w / (DESIGN_W * dp), (h - textArea) / (DESIGN_H * dp)), 1.3f).coerceAtLeast(0.3f)
|
||||||
|
val u = dp * scale
|
||||||
|
val cx = w / 2f
|
||||||
|
val top = ((h - textArea) - DESIGN_H * u) / 2f
|
||||||
|
|
||||||
|
val elapsed = SystemClock.uptimeMillis() - stateStart
|
||||||
|
|
||||||
|
// ── Card motion: 0 = resting away from phone, 1 = held on phone ─────────
|
||||||
|
val contact = when (state) {
|
||||||
|
State.WAITING -> {
|
||||||
|
val p = (elapsed % CYCLE_MS) / CYCLE_MS.toFloat()
|
||||||
|
when {
|
||||||
|
p < 0.35f -> easeInOut.getInterpolation(p / 0.35f)
|
||||||
|
p < 0.70f -> 1f
|
||||||
|
p < 1.00f -> 1f - easeInOut.getInterpolation((p - 0.70f) / 0.30f)
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> 1f
|
||||||
|
}
|
||||||
|
val shake = if (state == State.ERROR && elapsed < 500)
|
||||||
|
sin(elapsed / 500f * 6 * PI).toFloat() * (1f - elapsed / 500f) * 8 * u else 0f
|
||||||
|
|
||||||
|
// Phone
|
||||||
|
val phoneW = 64 * u; val phoneH = 112 * u
|
||||||
|
val phoneL = cx - phoneW / 2f; val phoneT = top + 44 * u
|
||||||
|
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
|
||||||
|
rect.set(phoneL, phoneT, phoneL + phoneW, phoneT + phoneH)
|
||||||
|
canvas.drawRoundRect(rect, 10 * u, 10 * u, paint)
|
||||||
|
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * u; paint.color = colorOnSurface
|
||||||
|
canvas.drawRoundRect(rect, 10 * u, 10 * u, paint)
|
||||||
|
// Camera bump (we're looking at the back of the phone)
|
||||||
|
paint.style = Paint.Style.FILL; paint.color = colorOnSurface; paint.alpha = 60
|
||||||
|
rect.set(phoneL + 8 * u, phoneT + 8 * u, phoneL + 26 * u, phoneT + 30 * u)
|
||||||
|
canvas.drawRoundRect(rect, 5 * u, 5 * u, paint)
|
||||||
|
paint.alpha = 255
|
||||||
|
|
||||||
|
// Contact point where the NFC antenna sits
|
||||||
|
val touchX = cx; val touchY = phoneT + phoneH * 0.42f
|
||||||
|
|
||||||
|
// ── NFC waves (behind the card) ────────────────────────────────────────
|
||||||
|
val waveStrength = when (state) {
|
||||||
|
State.WAITING -> ((contact - 0.85f) / 0.15f).coerceIn(0f, 1f)
|
||||||
|
State.READING -> 1f
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
|
if (waveStrength > 0f) {
|
||||||
|
val period = if (state == State.READING) 700f else 1100f
|
||||||
|
val base = (elapsed % period.toLong()) / period
|
||||||
|
paint.style = Paint.Style.STROKE; paint.strokeWidth = 3 * u
|
||||||
|
for (i in 0..2) {
|
||||||
|
val p = (base + i / 3f) % 1f
|
||||||
|
val r = 58 * u + p * 46 * u
|
||||||
|
paint.color = colorPrimary
|
||||||
|
paint.alpha = ((1f - p) * 220 * waveStrength).toInt().coerceIn(0, 255)
|
||||||
|
rect.set(touchX - r, touchY - r * 0.72f, touchX + r, touchY + r * 0.72f)
|
||||||
|
canvas.drawOval(rect, paint)
|
||||||
|
}
|
||||||
|
paint.alpha = 255
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Card ───────────────────────────────────────────────────────────────
|
||||||
|
val cardW = 104 * u; val cardH = 66 * u
|
||||||
|
val restX = cx + 58 * u; val restY = top + 48 * u
|
||||||
|
val cardCx = restX + (touchX - restX) * contact + shake
|
||||||
|
val cardCy = restY + (touchY - restY) * contact
|
||||||
|
val rotation = 18f * (1f - contact)
|
||||||
|
val lift = 1f + 0.08f * (1f - contact)
|
||||||
|
|
||||||
|
canvas.save()
|
||||||
|
canvas.translate(cardCx, cardCy)
|
||||||
|
canvas.rotate(rotation)
|
||||||
|
canvas.scale(lift, lift)
|
||||||
|
|
||||||
|
// Same flat look as the phone: surface-variant body, on-surface outline, primary tint for the chip
|
||||||
|
val outline = if (state == State.ERROR) colorError else colorOnSurface
|
||||||
|
rect.set(-cardW / 2, -cardH / 2, cardW / 2, cardH / 2)
|
||||||
|
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
|
||||||
|
canvas.drawRoundRect(rect, 8 * u, 8 * u, paint)
|
||||||
|
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * u; paint.color = outline
|
||||||
|
canvas.drawRoundRect(rect, 8 * u, 8 * u, paint)
|
||||||
|
|
||||||
|
// Chip
|
||||||
|
rect.set(-cardW / 2 + 12 * u, -9 * u, -cardW / 2 + 30 * u, 5 * u)
|
||||||
|
paint.style = Paint.Style.FILL; paint.color = colorPrimary; paint.alpha = 70
|
||||||
|
canvas.drawRoundRect(rect, 3 * u, 3 * u, paint)
|
||||||
|
paint.alpha = 255
|
||||||
|
paint.style = Paint.Style.STROKE; paint.strokeWidth = 1.5f * u; paint.color = outline
|
||||||
|
canvas.drawRoundRect(rect, 3 * u, 3 * u, paint)
|
||||||
|
canvas.drawLine(rect.left, rect.centerY(), rect.right, rect.centerY(), paint)
|
||||||
|
|
||||||
|
// Contactless symbol on the card
|
||||||
|
paint.strokeWidth = 1.8f * u; paint.strokeCap = Paint.Cap.ROUND
|
||||||
|
for (i in 0..2) {
|
||||||
|
val r = (5 + i * 4.5f) * u
|
||||||
|
rect.set(cardW / 2 - 28 * u - r, -14 * u - r, cardW / 2 - 28 * u + r, -14 * u + r)
|
||||||
|
canvas.drawArc(rect, -45f, 90f, false, paint)
|
||||||
|
}
|
||||||
|
// Number + name placeholders
|
||||||
|
paint.strokeWidth = 3f * u; paint.alpha = 150
|
||||||
|
for (g in 0..3) {
|
||||||
|
val x = -cardW / 2 + 12 * u + g * 21 * u
|
||||||
|
canvas.drawLine(x, 16 * u, x + 15 * u, 16 * u, paint)
|
||||||
|
}
|
||||||
|
paint.alpha = 100; paint.strokeWidth = 2.5f * u
|
||||||
|
canvas.drawLine(-cardW / 2 + 12 * u, 26 * u, -cardW / 2 + 48 * u, 26 * u, paint)
|
||||||
|
paint.alpha = 255; paint.strokeCap = Paint.Cap.BUTT
|
||||||
|
canvas.restore()
|
||||||
|
|
||||||
|
// ── Success badge ──────────────────────────────────────────────────────
|
||||||
|
if (state == State.SUCCESS) {
|
||||||
|
val t = (elapsed / 450f).coerceIn(0f, 1f)
|
||||||
|
val badgeR = 22 * u * overshoot.getInterpolation(t)
|
||||||
|
val bx = touchX + cardW / 2 - 6 * u; val by = touchY - cardH / 2 + 4 * u
|
||||||
|
paint.style = Paint.Style.FILL; paint.color = colorPrimary
|
||||||
|
canvas.drawCircle(bx, by, badgeR, paint)
|
||||||
|
val checkT = ((elapsed - 200) / 350f).coerceIn(0f, 1f)
|
||||||
|
if (checkT > 0f) {
|
||||||
|
paint.style = Paint.Style.STROKE; paint.strokeWidth = 3.5f * u
|
||||||
|
paint.strokeCap = Paint.Cap.ROUND; paint.color = colorOnPrimary
|
||||||
|
val x0 = bx - 9 * u; val y0 = by
|
||||||
|
val x1 = bx - 3 * u; val y1 = by + 7 * u
|
||||||
|
val x2 = bx + 10 * u; val y2 = by - 7 * u
|
||||||
|
path.reset(); path.moveTo(x0, y0)
|
||||||
|
if (checkT < 0.4f) {
|
||||||
|
val k = checkT / 0.4f
|
||||||
|
path.lineTo(x0 + (x1 - x0) * k, y0 + (y1 - y0) * k)
|
||||||
|
} else {
|
||||||
|
val k = (checkT - 0.4f) / 0.6f
|
||||||
|
path.lineTo(x1, y1); path.lineTo(x1 + (x2 - x1) * k, y1 + (y2 - y1) * k)
|
||||||
|
}
|
||||||
|
canvas.drawPath(path, paint)
|
||||||
|
paint.strokeCap = Paint.Cap.BUTT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Label ──────────────────────────────────────────────────────────────
|
||||||
|
textPaint.textSize = 16 * dp
|
||||||
|
textPaint.color = if (state == State.ERROR) colorError else colorOnSurface
|
||||||
|
textPaint.alpha = when (state) {
|
||||||
|
State.WAITING -> (170 + 60 * sin(elapsed / 600.0).toFloat()).toInt().coerceIn(0, 255)
|
||||||
|
else -> 230
|
||||||
|
}
|
||||||
|
canvas.drawText(label, cx, h - textArea / 2f + textPaint.textSize / 3f, textPaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val DESIGN_W = 240f
|
||||||
|
private const val DESIGN_H = 170f
|
||||||
|
private const val CYCLE_MS = 2600L
|
||||||
|
private const val ERROR_HOLD_MS = 1800L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,561 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.animation.ValueAnimator
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.*
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.util.TypedValue
|
||||||
|
import android.view.*
|
||||||
|
import android.view.animation.DecelerateInterpolator
|
||||||
|
import android.view.animation.LinearInterpolator
|
||||||
|
import android.animation.AnimatorListenerAdapter
|
||||||
|
import android.animation.Animator
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
|
import androidx.appcompat.widget.Toolbar
|
||||||
|
import androidx.core.graphics.drawable.DrawableCompat
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import kotlin.math.*
|
||||||
|
|
||||||
|
class CircularNavFragment : Fragment() {
|
||||||
|
|
||||||
|
private var wheelView: CircularWheelView? = null
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val colorPrimary = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorPrimary, Color.RED)
|
||||||
|
val colorSurface = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSurface, Color.WHITE)
|
||||||
|
val colorOnSurface = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, Color.DKGRAY)
|
||||||
|
|
||||||
|
fun dp(v: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, ctx.resources.displayMetrics)
|
||||||
|
|
||||||
|
val root = android.widget.LinearLayout(ctx).apply {
|
||||||
|
orientation = android.widget.LinearLayout.VERTICAL
|
||||||
|
setBackgroundColor(colorSurface)
|
||||||
|
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wheel area (weight 1, fills remaining space)
|
||||||
|
val wheelContainer = FrameLayout(ctx).apply {
|
||||||
|
layoutParams = android.widget.LinearLayout.LayoutParams(
|
||||||
|
android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val prefs = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
wheelView = CircularWheelView(ctx).apply {
|
||||||
|
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
|
||||||
|
wheelAngle = prefs.getFloat("circular_wheel_angle", 0f)
|
||||||
|
val savedSlots = NavCustomization.getCircularSlots(prefs).map { id ->
|
||||||
|
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == id }!!
|
||||||
|
CircularWheelView.WheelItem(def.id, def.iconRes, ctx.getString(def.titleRes))
|
||||||
|
}
|
||||||
|
items = listOf(
|
||||||
|
savedSlots[3], // 4 o'clock (strip slot 3)
|
||||||
|
CircularWheelView.WheelItem(R.id.nav_dashboard, R.drawable.ic_nav_dashboard, ctx.getString(R.string.nav_dashboard)), // 6 o'clock
|
||||||
|
CircularWheelView.WheelItem(R.id.nav_more, R.drawable.ic_nav_more, ctx.getString(R.string.nav_more)), // 8 o'clock
|
||||||
|
savedSlots[0], // 10 o'clock (strip slot 0 — first in strip)
|
||||||
|
savedSlots[1], // 12 o'clock (strip slot 1)
|
||||||
|
savedSlots[2], // 2 o'clock (strip slot 2)
|
||||||
|
)
|
||||||
|
accentColor = colorPrimary
|
||||||
|
surfaceColor = colorSurface
|
||||||
|
labelColor = colorOnSurface
|
||||||
|
onItemClick = { navId -> (activity as? HomeActivity)?.navigateTo(navId) }
|
||||||
|
onCenterClick = { /* unused: tap on unlocked center locks the wheel */ }
|
||||||
|
onWheelCenterLockedTap = { (activity as? HomeActivity)?.notifyWheelLockTap() }
|
||||||
|
}
|
||||||
|
wheelContainer.addView(wheelView)
|
||||||
|
|
||||||
|
// App icon centered at the bottom
|
||||||
|
val iconSz = dp(48f).toInt()
|
||||||
|
val footerIcon = android.widget.ImageView(ctx).apply {
|
||||||
|
setImageDrawable(ctx.packageManager.getApplicationIcon(ctx.packageName))
|
||||||
|
layoutParams = android.widget.LinearLayout.LayoutParams(iconSz, iconSz).also {
|
||||||
|
it.gravity = Gravity.CENTER_HORIZONTAL
|
||||||
|
it.topMargin = dp(12f).toInt()
|
||||||
|
it.bottomMargin = dp(16f).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
root.addView(wheelContainer)
|
||||||
|
root.addView(footerIcon)
|
||||||
|
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets ->
|
||||||
|
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
(footerIcon.layoutParams as android.widget.LinearLayout.LayoutParams).bottomMargin = dp(16f).toInt() + bars.bottom
|
||||||
|
footerIcon.requestLayout()
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().invalidateOptionsMenu()
|
||||||
|
val ctx = requireContext()
|
||||||
|
val toolbar = requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar)
|
||||||
|
requireActivity().title = ""
|
||||||
|
|
||||||
|
val textColor = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.DKGRAY)
|
||||||
|
|
||||||
|
val container = android.widget.TextView(ctx).apply {
|
||||||
|
text = getString(R.string.app_name)
|
||||||
|
setTextColor(textColor)
|
||||||
|
textSize = 20f
|
||||||
|
typeface = Typeface.DEFAULT_BOLD
|
||||||
|
tag = "wheel_title"
|
||||||
|
}
|
||||||
|
|
||||||
|
toolbar.addView(container, Toolbar.LayoutParams(
|
||||||
|
Toolbar.LayoutParams.WRAP_CONTENT,
|
||||||
|
Toolbar.LayoutParams.WRAP_CONTENT,
|
||||||
|
Gravity.CENTER
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
wheelView?.let { wv ->
|
||||||
|
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
.edit().putFloat("circular_wheel_angle", wv.wheelAngle).apply()
|
||||||
|
}
|
||||||
|
val toolbar = requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar)
|
||||||
|
toolbar.findViewWithTag<android.view.View>("wheel_title")?.let { toolbar.removeView(it) }
|
||||||
|
requireActivity().invalidateOptionsMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unlockWheelLock() {
|
||||||
|
wheelView?.unlockWheel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Custom wheel view
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class CircularWheelView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null
|
||||||
|
) : View(context, attrs) {
|
||||||
|
|
||||||
|
data class WheelItem(
|
||||||
|
val navId: Int,
|
||||||
|
@DrawableRes val iconRes: Int,
|
||||||
|
val label: String
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---- public properties ------------------------------------------------
|
||||||
|
|
||||||
|
var items: List<WheelItem> = emptyList()
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
iconBitmaps = arrayOfNulls(value.size)
|
||||||
|
if (cx > 0f) reloadBitmaps()
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
var accentColor: Int = Color.RED
|
||||||
|
set(value) { field = value; if (cx > 0f) reloadBitmaps(); invalidate() }
|
||||||
|
|
||||||
|
var surfaceColor: Int = Color.WHITE
|
||||||
|
set(value) { field = value; invalidate() }
|
||||||
|
|
||||||
|
var labelColor: Int = Color.DKGRAY
|
||||||
|
set(value) { field = value; invalidate() }
|
||||||
|
|
||||||
|
var isWheelLocked = false
|
||||||
|
set(value) { field = value; invalidate() }
|
||||||
|
|
||||||
|
var onItemClick: ((Int) -> Unit)? = null
|
||||||
|
var onCenterClick: (() -> Unit)? = null
|
||||||
|
var onWheelCenterLockedTap: (() -> Unit)? = null
|
||||||
|
|
||||||
|
// ---- geometry ---------------------------------------------------------
|
||||||
|
|
||||||
|
private var cx = 0f
|
||||||
|
private var cy = 0f
|
||||||
|
private var outerRadius = 0f
|
||||||
|
private var innerRadius = 0f
|
||||||
|
|
||||||
|
// ---- paint ------------------------------------------------------------
|
||||||
|
|
||||||
|
private val discPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val accentRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||||
|
private val accentRing2Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||||
|
private val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||||
|
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
textAlign = Paint.Align.CENTER
|
||||||
|
typeface = Typeface.DEFAULT_BOLD
|
||||||
|
}
|
||||||
|
private val centerFillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val centerRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||||
|
private val bitmapPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
|
||||||
|
private var iconBitmaps: Array<Bitmap?> = emptyArray()
|
||||||
|
private var centerBitmap: Bitmap? = null
|
||||||
|
private var centerUnlockedBitmap: Bitmap? = null
|
||||||
|
private val grayFilter = ColorMatrixColorFilter(ColorMatrix().apply { setSaturation(0f) })
|
||||||
|
private var lockShakeAngle = 0f
|
||||||
|
private var shakeAnimator: ValueAnimator? = null
|
||||||
|
|
||||||
|
// ---- touch & fling ----------------------------------------------------
|
||||||
|
|
||||||
|
var wheelAngle = 0f
|
||||||
|
private var isDragging = false
|
||||||
|
private var snapAnimator: ValueAnimator? = null
|
||||||
|
|
||||||
|
// Incremental drag state
|
||||||
|
private var prevTouchAngle = 0f
|
||||||
|
private var touchDownX = 0f
|
||||||
|
private var touchDownY = 0f
|
||||||
|
|
||||||
|
// Velocity buffer: stores (cumulative wheel angle, timestamp) for last N samples
|
||||||
|
private val VEL_SAMPLES = 6
|
||||||
|
private val velAngles = FloatArray(VEL_SAMPLES)
|
||||||
|
private val velTimes = LongArray(VEL_SAMPLES)
|
||||||
|
private var velIdx = 0
|
||||||
|
private var velCount = 0
|
||||||
|
|
||||||
|
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop.toFloat()
|
||||||
|
|
||||||
|
// ---- helpers ----------------------------------------------------------
|
||||||
|
|
||||||
|
private fun dp(v: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, resources.displayMetrics)
|
||||||
|
|
||||||
|
// ---- sizing -----------------------------------------------------------
|
||||||
|
|
||||||
|
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||||
|
cx = w / 2f
|
||||||
|
cy = h / 2f
|
||||||
|
val size = minOf(w, h)
|
||||||
|
outerRadius = size / 2f * 0.80f
|
||||||
|
innerRadius = outerRadius * 0.26f
|
||||||
|
|
||||||
|
textPaint.textSize = size * 0.034f
|
||||||
|
dividerPaint.strokeWidth = dp(0.7f)
|
||||||
|
accentRingPaint.strokeWidth = dp(5f)
|
||||||
|
accentRing2Paint.strokeWidth = dp(3f)
|
||||||
|
centerRingPaint.strokeWidth = dp(4f)
|
||||||
|
|
||||||
|
reloadBitmaps()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reloadBitmaps() {
|
||||||
|
val iconPx = (outerRadius * 0.24f).toInt().coerceAtLeast(1)
|
||||||
|
items.forEachIndexed { i, item ->
|
||||||
|
iconBitmaps[i] = tintedBitmap(item.iconRes, accentColor, iconPx)
|
||||||
|
}
|
||||||
|
val centerPx = (innerRadius * 0.64f).toInt().coerceAtLeast(1)
|
||||||
|
centerBitmap = tintedBitmap(R.drawable.ic_lock, accentColor, centerPx)
|
||||||
|
centerUnlockedBitmap = tintedBitmap(R.drawable.ic_lock_open, accentColor, centerPx)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tintedBitmap(@DrawableRes resId: Int, tint: Int, sizePx: Int): Bitmap? {
|
||||||
|
if (sizePx <= 0) return null
|
||||||
|
return try {
|
||||||
|
val d = AppCompatResources.getDrawable(context, resId)!!.mutate()
|
||||||
|
DrawableCompat.setTint(DrawableCompat.wrap(d), tint)
|
||||||
|
val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||||
|
Canvas(bmp).also { d.setBounds(0, 0, sizePx, sizePx); d.draw(it) }
|
||||||
|
bmp
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- drawing ----------------------------------------------------------
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
if (items.isEmpty()) return
|
||||||
|
|
||||||
|
val segCount = items.size
|
||||||
|
val segDeg = 360f / segCount
|
||||||
|
|
||||||
|
// Wheel disc
|
||||||
|
discPaint.color = surfaceColor
|
||||||
|
canvas.drawCircle(cx, cy, outerRadius, discPaint)
|
||||||
|
|
||||||
|
// Accent ring around wheel
|
||||||
|
accentRingPaint.color = accentColor
|
||||||
|
canvas.drawCircle(cx, cy, outerRadius + dp(20f), accentRingPaint)
|
||||||
|
|
||||||
|
// Rotatable layer
|
||||||
|
canvas.save()
|
||||||
|
canvas.rotate(wheelAngle, cx, cy)
|
||||||
|
|
||||||
|
// Divider lines between segments
|
||||||
|
dividerPaint.color = (labelColor and 0x00FFFFFF) or (100 shl 24)
|
||||||
|
for (i in 0 until segCount) {
|
||||||
|
val rad = Math.toRadians((i * segDeg).toDouble())
|
||||||
|
val cos = cos(rad).toFloat()
|
||||||
|
val sin = sin(rad).toFloat()
|
||||||
|
canvas.drawLine(
|
||||||
|
cx + cos * (innerRadius + dp(6f)), cy + sin * (innerRadius + dp(6f)),
|
||||||
|
cx + cos * (outerRadius - dp(12f)), cy + sin * (outerRadius - dp(12f)),
|
||||||
|
dividerPaint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Segment content
|
||||||
|
for (i in 0 until segCount) {
|
||||||
|
val midDeg = i * segDeg + segDeg / 2f
|
||||||
|
drawSegment(canvas, i, midDeg)
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.restore()
|
||||||
|
|
||||||
|
// Center button — always upright
|
||||||
|
centerRingPaint.color = accentColor
|
||||||
|
canvas.drawCircle(cx, cy, innerRadius + dp(3f), centerRingPaint)
|
||||||
|
centerFillPaint.color = surfaceColor
|
||||||
|
canvas.drawCircle(cx, cy, innerRadius, centerFillPaint)
|
||||||
|
val activeCenterBitmap = if (isWheelLocked) centerBitmap else centerUnlockedBitmap
|
||||||
|
activeCenterBitmap?.let {
|
||||||
|
canvas.save()
|
||||||
|
// Shake pivots around the bottom-centre of the icon
|
||||||
|
if (lockShakeAngle != 0f) canvas.rotate(lockShakeAngle, cx, cy + it.height / 2f)
|
||||||
|
canvas.drawBitmap(it, cx - it.width / 2f, cy - it.height / 2f, bitmapPaint)
|
||||||
|
canvas.restore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun drawSegment(canvas: Canvas, index: Int, midDeg: Float) {
|
||||||
|
val rad = Math.toRadians(midDeg.toDouble())
|
||||||
|
val cosA = cos(rad).toFloat()
|
||||||
|
val sinA = sin(rad).toFloat()
|
||||||
|
|
||||||
|
val iconX = cx + cosA * (outerRadius * 0.63f)
|
||||||
|
val iconY = cy + sinA * (outerRadius * 0.63f)
|
||||||
|
|
||||||
|
// Icon — radially oriented; top items are naturally upside-down
|
||||||
|
iconBitmaps.getOrNull(index)?.let { bmp ->
|
||||||
|
canvas.save()
|
||||||
|
canvas.translate(iconX, iconY)
|
||||||
|
canvas.rotate(midDeg - 90f)
|
||||||
|
if (isWheelLocked) {
|
||||||
|
bitmapPaint.colorFilter = grayFilter
|
||||||
|
bitmapPaint.alpha = 100
|
||||||
|
}
|
||||||
|
canvas.drawBitmap(bmp, -bmp.width / 2f, -bmp.height / 2f, bitmapPaint)
|
||||||
|
if (isWheelLocked) {
|
||||||
|
bitmapPaint.colorFilter = null
|
||||||
|
bitmapPaint.alpha = 255
|
||||||
|
}
|
||||||
|
canvas.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Curved label — same radial orientation as icons.
|
||||||
|
// In the local rotated frame the wheel arc is a circle of radius `labelRadius`
|
||||||
|
// with its centre directly "above" at (0, -labelRadius). A CCW arc through (0,0)
|
||||||
|
// flows rightward at that point, matching the natural reading direction at 6 o'clock.
|
||||||
|
val labelRadius = outerRadius * 0.84f
|
||||||
|
val textX = cx + cosA * labelRadius
|
||||||
|
val textY = cy + sinA * labelRadius
|
||||||
|
val label = items[index].label
|
||||||
|
textPaint.color = if (isWheelLocked) (labelColor and 0x00FFFFFF) or (80 shl 24) else labelColor
|
||||||
|
textPaint.textAlign = Paint.Align.LEFT
|
||||||
|
val halfAngleDeg = Math.toDegrees((textPaint.measureText(label) / 2.0) / labelRadius).toFloat()
|
||||||
|
val localArcRect = RectF(-labelRadius, -2f * labelRadius, labelRadius, 0f)
|
||||||
|
val arcPath = Path().apply { addArc(localArcRect, 90f + halfAngleDeg, -(halfAngleDeg * 2f)) }
|
||||||
|
canvas.save()
|
||||||
|
canvas.translate(textX, textY)
|
||||||
|
canvas.rotate(midDeg - 90f)
|
||||||
|
canvas.drawTextOnPath(label, arcPath, 0f, textPaint.textSize * 0.36f, textPaint)
|
||||||
|
canvas.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- touch ------------------------------------------------------------
|
||||||
|
|
||||||
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||||
|
when (event.actionMasked) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
snapAnimator?.cancel()
|
||||||
|
prevTouchAngle = angleAt(event.x, event.y)
|
||||||
|
touchDownX = event.x
|
||||||
|
touchDownY = event.y
|
||||||
|
isDragging = false
|
||||||
|
velIdx = 0
|
||||||
|
velCount = 0
|
||||||
|
recordVelSample()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
val curr = angleAt(event.x, event.y)
|
||||||
|
// Incremental delta — normalised to [-180, 180] to survive the ±180° wrap
|
||||||
|
var dA = curr - prevTouchAngle
|
||||||
|
if (dA > 180f) dA -= 360f
|
||||||
|
if (dA < -180f) dA += 360f
|
||||||
|
prevTouchAngle = curr
|
||||||
|
|
||||||
|
val moved = hypot(event.x - touchDownX, event.y - touchDownY)
|
||||||
|
if (moved > touchSlop || isDragging) {
|
||||||
|
isDragging = true
|
||||||
|
wheelAngle += dA
|
||||||
|
recordVelSample()
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_UP -> {
|
||||||
|
if (!isDragging) {
|
||||||
|
val dist = hypot(event.x - cx, event.y - cy)
|
||||||
|
when {
|
||||||
|
dist <= innerRadius -> {
|
||||||
|
if (isWheelLocked) {
|
||||||
|
onWheelCenterLockedTap?.invoke()
|
||||||
|
} else {
|
||||||
|
isWheelLocked = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dist <= outerRadius -> {
|
||||||
|
if (isWheelLocked) {
|
||||||
|
val idx = segmentAt(event.x, event.y)
|
||||||
|
if (idx in items.indices) animateToSixOClock(idx) {
|
||||||
|
vibrateDevice()
|
||||||
|
shakeLock()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val idx = segmentAt(event.x, event.y)
|
||||||
|
if (idx in items.indices) animateToSixOClock(idx) { onItemClick?.invoke(items[idx].navId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val vel = computeVelocity()
|
||||||
|
if (abs(vel) > 0.05f) fling(vel) else snapToNearest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_CANCEL -> {
|
||||||
|
if (isDragging) {
|
||||||
|
val vel = computeVelocity()
|
||||||
|
if (abs(vel) > 0.05f) fling(vel) else snapToNearest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recordVelSample() {
|
||||||
|
val slot = velIdx % VEL_SAMPLES
|
||||||
|
velAngles[slot] = wheelAngle
|
||||||
|
velTimes[slot] = System.currentTimeMillis()
|
||||||
|
velIdx++
|
||||||
|
if (velCount < VEL_SAMPLES) velCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns angular velocity in degrees per millisecond, using the oldest available sample. */
|
||||||
|
private fun computeVelocity(): Float {
|
||||||
|
if (velCount < 2) return 0f
|
||||||
|
val newest = (velIdx - 1 + VEL_SAMPLES) % VEL_SAMPLES
|
||||||
|
// Use the sample that is ~100 ms old for a stable estimate
|
||||||
|
val oldest = (velIdx - velCount + VEL_SAMPLES) % VEL_SAMPLES
|
||||||
|
val dt = velTimes[newest] - velTimes[oldest]
|
||||||
|
if (dt <= 0L) return 0f
|
||||||
|
return (velAngles[newest] - velAngles[oldest]) / dt
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kick off a physics-based fling: uniform deceleration from [initialVel] to zero,
|
||||||
|
* then snap to the nearest segment.
|
||||||
|
* Formula: total_rotation = v0² / (2 * DECEL), duration = v0 / DECEL
|
||||||
|
* With DecelerateInterpolator(1) the initial animation velocity matches v0.
|
||||||
|
*/
|
||||||
|
private fun fling(initialVel: Float) {
|
||||||
|
val DECEL = 0.0008f // deg / ms² — tune for feel
|
||||||
|
val duration = (abs(initialVel) / DECEL).toLong().coerceIn(200, 3500)
|
||||||
|
val sign = if (initialVel >= 0f) 1f else -1f
|
||||||
|
val totalRot = sign * initialVel * initialVel / (2f * DECEL)
|
||||||
|
val startAngle = wheelAngle
|
||||||
|
val endAngle = startAngle + totalRot
|
||||||
|
|
||||||
|
snapAnimator = ValueAnimator.ofFloat(startAngle, endAngle).apply {
|
||||||
|
this.duration = duration
|
||||||
|
interpolator = DecelerateInterpolator() // matches v0 at t=0
|
||||||
|
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
|
||||||
|
addListener(object : AnimatorListenerAdapter() {
|
||||||
|
override fun onAnimationEnd(a: Animator) { snapToNearest() }
|
||||||
|
})
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun angleAt(x: Float, y: Float): Float =
|
||||||
|
Math.toDegrees(atan2((y - cy).toDouble(), (x - cx).toDouble())).toFloat()
|
||||||
|
|
||||||
|
private fun segmentAt(x: Float, y: Float): Int {
|
||||||
|
var a = angleAt(x, y) - wheelAngle
|
||||||
|
a = (a % 360f + 360f) % 360f
|
||||||
|
return (a / (360f / items.size)).toInt() % items.size
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun animateToSixOClock(index: Int, onDone: () -> Unit) {
|
||||||
|
val segDeg = 360f / items.size.coerceAtLeast(1)
|
||||||
|
val midDeg = index * segDeg + segDeg / 2f
|
||||||
|
// delta needed so this segment's midpoint lands at 90° (6 o'clock in math coords)
|
||||||
|
var delta = (90f - midDeg) - wheelAngle
|
||||||
|
// normalise to shortest path [-180, 180]
|
||||||
|
delta = ((delta % 360f) + 360f) % 360f
|
||||||
|
if (delta > 180f) delta -= 360f
|
||||||
|
val endAngle = wheelAngle + delta
|
||||||
|
|
||||||
|
snapAnimator?.cancel()
|
||||||
|
snapAnimator = ValueAnimator.ofFloat(wheelAngle, endAngle).apply {
|
||||||
|
duration = 350
|
||||||
|
interpolator = DecelerateInterpolator()
|
||||||
|
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
|
||||||
|
addListener(object : AnimatorListenerAdapter() {
|
||||||
|
private var cancelled = false
|
||||||
|
override fun onAnimationCancel(a: Animator) { cancelled = true }
|
||||||
|
override fun onAnimationEnd(a: Animator) { if (!cancelled) onDone() }
|
||||||
|
})
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun snapToNearest() {
|
||||||
|
val segDeg = 360f / items.size.coerceAtLeast(1)
|
||||||
|
val target = (wheelAngle / segDeg).roundToInt() * segDeg
|
||||||
|
snapAnimator = ValueAnimator.ofFloat(wheelAngle, target).apply {
|
||||||
|
duration = 300
|
||||||
|
interpolator = DecelerateInterpolator()
|
||||||
|
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun vibrateDevice() {
|
||||||
|
val v = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||||
|
v.vibrate(VibrationEffect.createOneShot(60, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun shakeLock() {
|
||||||
|
shakeAnimator?.cancel()
|
||||||
|
shakeAnimator = ValueAnimator.ofFloat(0f, -18f, 18f, -12f, 12f, -6f, 6f, 0f).apply {
|
||||||
|
duration = 500
|
||||||
|
interpolator = LinearInterpolator()
|
||||||
|
addUpdateListener { lockShakeAngle = it.animatedValue as Float; invalidate() }
|
||||||
|
addListener(object : AnimatorListenerAdapter() {
|
||||||
|
override fun onAnimationEnd(a: Animator) { lockShakeAngle = 0f; invalidate() }
|
||||||
|
})
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unlockWheel() {
|
||||||
|
isWheelLocked = false
|
||||||
|
lockShakeAngle = 0f
|
||||||
|
shakeAnimator?.cancel()
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.os.bundleOf
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.SheetContactDetailsBinding
|
||||||
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
|
import sh.sar.basedbank.util.ContactListParser
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.TransferNetwork
|
||||||
|
|
||||||
|
/** Contact details drawer shown when a row in [ContactsFragment] is tapped. */
|
||||||
|
class ContactDetailsSheetFragment : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
|
private var _binding: SheetContactDetailsBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
|
private val contactsFragment get() = parentFragment as? ContactsFragment
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = SheetContactDetailsBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val args = requireArguments()
|
||||||
|
val contactId = args.getString(ARG_CONTACT_ID)
|
||||||
|
val profileId = args.getString(ARG_PROFILE_ID)
|
||||||
|
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
|
||||||
|
val contact = contacts.firstOrNull { it.benefNo == contactId && it.profileId == profileId }
|
||||||
|
?.let { ContactListParser.from(it) }
|
||||||
|
if (contact == null) dismissAllowingStateLoss() else bind(contact)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bind(contact: ContactDisplay) {
|
||||||
|
val sizePx = resources.getDimensionPixelSize(android.R.dimen.app_icon_size).coerceAtLeast(96) * 2
|
||||||
|
val photo = contact.imageHash?.let { ContactImageCache.load(requireContext(), it) }
|
||||||
|
binding.ivPhoto.setImageBitmap(photo ?: contactInitialsBitmap(contact.name, contact.bankColor, sizePx))
|
||||||
|
|
||||||
|
binding.tvName.text = contact.name
|
||||||
|
|
||||||
|
binding.actionTransfer.visibility = if (contact.canTransfer) View.VISIBLE else View.GONE
|
||||||
|
// QR stays visible for every contact; greyed out where PayMV QR isn't supported yet
|
||||||
|
val canQr = canShowPayMvQr(contact)
|
||||||
|
binding.btnQr.isEnabled = canQr
|
||||||
|
binding.tvQrLabel.alpha = if (canQr) 1f else 0.38f
|
||||||
|
binding.actionEdit.visibility = if (contact.canEdit) View.VISIBLE else View.GONE
|
||||||
|
binding.actionDelete.visibility = if (contact.canDelete) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
binding.btnTransfer.setOnClickListener {
|
||||||
|
val parent = contactsFragment
|
||||||
|
dismiss()
|
||||||
|
parent?.openTransfer(contact)
|
||||||
|
}
|
||||||
|
binding.btnQr.setOnClickListener {
|
||||||
|
val activity = requireActivity() as HomeActivity
|
||||||
|
val bank = contactBank(contact) ?: return@setOnClickListener
|
||||||
|
val qrName = contact.realName.ifBlank { contact.name }
|
||||||
|
dismiss()
|
||||||
|
activity.showWithBackStack(PayMvQrFragment.forContact(contact.accountNumber, qrName, bank))
|
||||||
|
}
|
||||||
|
binding.btnEdit.setOnClickListener {
|
||||||
|
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
binding.btnDelete.setOnClickListener {
|
||||||
|
val parent = contactsFragment
|
||||||
|
dismiss()
|
||||||
|
parent?.confirmDelete(contact)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.tvAccount.text = contact.accountNumber
|
||||||
|
val copy = View.OnClickListener { copyAccount(contact.accountNumber) }
|
||||||
|
binding.rowAccount.setOnClickListener(copy)
|
||||||
|
binding.btnCopyAccount.setOnClickListener(copy)
|
||||||
|
binding.btnShareAccount.setOnClickListener {
|
||||||
|
shareAccount(contact.realName.ifBlank { contact.name }, contact.accountNumber)
|
||||||
|
}
|
||||||
|
|
||||||
|
val showRealName = contact.realName.isNotBlank() && contact.realName != contact.name
|
||||||
|
binding.tvRealName.text = contact.realName
|
||||||
|
binding.rowRealName.visibility = if (showRealName) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
binding.tvCurrency.text = contact.currency ?: ""
|
||||||
|
binding.rowCurrency.visibility = if (contact.currency != null) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
binding.tvBank.text = contact.bankName ?: ""
|
||||||
|
binding.rowBank.visibility = if (contact.bankName != null) View.VISIBLE else View.GONE
|
||||||
|
val bankLogo = bankLogoRes(contact)
|
||||||
|
if (bankLogo != null) {
|
||||||
|
binding.ivBankIcon.setImageResource(bankLogo)
|
||||||
|
binding.ivBankIcon.imageTintList = null
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.sourceSection.visibility = View.GONE
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val appContext = requireContext().applicationContext
|
||||||
|
val source = withContext(Dispatchers.IO) { sourceProfile(appContext, contact) }
|
||||||
|
val b = _binding ?: return@launch
|
||||||
|
if (source == null) return@launch
|
||||||
|
b.ivSourceIcon.setImageResource(source.logoRes)
|
||||||
|
b.tvSource.text = source.name
|
||||||
|
b.tvSourceBank.setText(source.bankNameRes)
|
||||||
|
b.sourceSection.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Which bank the contact's account is at ("BML" / "MIB"); null when we can't tell. */
|
||||||
|
private fun contactBank(contact: ContactDisplay): String? = when {
|
||||||
|
contact.network == TransferNetwork.MIB -> "MIB"
|
||||||
|
contact.accountNumber.matches(Regex("^7\\d{12}$")) -> "BML"
|
||||||
|
contact.accountNumber.matches(Regex("^9\\d{16}$")) -> "MIB"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Logo of the contact's bank; null when we have no logo for it (only MIB and BML for now). */
|
||||||
|
private fun bankLogoRes(contact: ContactDisplay): Int? = when (contactBank(contact)) {
|
||||||
|
"BML" -> R.drawable.bml_logo_vector
|
||||||
|
"MIB" -> R.drawable.mib_logo
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PayMV QR is only offered for contacts whose account is at BML for now.
|
||||||
|
* TODO: enable for MIB and other banks once their PayMV QR payloads are verified —
|
||||||
|
* PayMvQrFragment.forContact already takes the bank, so this check is the only gate.
|
||||||
|
*/
|
||||||
|
private fun canShowPayMvQr(contact: ContactDisplay): Boolean =
|
||||||
|
contactBank(contact) == "BML" &&
|
||||||
|
contact.currency?.contains("USD", ignoreCase = true) != true // PayMV QR is MVR only
|
||||||
|
|
||||||
|
private data class SourceProfile(val logoRes: Int, val bankNameRes: Int, val name: String)
|
||||||
|
|
||||||
|
/** Which of the user's own bank profiles this contact is saved under. */
|
||||||
|
private fun sourceProfile(context: Context, contact: ContactDisplay): SourceProfile? {
|
||||||
|
val store = CredentialStore(context)
|
||||||
|
return when (contact.network) {
|
||||||
|
TransferNetwork.BML -> {
|
||||||
|
// BML contacts carry either the loginId or a BML profileId
|
||||||
|
val loginId = store.getBmlLoginIds().firstOrNull { loginId ->
|
||||||
|
loginId == contact.profileId ||
|
||||||
|
store.loadBmlProfiles(loginId).any { it.profileId == contact.profileId }
|
||||||
|
} ?: return null
|
||||||
|
val profile = store.loadBmlProfiles(loginId).firstOrNull { it.profileId == contact.profileId }
|
||||||
|
val fullName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
|
||||||
|
val name = (if (profile != null && profile.profileType == "business") profile.name else fullName ?: profile?.name)
|
||||||
|
?: return null
|
||||||
|
SourceProfile(R.drawable.bml_logo_vector, R.string.bml_name, name)
|
||||||
|
}
|
||||||
|
TransferNetwork.FAHIPAY -> {
|
||||||
|
val name = store.getFahipayLoginIds().firstNotNullOfOrNull { loginId ->
|
||||||
|
store.loadFahipayUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
|
||||||
|
} ?: return null
|
||||||
|
SourceProfile(R.drawable.fahipay_logo, R.string.fahipay_name, name)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val profile = store.getMibLoginIds().firstNotNullOfOrNull { loginId ->
|
||||||
|
store.loadMibProfiles(loginId).firstOrNull { it.profileId == contact.profileId }
|
||||||
|
} ?: return null
|
||||||
|
SourceProfile(R.drawable.mib_logo, R.string.mib_name, profile.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shareAccount(accountName: String, account: String) {
|
||||||
|
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||||
|
type = "text/plain"
|
||||||
|
putExtra(Intent.EXTRA_TEXT, "$accountName\n$account")
|
||||||
|
}
|
||||||
|
startActivity(Intent.createChooser(intent, getString(R.string.contact_share_account)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyAccount(account: String) {
|
||||||
|
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("account", account))
|
||||||
|
Toast.makeText(requireContext(), R.string.contact_account_copied, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_CONTACT_ID = "contact_id"
|
||||||
|
private const val ARG_PROFILE_ID = "profile_id"
|
||||||
|
|
||||||
|
fun newInstance(contact: ContactDisplay) = ContactDetailsSheetFragment().apply {
|
||||||
|
arguments = bundleOf(ARG_CONTACT_ID to contact.id, ARG_PROFILE_ID to contact.profileId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import sh.sar.basedbank.databinding.ItemPickerSectionHeaderBinding
|
|||||||
|
|
||||||
class ContactPickerAdapter(
|
class ContactPickerAdapter(
|
||||||
private val imageCache: MutableMap<String, Bitmap>,
|
private val imageCache: MutableMap<String, Bitmap>,
|
||||||
private val onItemClick: (accountNumber: String, label: String) -> Unit,
|
private val onItemClick: (accountNumber: String, label: String, categoryId: String?) -> Unit,
|
||||||
private val onSameAsFrom: () -> Unit,
|
private val onSameAsFrom: () -> Unit,
|
||||||
private val onImageNeeded: ((hash: String) -> Unit)? = null,
|
private val onImageNeeded: ((hash: String) -> Unit)? = null,
|
||||||
private val onItemLongClick: ((accountNumber: String, anchor: android.view.View) -> Boolean)? = null
|
private val onItemLongClick: ((accountNumber: String, anchor: android.view.View) -> Boolean)? = null
|
||||||
@@ -33,7 +33,9 @@ class ContactPickerAdapter(
|
|||||||
val imageHash: String? = null,
|
val imageHash: String? = null,
|
||||||
val inactiveReason: String? = null,
|
val inactiveReason: String? = null,
|
||||||
val balance: String? = null,
|
val balance: String? = null,
|
||||||
val bankLogoRes: Int? = null
|
val bankLogoRes: Int? = null,
|
||||||
|
/** The saved contact's category, for rows that are contacts; null otherwise. */
|
||||||
|
val categoryId: String? = null
|
||||||
) : PickerItem()
|
) : PickerItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +127,7 @@ class ContactPickerAdapter(
|
|||||||
Toast.makeText(binding.root.context, item.inactiveReason, Toast.LENGTH_SHORT).show()
|
Toast.makeText(binding.root.context, item.inactiveReason, Toast.LENGTH_SHORT).show()
|
||||||
item.isSameAsFrom ->
|
item.isSameAsFrom ->
|
||||||
Toast.makeText(binding.root.context, R.string.transfer_same_account, Toast.LENGTH_SHORT).show()
|
Toast.makeText(binding.root.context, R.string.transfer_same_account, Toast.LENGTH_SHORT).show()
|
||||||
else -> onItemClick(item.accountNumber, item.displayName)
|
else -> onItemClick(item.accountNumber, item.displayName, item.categoryId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.root.setOnLongClickListener { view ->
|
binding.root.setOnLongClickListener { view ->
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
val pageAdapters: List<ContactPickerAdapter> = pages.mapIndexed { i, page ->
|
val pageAdapters: List<ContactPickerAdapter> = pages.mapIndexed { i, page ->
|
||||||
ContactPickerAdapter(
|
ContactPickerAdapter(
|
||||||
imageCache = sharedImageCache,
|
imageCache = sharedImageCache,
|
||||||
onItemClick = { accountNumber, label -> handlePickerSelection(accountNumber, label) },
|
onItemClick = { accountNumber, label, categoryId -> handlePickerSelection(accountNumber, label, categoryId) },
|
||||||
onSameAsFrom = {},
|
onSameAsFrom = {},
|
||||||
onImageNeeded = { hash -> fetchImage(hash) },
|
onImageNeeded = { hash -> fetchImage(hash) },
|
||||||
onItemLongClick = { accountNumber, anchor ->
|
onItemLongClick = { accountNumber, anchor ->
|
||||||
@@ -151,7 +151,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||||
|
|
||||||
(activity as? HomeActivity)?.triggerRefresh()
|
(activity as? HomeActivity)?.loadAllContacts()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun attachMediator(pages: List<TabDef>) {
|
private fun attachMediator(pages: List<TabDef>) {
|
||||||
@@ -161,13 +161,22 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
}.also { it.attach() }
|
}.also { it.attach() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handlePickerSelection(accountNumber: String, label: String) {
|
private fun handlePickerSelection(accountNumber: String, label: String, categoryId: String?) {
|
||||||
val contacts = viewModel.contacts.value ?: emptyList()
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
val accounts = viewModel.accounts.value ?: emptyList()
|
val accounts = viewModel.accounts.value ?: emptyList()
|
||||||
val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
|
val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
|
||||||
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
|
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
|
||||||
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
|
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
|
||||||
|
categoryId?.let { bundle.putString(KEY_CATEGORY, it) }
|
||||||
when {
|
when {
|
||||||
|
accountNumber.startsWith("bmlqr:") -> {
|
||||||
|
bundle.putString(KEY_SUBTITLE, "BML QR Merchant")
|
||||||
|
bundle.putString(KEY_COLOR, "#0066A1")
|
||||||
|
}
|
||||||
|
accountNumber.startsWith("mfaisaqr:") -> {
|
||||||
|
bundle.putString(KEY_SUBTITLE, "M-Faisa QR Merchant")
|
||||||
|
bundle.putString(KEY_COLOR, "#ED1C24")
|
||||||
|
}
|
||||||
account != null -> {
|
account != null -> {
|
||||||
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
|
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
|
||||||
bundle.putString(KEY_COLOR, "#FE860E")
|
bundle.putString(KEY_COLOR, "#FE860E")
|
||||||
@@ -191,6 +200,17 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
val hide = viewModel.hideAmounts.value ?: false
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
val items = mutableListOf<ContactPickerAdapter.PickerItem>()
|
val items = mutableListOf<ContactPickerAdapter.PickerItem>()
|
||||||
|
|
||||||
|
val accounts = viewModel.accounts.value ?: emptyList()
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
|
||||||
|
val fromCurrency = fromAccount?.currencyName ?: ""
|
||||||
|
val fromLoginTag = fromAccount?.loginTag ?: ""
|
||||||
|
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT" || fromAccount?.profileType == "BML_DEBIT"
|
||||||
|
val fromIsMfaisa = fromAccount?.bank == "MFAISA"
|
||||||
|
// TODO: when M-Faisa-supported contacts are wired up, swap this for a per-row check
|
||||||
|
// (e.g. is the recipient also an M-Faisa wallet) instead of disabling everything.
|
||||||
|
val mfaisaInactive = if (fromIsMfaisa) "Unsupported recipient from M-Faisa" else null
|
||||||
|
|
||||||
if (tabTag == RECENTS_TAG) {
|
if (tabTag == RECENTS_TAG) {
|
||||||
val recents = RecentsCache.load(requireContext())
|
val recents = RecentsCache.load(requireContext())
|
||||||
val filtered = if (search.isBlank()) recents else recents.filter {
|
val filtered = if (search.isBlank()) recents else recents.filter {
|
||||||
@@ -204,19 +224,16 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
subtitle = r.subtitle,
|
subtitle = r.subtitle,
|
||||||
colorHex = r.colorHex,
|
colorHex = r.colorHex,
|
||||||
isSameAsFrom = r.accountNumber == fromAccountNumber,
|
isSameAsFrom = r.accountNumber == fromAccountNumber,
|
||||||
imageHash = r.imageHash
|
imageHash = r.imageHash,
|
||||||
|
// A MFAISA-tagged recent is itself a valid M-Faisa recipient — don't grey it out
|
||||||
|
// when the source is M-Faisa.
|
||||||
|
inactiveReason = if (r.bank == "MFAISA") null else mfaisaInactive,
|
||||||
|
categoryId = r.contactCategory
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
val accounts = viewModel.accounts.value ?: emptyList()
|
|
||||||
val contacts = viewModel.contacts.value ?: emptyList()
|
|
||||||
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
|
|
||||||
val fromCurrency = fromAccount?.currencyName ?: ""
|
|
||||||
val fromLoginTag = fromAccount?.loginTag ?: ""
|
|
||||||
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT" || fromAccount?.profileType == "BML_DEBIT"
|
|
||||||
|
|
||||||
if (tabTag == MY_ACCOUNTS_TAG) {
|
if (tabTag == MY_ACCOUNTS_TAG) {
|
||||||
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" }
|
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" }
|
||||||
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
|
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
|
||||||
@@ -247,9 +264,12 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
colorHex = "#FE860E",
|
colorHex = "#FE860E",
|
||||||
isSameAsFrom = isSame,
|
isSameAsFrom = isSame,
|
||||||
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
||||||
inactiveReason = if (isSame) null
|
inactiveReason = when {
|
||||||
else if (fromIsCard && acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
isSame -> null
|
||||||
else currencyMismatchReason(fromCurrency, acc.currencyName),
|
mfaisaInactive != null && acc.bank != "MFAISA" -> mfaisaInactive
|
||||||
|
fromIsCard && acc.loginTag != fromLoginTag -> "Cards can only be used within the same BML account"
|
||||||
|
else -> currencyMismatchReason(fromCurrency, acc.currencyName)
|
||||||
|
},
|
||||||
balance = balance,
|
balance = balance,
|
||||||
bankLogoRes = logoRes
|
bankLogoRes = logoRes
|
||||||
))
|
))
|
||||||
@@ -279,10 +299,13 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
colorHex = "#FE860E",
|
colorHex = "#FE860E",
|
||||||
isSameAsFrom = isSame,
|
isSameAsFrom = isSame,
|
||||||
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
||||||
inactiveReason = if (isSame) null
|
inactiveReason = when {
|
||||||
else if (!isActive) acc.statusDesc
|
isSame -> null
|
||||||
else if (acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
mfaisaInactive != null -> mfaisaInactive
|
||||||
else currencyMismatchReason(fromCurrency, acc.currencyName),
|
!isActive -> acc.statusDesc
|
||||||
|
acc.loginTag != fromLoginTag -> "Cards can only be used within the same BML account"
|
||||||
|
else -> currencyMismatchReason(fromCurrency, acc.currencyName)
|
||||||
|
},
|
||||||
balance = balance,
|
balance = balance,
|
||||||
bankLogoRes = logoRes
|
bankLogoRes = logoRes
|
||||||
))
|
))
|
||||||
@@ -307,7 +330,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
colorHex = contact.bankColor,
|
colorHex = contact.bankColor,
|
||||||
isSameAsFrom = contact.benefAccount == fromAccountNumber,
|
isSameAsFrom = contact.benefAccount == fromAccountNumber,
|
||||||
imageHash = contact.customerImgHash,
|
imageHash = contact.customerImgHash,
|
||||||
inactiveReason = currencyMismatchReason(fromCurrency, contact.transferCyDesc)
|
inactiveReason = mfaisaInactive ?: currencyMismatchReason(fromCurrency, contact.transferCyDesc),
|
||||||
|
categoryId = contact.benefCategoryId
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
@@ -375,6 +399,8 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
const val KEY_SUBTITLE = "subtitle"
|
const val KEY_SUBTITLE = "subtitle"
|
||||||
const val KEY_COLOR = "color"
|
const val KEY_COLOR = "color"
|
||||||
const val KEY_IMAGE_HASH = "imageHash"
|
const val KEY_IMAGE_HASH = "imageHash"
|
||||||
|
/** The picked contact's category (`BankContact.benefCategoryId`); absent for non-contacts. */
|
||||||
|
const val KEY_CATEGORY = "category"
|
||||||
private const val ARG_FROM_ACCOUNT = "fromAccount"
|
private const val ARG_FROM_ACCOUNT = "fromAccount"
|
||||||
private const val RECENTS_TAG = "__recents__"
|
private const val RECENTS_TAG = "__recents__"
|
||||||
private const val MY_ACCOUNTS_TAG = "__my_accounts__"
|
private const val MY_ACCOUNTS_TAG = "__my_accounts__"
|
||||||
|
|||||||
@@ -11,14 +11,13 @@ import android.view.LayoutInflater
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import sh.sar.basedbank.R
|
|
||||||
import sh.sar.basedbank.databinding.ItemContactBinding
|
import sh.sar.basedbank.databinding.ItemContactBinding
|
||||||
import sh.sar.basedbank.util.ContactDisplay
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
|
||||||
class ContactsAdapter(
|
class ContactsAdapter(
|
||||||
private val imageCache: MutableMap<String, Bitmap>,
|
private val imageCache: MutableMap<String, Bitmap>,
|
||||||
private val onImageNeeded: (hash: String) -> Unit,
|
private val onImageNeeded: (hash: String) -> Unit,
|
||||||
private val onDeleteClick: (ContactDisplay) -> Unit,
|
private val onContactClick: (ContactDisplay) -> Unit,
|
||||||
private val onTransferClick: (ContactDisplay) -> Unit
|
private val onTransferClick: (ContactDisplay) -> Unit
|
||||||
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
|
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
|
||||||
|
|
||||||
@@ -66,12 +65,9 @@ class ContactsAdapter(
|
|||||||
val pos = holder.bindingAdapterPosition
|
val pos = holder.bindingAdapterPosition
|
||||||
if (pos != RecyclerView.NO_POSITION) onTransferClick(displayed[pos])
|
if (pos != RecyclerView.NO_POSITION) onTransferClick(displayed[pos])
|
||||||
}
|
}
|
||||||
binding.btnEditContact.setOnClickListener {
|
binding.root.setOnClickListener {
|
||||||
Toast.makeText(it.context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
binding.btnDeleteContact.setOnClickListener {
|
|
||||||
val pos = holder.bindingAdapterPosition
|
val pos = holder.bindingAdapterPosition
|
||||||
if (pos != RecyclerView.NO_POSITION) onDeleteClick(displayed[pos])
|
if (pos != RecyclerView.NO_POSITION) onContactClick(displayed[pos])
|
||||||
}
|
}
|
||||||
binding.root.setOnLongClickListener {
|
binding.root.setOnLongClickListener {
|
||||||
val pos = holder.bindingAdapterPosition
|
val pos = holder.bindingAdapterPosition
|
||||||
@@ -107,24 +103,22 @@ class ContactsAdapter(
|
|||||||
if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
|
if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
|
||||||
binding.btnTransferContact.visibility =
|
binding.btnTransferContact.visibility =
|
||||||
if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
|
if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
|
||||||
binding.btnEditContact.visibility =
|
|
||||||
if (contact.canEdit) android.view.View.VISIBLE else android.view.View.GONE
|
|
||||||
binding.btnDeleteContact.visibility =
|
|
||||||
if (contact.canDelete) android.view.View.VISIBLE else android.view.View.GONE
|
|
||||||
|
|
||||||
if (photo != null) {
|
if (photo != null) {
|
||||||
binding.ivContactPhoto.setImageBitmap(photo)
|
binding.ivContactPhoto.setImageBitmap(photo)
|
||||||
} else {
|
} else {
|
||||||
binding.ivContactPhoto.setImageBitmap(
|
|
||||||
makeInitialsBitmap(contact.name, contact.bankColor)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
|
|
||||||
val sizePx = binding.ivContactPhoto.context.resources
|
val sizePx = binding.ivContactPhoto.context.resources
|
||||||
.getDimensionPixelSize(android.R.dimen.app_icon_size)
|
.getDimensionPixelSize(android.R.dimen.app_icon_size)
|
||||||
.coerceAtLeast(96)
|
.coerceAtLeast(96)
|
||||||
|
binding.ivContactPhoto.setImageBitmap(
|
||||||
|
contactInitialsBitmap(contact.name, contact.bankColor, sizePx)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun contactInitialsBitmap(name: String, colorHex: String, sizePx: Int): Bitmap {
|
||||||
val bgColor = try { Color.parseColor(colorHex) } catch (e: Exception) { Color.GRAY }
|
val bgColor = try { Color.parseColor(colorHex) } catch (e: Exception) { Color.GRAY }
|
||||||
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||||
val canvas = Canvas(bm)
|
val canvas = Canvas(bm)
|
||||||
@@ -138,6 +132,4 @@ class ContactsAdapter(
|
|||||||
val metrics = paint.fontMetrics
|
val metrics = paint.fontMetrics
|
||||||
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
||||||
return bm
|
return bm
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,20 @@ package sh.sar.basedbank.ui.home
|
|||||||
|
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.text.SpannableStringBuilder
|
||||||
|
import android.text.Spanned
|
||||||
|
import android.text.style.ForegroundColorSpan
|
||||||
|
import android.text.style.StyleSpan
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Base64
|
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 android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.widget.addTextChangedListener
|
import androidx.core.widget.addTextChangedListener
|
||||||
@@ -59,7 +66,7 @@ class ContactsFragment : Fragment() {
|
|||||||
ContactsAdapter(
|
ContactsAdapter(
|
||||||
imageCache = sharedImageCache,
|
imageCache = sharedImageCache,
|
||||||
onImageNeeded = { hash -> fetchImage(hash) },
|
onImageNeeded = { hash -> fetchImage(hash) },
|
||||||
onDeleteClick = { contact -> confirmDelete(contact) },
|
onContactClick = { contact -> showDetails(contact) },
|
||||||
onTransferClick = { contact -> openTransfer(contact) }
|
onTransferClick = { contact -> openTransfer(contact) }
|
||||||
).also { a ->
|
).also { a ->
|
||||||
a.setFilter(page.categoryId, currentSearch)
|
a.setFilter(page.categoryId, currentSearch)
|
||||||
@@ -170,21 +177,43 @@ class ContactsFragment : Fragment() {
|
|||||||
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
|
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun openTransfer(contact: ContactDisplay) {
|
private fun showDetails(contact: ContactDisplay) {
|
||||||
|
if (childFragmentManager.findFragmentByTag("contact_details") != null) return
|
||||||
|
ContactDetailsSheetFragment.newInstance(contact).show(childFragmentManager, "contact_details")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun openTransfer(contact: ContactDisplay) {
|
||||||
val fragment = TransferFragment.newInstance(
|
val fragment = TransferFragment.newInstance(
|
||||||
accountNumber = contact.accountNumber,
|
accountNumber = contact.accountNumber,
|
||||||
displayName = contact.name,
|
displayName = contact.name,
|
||||||
subtitle = contact.transferSubtitle,
|
subtitle = contact.transferSubtitle,
|
||||||
colorHex = contact.bankColor,
|
colorHex = contact.bankColor,
|
||||||
imageHash = contact.imageHash
|
imageHash = contact.imageHash,
|
||||||
|
contactCategory = contact.categoryId
|
||||||
)
|
)
|
||||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
|
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun confirmDelete(contact: ContactDisplay) {
|
internal fun confirmDelete(contact: ContactDisplay) {
|
||||||
MaterialAlertDialogBuilder(requireContext())
|
val ctx = requireContext()
|
||||||
|
val errorColor = MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorError)
|
||||||
|
val icon = ContextCompat.getDrawable(ctx, R.drawable.ic_delete)?.mutate()?.apply { setTint(errorColor) }
|
||||||
|
|
||||||
|
val prompt = getString(R.string.contact_delete_message, contact.name)
|
||||||
|
val message = SpannableStringBuilder(prompt).apply {
|
||||||
|
val start = prompt.indexOf(contact.name)
|
||||||
|
if (start >= 0) setSpan(StyleSpan(Typeface.BOLD), start, start + contact.name.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
append("\n").append(contact.accountNumber)
|
||||||
|
append("\n\n")
|
||||||
|
val warnStart = length
|
||||||
|
append(getString(R.string.contact_delete_warning))
|
||||||
|
setSpan(ForegroundColorSpan(errorColor), warnStart, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
}
|
||||||
|
|
||||||
|
MaterialAlertDialogBuilder(ctx, R.style.ThemeOverlay_BasedBank_DestructiveDialog)
|
||||||
|
.setIcon(icon)
|
||||||
.setTitle(R.string.contact_delete_title)
|
.setTitle(R.string.contact_delete_title)
|
||||||
.setMessage(getString(R.string.contact_delete_message, contact.name))
|
.setMessage(message)
|
||||||
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
|
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
|
||||||
.setNegativeButton(R.string.cancel, null)
|
.setNegativeButton(R.string.cancel, null)
|
||||||
.show()
|
.show()
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import android.app.Activity
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
@@ -26,6 +26,8 @@ import sh.sar.basedbank.api.mib.MibCard
|
|||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.NfcPaymentUtil
|
||||||
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import sh.sar.basedbank.databinding.FragmentDashboardBinding
|
import sh.sar.basedbank.databinding.FragmentDashboardBinding
|
||||||
import sh.sar.basedbank.databinding.ItemForeignLimitBinding
|
import sh.sar.basedbank.databinding.ItemForeignLimitBinding
|
||||||
@@ -35,21 +37,35 @@ class DashboardFragment : Fragment() {
|
|||||||
private var _binding: FragmentDashboardBinding? = null
|
private var _binding: FragmentDashboardBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
private var pendingQrCardNumber: String? = null
|
||||||
private var pendingQrAccountNumber: String? = null
|
|
||||||
|
|
||||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
|
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
||||||
raw.startsWith("https://pay.bml.com.mv/app/")) {
|
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||||
|
if (bmlTarget != null) {
|
||||||
(requireActivity() as HomeActivity).navigateTo(
|
(requireActivity() as HomeActivity).navigateTo(
|
||||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, pendingQrAccountNumber)
|
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
val qr = PaymvQrParser.parse(raw)
|
||||||
|
if (qr?.accountNumber != null) {
|
||||||
|
Toast.makeText(requireContext(), R.string.card_qr_paymv_unsupported, Toast.LENGTH_SHORT).show()
|
||||||
|
val defaultFrom = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||||
|
(requireActivity() as HomeActivity).navigateTo(
|
||||||
|
R.id.nav_transfer, TransferFragment.newInstanceFromQr(
|
||||||
|
accountNumber = qr.accountNumber,
|
||||||
|
displayName = qr.merchantName ?: qr.accountNumber,
|
||||||
|
amount = qr.amount,
|
||||||
|
remarks = qr.purpose,
|
||||||
|
fromAccountNumber = defaultFrom
|
||||||
|
)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
pendingQrAccountNumber = null
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
@@ -84,11 +100,11 @@ class DashboardFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
binding.cardPendingFinances.setOnClickListener {
|
binding.cardPendingFinances.setOnClickListener {
|
||||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
|
(activity as? HomeActivity)?.showWithBackStackAndNav(FinancingFragment(), R.id.nav_finances)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.cardOverdue.setOnClickListener {
|
binding.cardOverdue.setOnClickListener {
|
||||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
|
(activity as? HomeActivity)?.showWithBackStackAndNav(FinancingFragment(), R.id.nav_finances)
|
||||||
}
|
}
|
||||||
|
|
||||||
val cardAdapter = DashboardCardAdapter()
|
val cardAdapter = DashboardCardAdapter()
|
||||||
@@ -97,12 +113,17 @@ class DashboardFragment : Fragment() {
|
|||||||
LinearSnapHelper().attachToRecyclerView(binding.rvCards)
|
LinearSnapHelper().attachToRecyclerView(binding.rvCards)
|
||||||
|
|
||||||
val updateCardList = {
|
val updateCardList = {
|
||||||
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
|
val credStore = CredentialStore(requireContext())
|
||||||
|
val hidden = credStore.getHiddenDashboardCardNumbers()
|
||||||
|
val mibItems = (viewModel.mibCards.value ?: emptyList())
|
||||||
|
.filter { CardsFragment.isMibCardActive(it.cardStatus) && !hidden.contains(it.maskedCardNumber) }
|
||||||
|
.map { CardItem.Mib(it) }
|
||||||
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
||||||
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) }
|
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
|
||||||
.map { CardItem.Bml(it) }
|
.map { CardItem.Bml(it) }
|
||||||
val all = mibItems + bmlItems
|
val rank = credStore.loginRank()
|
||||||
val defaultNum = CredentialStore(requireContext()).getDefaultCardAccountNumber()
|
val all = (bmlItems + mibItems).sortedBy { rank(it.loginTag) }
|
||||||
|
val defaultNum = credStore.getDefaultCardAccountNumber()
|
||||||
val ordered = if (defaultNum != null) {
|
val ordered = if (defaultNum != null) {
|
||||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||||
if (def != null) listOf(def) + all.filter { it !== def } else all
|
if (def != null) listOf(def) + all.filter { it !== def } else all
|
||||||
@@ -115,7 +136,7 @@ class DashboardFragment : Fragment() {
|
|||||||
|
|
||||||
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
|
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
|
||||||
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
val isBottomNav = NavCustomization.getNavMode(requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE)) == NavCustomization.NAV_MODE_BOTTOM
|
||||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||||
@@ -126,13 +147,32 @@ class DashboardFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
|
val isBottom = NavCustomization.getNavMode(requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)) == NavCustomization.NAV_MODE_BOTTOM
|
||||||
|
if (isBottom) {
|
||||||
|
requireActivity().title = getString(R.string.app_name)
|
||||||
|
val size = (28 * resources.displayMetrics.density).toInt()
|
||||||
|
val gap = (8 * resources.displayMetrics.density).toInt()
|
||||||
|
val icon = requireContext().packageManager.getApplicationIcon(requireContext().packageName)
|
||||||
|
val bmp = android.graphics.Bitmap.createBitmap(size + gap, size, android.graphics.Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = android.graphics.Canvas(bmp)
|
||||||
|
icon.setBounds(0, 0, size, size)
|
||||||
|
icon.draw(canvas)
|
||||||
|
requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar).logo =
|
||||||
|
android.graphics.drawable.BitmapDrawable(resources, bmp)
|
||||||
|
} else {
|
||||||
requireActivity().title = getString(R.string.nav_dashboard)
|
requireActivity().title = getString(R.string.nav_dashboard)
|
||||||
|
}
|
||||||
refreshQuickActions()
|
refreshQuickActions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar).logo = null
|
||||||
|
}
|
||||||
|
|
||||||
private fun refreshQuickActions() {
|
private fun refreshQuickActions() {
|
||||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
val isBottom = NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM
|
||||||
if (isBottom) {
|
if (isBottom) {
|
||||||
binding.buttonBar.visibility = View.GONE
|
binding.buttonBar.visibility = View.GONE
|
||||||
return
|
return
|
||||||
@@ -377,7 +417,7 @@ class DashboardFragment : Fragment() {
|
|||||||
if (isMib) {
|
if (isMib) {
|
||||||
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||||
} else {
|
} else {
|
||||||
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
|
pendingQrCardNumber = (item as CardItem.Bml).account.accountNumber
|
||||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -385,8 +425,17 @@ class DashboardFragment : Fragment() {
|
|||||||
val nfcSupported = nfcAdapter != null
|
val nfcSupported = nfcAdapter != null
|
||||||
btnPayNfc.isEnabled = nfcSupported
|
btnPayNfc.isEnabled = nfcSupported
|
||||||
btnPayNfc.setOnClickListener {
|
btnPayNfc.setOnClickListener {
|
||||||
val msg = if (isMib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
|
if (isMib) {
|
||||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
NfcPaymentUtil.checkAndProceed(requireContext()) {
|
||||||
|
val accountNumber = (item as CardItem.Bml).account.accountNumber
|
||||||
|
(requireActivity() as HomeActivity).navigateTo(
|
||||||
|
R.id.nav_pay_with_card,
|
||||||
|
CardsFragment.newInstanceWithAutoTapMode(accountNumber)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,9 +44,11 @@ class FinancingAdapter(mibDeals: List<MibFinanceDeal>) :
|
|||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>) {
|
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>, mibFirst: Boolean = true) {
|
||||||
expandedPositions.clear()
|
expandedPositions.clear()
|
||||||
items = mibDeals.map { Item.Mib(it) } + bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
|
val mibItems = mibDeals.map { Item.Mib(it) }
|
||||||
|
val bmlItems = bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
|
||||||
|
items = if (mibFirst) mibItems + bmlItems else bmlItems + mibItems
|
||||||
notifyDataSetChanged()
|
notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import sh.sar.basedbank.api.bml.BmlLoanDetail
|
|||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.databinding.FragmentFinancingBinding
|
import sh.sar.basedbank.databinding.FragmentFinancingBinding
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
|
||||||
class FinancingFragment : Fragment() {
|
class FinancingFragment : Fragment() {
|
||||||
|
|
||||||
@@ -67,7 +68,11 @@ class FinancingFragment : Fragment() {
|
|||||||
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
|
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
|
||||||
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
|
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
|
||||||
|
|
||||||
adapter.update(latestMibDeals, bmlLoans)
|
// MIB deals carry no login tag, so order the MIB and BML blocks by each bank's first login
|
||||||
|
val order = CredentialStore(requireContext()).getLoginOrder()
|
||||||
|
val firstMib = order.indexOfFirst { it.startsWith("mib_") }
|
||||||
|
val firstBml = order.indexOfFirst { it.startsWith("bml_") }
|
||||||
|
adapter.update(latestMibDeals, bmlLoans, mibFirst = firstBml < 0 || (firstMib in 0 until firstBml))
|
||||||
|
|
||||||
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
|
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
|
||||||
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ import androidx.core.view.WindowInsetsCompat
|
|||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
import androidx.lifecycle.repeatOnLifecycle
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -76,6 +78,8 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
private val viewModel: HomeViewModel by viewModels()
|
private val viewModel: HomeViewModel by viewModels()
|
||||||
private lateinit var toggle: ActionBarDrawerToggle
|
private lateinit var toggle: ActionBarDrawerToggle
|
||||||
private var suppressBottomNavCallback = false
|
private var suppressBottomNavCallback = false
|
||||||
|
private var cachedTransferFragment: TransferFragment? = null
|
||||||
|
private val navBackStack = ArrayDeque<Int>()
|
||||||
|
|
||||||
private var backPressedOnce = false
|
private var backPressedOnce = false
|
||||||
private val backPressHandler = Handler(Looper.getMainLooper())
|
private val backPressHandler = Handler(Looper.getMainLooper())
|
||||||
@@ -89,6 +93,10 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
private val warningRunnable = Runnable { showAutolockWarning() }
|
private val warningRunnable = Runnable { showAutolockWarning() }
|
||||||
|
|
||||||
private var isLocked = false
|
private var isLocked = false
|
||||||
|
private var pendingWheelUnlock = false
|
||||||
|
|
||||||
|
private var hasUnreadNotifications = false
|
||||||
|
private var notifMenuItem: MenuItem? = null
|
||||||
|
|
||||||
private val autolockRunnable = Runnable {
|
private val autolockRunnable = Runnable {
|
||||||
countdownTimer?.cancel(); countdownTimer = null
|
countdownTimer?.cancel(); countdownTimer = null
|
||||||
@@ -98,6 +106,72 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
if (securitySet) lock()
|
if (securitySet) lock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Payment guard ─────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The manifest has this activity handle theme, language, font-size and display-size changes
|
||||||
|
// itself, because recreating it mid-payment tears down the screen waiting on the bank's
|
||||||
|
// answer — the money can move with nothing left to say so. Those changes still need a
|
||||||
|
// recreate to re-inflate with the new resources, so it runs straight away when nothing is in
|
||||||
|
// flight and otherwise waits until the last payment finishes.
|
||||||
|
|
||||||
|
private var paymentsInFlight = 0
|
||||||
|
private var recreatePending = false
|
||||||
|
private var lastConfig: Configuration? = null
|
||||||
|
|
||||||
|
/** A payment in flight; [end] it once the outcome is on screen. Ending twice is harmless. */
|
||||||
|
inner class PaymentGuard internal constructor() {
|
||||||
|
private var ended = false
|
||||||
|
fun end() {
|
||||||
|
if (ended) return
|
||||||
|
ended = true
|
||||||
|
paymentsInFlight--
|
||||||
|
if (paymentsInFlight == 0 && recreatePending) {
|
||||||
|
recreatePending = false
|
||||||
|
// Posted so a receipt screen committed in the same pass is saved with the state
|
||||||
|
binding.root.post { recreate() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Holds off recreation until the guard ends, or [owner] is destroyed, whichever is first. */
|
||||||
|
fun beginPayment(owner: LifecycleOwner): PaymentGuard {
|
||||||
|
paymentsInFlight++
|
||||||
|
val guard = PaymentGuard()
|
||||||
|
owner.lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||||
|
override fun onDestroy(owner: LifecycleOwner) = guard.end()
|
||||||
|
})
|
||||||
|
return guard
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||||
|
super.onConfigurationChanged(newConfig)
|
||||||
|
val previous = lastConfig
|
||||||
|
lastConfig = Configuration(newConfig)
|
||||||
|
// Size and orientation changes are handled in place; these need fresh resources.
|
||||||
|
val needsRecreate = android.content.pm.ActivityInfo.CONFIG_UI_MODE or
|
||||||
|
android.content.pm.ActivityInfo.CONFIG_LOCALE or
|
||||||
|
android.content.pm.ActivityInfo.CONFIG_LAYOUT_DIRECTION or
|
||||||
|
android.content.pm.ActivityInfo.CONFIG_FONT_SCALE or
|
||||||
|
android.content.pm.ActivityInfo.CONFIG_DENSITY
|
||||||
|
if (previous == null || (previous.diff(newConfig) and needsRecreate) == 0) return
|
||||||
|
if (paymentsInFlight > 0) recreatePending = true else recreate()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun lockApp() = lock()
|
||||||
|
|
||||||
|
fun notifyWheelLockTap() {
|
||||||
|
val securitySet = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
|
.getString("security_method", null) != null
|
||||||
|
if (securitySet) {
|
||||||
|
pendingWheelUnlock = true
|
||||||
|
lock()
|
||||||
|
} else {
|
||||||
|
// No security configured — unlock the wheel immediately
|
||||||
|
(supportFragmentManager.findFragmentById(R.id.contentFrame) as? CircularNavFragment)
|
||||||
|
?.unlockWheelLock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun lock() {
|
private fun lock() {
|
||||||
isLocked = true
|
isLocked = true
|
||||||
startActivity(
|
startActivity(
|
||||||
@@ -115,6 +189,7 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
window.addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
}
|
}
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
|
lastConfig = Configuration(resources.configuration)
|
||||||
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
|
val isLight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_NO
|
||||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||||
isAppearanceLightStatusBars = isLight
|
isAppearanceLightStatusBars = isLight
|
||||||
@@ -150,13 +225,23 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
insets
|
insets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The app bar only pads for the status bar. In landscape the navigation bar (and any
|
||||||
|
// cutout) sits at a side edge, and the toolbar's end icons — the lock button — would
|
||||||
|
// draw underneath it, out of reach.
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.toolbar) { v, insets ->
|
||||||
|
val sides = insets.getInsets(
|
||||||
|
WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
|
||||||
|
v.updatePadding(left = sides.left, right = sides.right)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
binding.bottomNavigation.setOnItemSelectedListener { item ->
|
binding.bottomNavigation.setOnItemSelectedListener { item ->
|
||||||
if (suppressBottomNavCallback) return@setOnItemSelectedListener true
|
if (suppressBottomNavCallback) return@setOnItemSelectedListener true
|
||||||
val frag = when (item.itemId) {
|
val frag = when (item.itemId) {
|
||||||
R.id.nav_dashboard -> DashboardFragment()
|
R.id.nav_dashboard -> DashboardFragment()
|
||||||
R.id.nav_accounts -> AccountsFragment()
|
R.id.nav_accounts -> AccountsFragment()
|
||||||
R.id.nav_contacts -> ContactsFragment()
|
R.id.nav_contacts -> ContactsFragment()
|
||||||
R.id.nav_transfer -> TransferFragment()
|
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
|
||||||
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
||||||
R.id.nav_more -> MoreFragment()
|
R.id.nav_more -> MoreFragment()
|
||||||
R.id.nav_activities -> ActivitiesFragment()
|
R.id.nav_activities -> ActivitiesFragment()
|
||||||
@@ -181,9 +266,9 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load data
|
// Load data
|
||||||
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
|
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty() || app.mfaisaAccounts.isNotEmpty()) {
|
||||||
// Came from fresh manual login — accounts ready, rest fetched in background
|
// Came from fresh manual login — accounts ready, rest fetched in background
|
||||||
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts
|
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts + app.mfaisaAccounts
|
||||||
viewModel.accounts.value = merged.filterVisibleAccounts()
|
viewModel.accounts.value = merged.filterVisibleAccounts()
|
||||||
if (app.mibAccounts.isNotEmpty()) AccountCache.save(this, app.mibAccounts)
|
if (app.mibAccounts.isNotEmpty()) AccountCache.save(this, app.mibAccounts)
|
||||||
if (app.bmlAccounts.isNotEmpty()) {
|
if (app.bmlAccounts.isNotEmpty()) {
|
||||||
@@ -194,6 +279,10 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
val byLoginId = app.fahipayAccounts.groupBy { it.loginTag.removePrefix("fahipay_") }
|
val byLoginId = app.fahipayAccounts.groupBy { it.loginTag.removePrefix("fahipay_") }
|
||||||
byLoginId.forEach { (loginId, accs) -> AccountCache.saveFahipay(this, loginId, accs) }
|
byLoginId.forEach { (loginId, accs) -> AccountCache.saveFahipay(this, loginId, accs) }
|
||||||
}
|
}
|
||||||
|
if (app.mfaisaAccounts.isNotEmpty()) {
|
||||||
|
val byLoginId = app.mfaisaAccounts.groupBy { it.loginTag.removePrefix("mfaisa_") }
|
||||||
|
byLoginId.forEach { (loginId, accs) -> AccountCache.saveMfaisa(this, loginId, accs) }
|
||||||
|
}
|
||||||
|
|
||||||
val cachedCards = CardsCache.load(this)
|
val cachedCards = CardsCache.load(this)
|
||||||
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
||||||
@@ -217,10 +306,11 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
val cachedMib = AccountCache.load(this)
|
val cachedMib = AccountCache.load(this)
|
||||||
val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds())
|
val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds())
|
||||||
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
|
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
|
||||||
val merged = cachedMib + cachedBml + cachedFahipay
|
val cachedMfaisa = AccountCache.loadMfaisa(this, store.getMfaisaLoginIds())
|
||||||
if (merged.isNotEmpty()) viewModel.accounts.value = merged
|
val merged = cachedMib + cachedBml + cachedFahipay + cachedMfaisa
|
||||||
|
if (merged.isNotEmpty()) viewModel.accounts.value = merged.filterVisibleAccounts()
|
||||||
val cachedCards = CardsCache.load(this)
|
val cachedCards = CardsCache.load(this)
|
||||||
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards.filterVisibleCards()
|
||||||
val cachedFinancing = FinancingCache.load(this)
|
val cachedFinancing = FinancingCache.load(this)
|
||||||
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
|
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
|
||||||
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
|
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
|
||||||
@@ -237,16 +327,33 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
val navDest = intent.getIntExtra("nav_destination", -1)
|
val navDest = intent.getIntExtra("nav_destination", -1)
|
||||||
val autoScan = intent.getBooleanExtra("auto_scan", false)
|
val autoScan = intent.getBooleanExtra("auto_scan", false)
|
||||||
if (navDest != -1) {
|
val autoTapMode = intent.getBooleanExtra("auto_tap_mode", false)
|
||||||
val fragment = if (autoScan && navDest == R.id.nav_transfer)
|
val shareQrText = intent.getStringExtra("share_qr_text")
|
||||||
TransferFragment.newInstanceWithAutoScan()
|
when {
|
||||||
else null
|
shareQrText != null -> {
|
||||||
|
show(DashboardFragment())
|
||||||
|
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
||||||
|
routeSharedQrText(shareQrText)
|
||||||
|
}
|
||||||
|
navDest != -1 -> {
|
||||||
|
val fragment = when {
|
||||||
|
autoScan && navDest == R.id.nav_transfer -> TransferFragment.newInstanceWithAutoScan()
|
||||||
|
autoTapMode && navDest == R.id.nav_pay_with_card -> CardsFragment.newInstanceWithAutoTapMode()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
navigateTo(navDest, fragment)
|
navigateTo(navDest, fragment)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val initPrefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
|
if (NavCustomization.getNavMode(initPrefs) == NavCustomization.NAV_MODE_CIRCULAR) {
|
||||||
|
show(CircularNavFragment())
|
||||||
} else {
|
} else {
|
||||||
show(DashboardFragment())
|
show(DashboardFragment())
|
||||||
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||||
override fun handleOnBackPressed() {
|
override fun handleOnBackPressed() {
|
||||||
@@ -258,14 +365,40 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
// Let CardsFragment handle back if in manage mode
|
// Let CardsFragment handle back if in manage mode
|
||||||
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
|
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
|
||||||
if (currentFrag is CardsFragment && currentFrag.onBackPressed()) return
|
if (currentFrag is CardsFragment && currentFrag.onBackPressed()) return
|
||||||
|
|
||||||
|
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
|
val navMode = NavCustomization.getNavMode(prefs)
|
||||||
|
|
||||||
|
// Circular nav mode: back always returns to the wheel
|
||||||
|
if (navMode == NavCustomization.NAV_MODE_CIRCULAR) {
|
||||||
|
if (supportFragmentManager.backStackEntryCount > 0) {
|
||||||
|
supportFragmentManager.popBackStack()
|
||||||
|
navBackStack.removeLastOrNull()?.let { updateNavSelection(it) }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (currentFrag is CircularNavFragment) {
|
||||||
|
if (backPressedOnce) {
|
||||||
|
backPressHandler.removeCallbacks(resetBackPress)
|
||||||
|
finish()
|
||||||
|
} else {
|
||||||
|
backPressedOnce = true
|
||||||
|
Toast.makeText(this@HomeActivity, R.string.press_back_to_exit, Toast.LENGTH_SHORT).show()
|
||||||
|
backPressHandler.postDelayed(resetBackPress, 2000)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
show(CircularNavFragment())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Pop fragment back stack if there's anything on it (e.g. showWithBackStack)
|
// Pop fragment back stack if there's anything on it (e.g. showWithBackStack)
|
||||||
if (supportFragmentManager.backStackEntryCount > 0) {
|
if (supportFragmentManager.backStackEntryCount > 0) {
|
||||||
supportFragmentManager.popBackStack()
|
supportFragmentManager.popBackStack()
|
||||||
|
navBackStack.removeLastOrNull()?.let { updateNavSelection(it) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// In bottom nav mode, pressing back navigates up the hierarchy
|
// In bottom nav mode, pressing back navigates up the hierarchy
|
||||||
val isBottomNav = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
|
if (navMode == NavCustomization.NAV_MODE_BOTTOM && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
|
||||||
if (isBottomNav && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
|
|
||||||
// Sub-page reached via More (e.g. Settings, Activities) — go back to More
|
// Sub-page reached via More (e.g. Settings, Activities) — go back to More
|
||||||
if (binding.bottomNavigation.selectedItemId == R.id.nav_more && currentFrag !is MoreFragment) {
|
if (binding.bottomNavigation.selectedItemId == R.id.nav_more && currentFrag !is MoreFragment) {
|
||||||
show(MoreFragment())
|
show(MoreFragment())
|
||||||
@@ -321,23 +454,46 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
.commit()
|
.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateNavSelection(itemId: Int) {
|
||||||
|
binding.navigationView.setCheckedItem(itemId)
|
||||||
|
if (binding.bottomNavigation.visibility == View.VISIBLE) {
|
||||||
|
val bottomNavIds = (0 until binding.bottomNavigation.menu.size())
|
||||||
|
.map { binding.bottomNavigation.menu.getItem(it).itemId }.toSet()
|
||||||
|
val selectId = if (itemId in bottomNavIds) itemId else if (R.id.nav_more in bottomNavIds) R.id.nav_more else null
|
||||||
|
if (selectId != null) {
|
||||||
|
suppressBottomNavCallback = true
|
||||||
|
binding.bottomNavigation.selectedItemId = selectId
|
||||||
|
suppressBottomNavCallback = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun applyNavMode() {
|
fun applyNavMode() {
|
||||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
when (NavCustomization.getNavMode(prefs)) {
|
||||||
if (isBottom) {
|
NavCustomization.NAV_MODE_BOTTOM -> {
|
||||||
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
||||||
toggle.isDrawerIndicatorEnabled = false
|
toggle.isDrawerIndicatorEnabled = false
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
||||||
binding.bottomNavigation.visibility = View.VISIBLE
|
binding.bottomNavigation.visibility = View.VISIBLE
|
||||||
rebuildBottomNav(prefs)
|
rebuildBottomNav(prefs)
|
||||||
applyNavLabelVisibility()
|
applyNavLabelVisibility()
|
||||||
} else {
|
}
|
||||||
|
NavCustomization.NAV_MODE_CIRCULAR -> {
|
||||||
|
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
||||||
|
toggle.isDrawerIndicatorEnabled = false
|
||||||
|
supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
||||||
|
binding.bottomNavigation.visibility = View.GONE
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
supportActionBar?.show()
|
||||||
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
|
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
|
||||||
toggle.isDrawerIndicatorEnabled = true
|
toggle.isDrawerIndicatorEnabled = true
|
||||||
toggle.syncState()
|
toggle.syncState()
|
||||||
binding.bottomNavigation.visibility = View.GONE
|
binding.bottomNavigation.visibility = View.GONE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun rebuildBottomNav(prefs: android.content.SharedPreferences = getSharedPreferences("prefs", MODE_PRIVATE)) {
|
fun rebuildBottomNav(prefs: android.content.SharedPreferences = getSharedPreferences("prefs", MODE_PRIVATE)) {
|
||||||
val slots = NavCustomization.getSlots(prefs)
|
val slots = NavCustomization.getSlots(prefs)
|
||||||
@@ -374,11 +530,15 @@ fun applyNavLabelVisibility() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun navigateTo(itemId: Int, fragment: Fragment? = null) {
|
fun navigateTo(itemId: Int, fragment: Fragment? = null) {
|
||||||
|
// Restore action bar when leaving the circular wheel screen
|
||||||
|
if (NavCustomization.getNavMode(getSharedPreferences("prefs", MODE_PRIVATE)) == NavCustomization.NAV_MODE_CIRCULAR) {
|
||||||
|
supportActionBar?.show()
|
||||||
|
}
|
||||||
val dest = fragment ?: when (itemId) {
|
val dest = fragment ?: when (itemId) {
|
||||||
R.id.nav_dashboard -> DashboardFragment()
|
R.id.nav_dashboard -> DashboardFragment()
|
||||||
R.id.nav_accounts -> AccountsFragment()
|
R.id.nav_accounts -> AccountsFragment()
|
||||||
R.id.nav_contacts -> ContactsFragment()
|
R.id.nav_contacts -> ContactsFragment()
|
||||||
R.id.nav_transfer -> TransferFragment()
|
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
|
||||||
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
||||||
R.id.nav_activities -> ActivitiesFragment()
|
R.id.nav_activities -> ActivitiesFragment()
|
||||||
R.id.nav_transfer_history -> TransferHistoryFragment()
|
R.id.nav_transfer_history -> TransferHistoryFragment()
|
||||||
@@ -386,25 +546,16 @@ fun applyNavLabelVisibility() {
|
|||||||
R.id.nav_otp -> OtpFragment()
|
R.id.nav_otp -> OtpFragment()
|
||||||
R.id.nav_settings -> SettingsFragment()
|
R.id.nav_settings -> SettingsFragment()
|
||||||
R.id.nav_pay_with_card -> CardsFragment()
|
R.id.nav_pay_with_card -> CardsFragment()
|
||||||
|
R.id.nav_more -> MoreFragment()
|
||||||
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
|
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
|
||||||
}
|
}
|
||||||
show(dest)
|
show(dest)
|
||||||
binding.navigationView.setCheckedItem(itemId)
|
updateNavSelection(itemId)
|
||||||
if (binding.bottomNavigation.visibility == View.VISIBLE) {
|
|
||||||
val bottomNavIds = (0 until binding.bottomNavigation.menu.size())
|
|
||||||
.map { binding.bottomNavigation.menu.getItem(it).itemId }.toSet()
|
|
||||||
val selectId = if (itemId in bottomNavIds) itemId else if (R.id.nav_more in bottomNavIds) R.id.nav_more else null
|
|
||||||
if (selectId != null) {
|
|
||||||
suppressBottomNavCallback = true
|
|
||||||
binding.bottomNavigation.selectedItemId = selectId
|
|
||||||
suppressBottomNavCallback = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setBottomNavVisible(visible: Boolean) {
|
fun setBottomNavVisible(visible: Boolean) {
|
||||||
val isBottom = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
|
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
if (isBottom) {
|
if (NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM) {
|
||||||
binding.bottomNavigation.visibility = if (visible) View.VISIBLE else View.GONE
|
binding.bottomNavigation.visibility = if (visible) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -433,6 +584,37 @@ fun applyNavLabelVisibility() {
|
|||||||
.commit()
|
.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun showWithBackStackAndNav(fragment: Fragment, itemId: Int) {
|
||||||
|
navBackStack.addLast(binding.bottomNavigation.selectedItemId)
|
||||||
|
showWithBackStack(fragment)
|
||||||
|
updateNavSelection(itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun routeSharedQrText(text: String) {
|
||||||
|
val store = CredentialStore(this)
|
||||||
|
sh.sar.basedbank.api.bml.BmlMerchantTxnClient.parseTransactionId(text)?.let {
|
||||||
|
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlTxn(it))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text)
|
||||||
|
if (bmlTarget != null) {
|
||||||
|
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
|
||||||
|
if (qr?.accountNumber != null) {
|
||||||
|
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromQr(
|
||||||
|
accountNumber = qr.accountNumber,
|
||||||
|
displayName = qr.merchantName ?: qr.accountNumber,
|
||||||
|
amount = qr.amount,
|
||||||
|
remarks = qr.purpose,
|
||||||
|
fromAccountNumber = store.getDefaultAccountNumber()
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Toast.makeText(this, R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
// Returning from LockActivity — refresh sessions since they may have expired.
|
// Returning from LockActivity — refresh sessions since they may have expired.
|
||||||
@@ -441,6 +623,11 @@ fun applyNavLabelVisibility() {
|
|||||||
pauseTime = 0L
|
pauseTime = 0L
|
||||||
resetAutolockTimer()
|
resetAutolockTimer()
|
||||||
autoRefresh(CredentialStore(this))
|
autoRefresh(CredentialStore(this))
|
||||||
|
if (pendingWheelUnlock) {
|
||||||
|
pendingWheelUnlock = false
|
||||||
|
(supportFragmentManager.findFragmentById(R.id.contentFrame) as? CircularNavFragment)
|
||||||
|
?.unlockWheelLock()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// If we were away long enough to have hit the autolock timeout (e.g. while
|
// If we were away long enough to have hit the autolock timeout (e.g. while
|
||||||
@@ -523,9 +710,19 @@ fun applyNavLabelVisibility() {
|
|||||||
eyeItem?.isVisible = true
|
eyeItem?.isVisible = true
|
||||||
val hidden = viewModel.hideAmounts.value ?: false
|
val hidden = viewModel.hideAmounts.value ?: false
|
||||||
eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility)
|
eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility)
|
||||||
|
notifMenuItem = menu.findItem(R.id.action_notifications)
|
||||||
|
notifMenuItem?.setIcon(if (hasUnreadNotifications) R.drawable.ic_bell else R.drawable.ic_bell_read)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
|
||||||
|
val onWheel = supportFragmentManager.findFragmentById(R.id.contentFrame) is CircularNavFragment
|
||||||
|
menu.findItem(R.id.action_hide_amounts)?.isVisible = !onWheel
|
||||||
|
menu.findItem(R.id.action_lock)?.isVisible = !onWheel
|
||||||
|
menu.findItem(R.id.action_notifications)?.isVisible = !onWheel
|
||||||
|
return super.onPrepareOptionsMenu(menu)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
if (item.itemId == R.id.action_lock) {
|
if (item.itemId == R.id.action_lock) {
|
||||||
val avd = getDrawable(R.drawable.avd_lock) as? android.graphics.drawable.AnimatedVectorDrawable
|
val avd = getDrawable(R.drawable.avd_lock) as? android.graphics.drawable.AnimatedVectorDrawable
|
||||||
@@ -538,6 +735,10 @@ fun applyNavLabelVisibility() {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if (item.itemId == R.id.action_notifications) {
|
||||||
|
openNotificationsSheet()
|
||||||
|
return true
|
||||||
|
}
|
||||||
if (item.itemId == R.id.action_hide_amounts) {
|
if (item.itemId == R.id.action_hide_amounts) {
|
||||||
val newHidden = !(viewModel.hideAmounts.value ?: false)
|
val newHidden = !(viewModel.hideAmounts.value ?: false)
|
||||||
viewModel.hideAmounts.value = newHidden
|
viewModel.hideAmounts.value = newHidden
|
||||||
@@ -551,23 +752,36 @@ fun applyNavLabelVisibility() {
|
|||||||
return super.onOptionsItemSelected(item)
|
return super.onOptionsItemSelected(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setNotificationUnread(hasUnread: Boolean) {
|
||||||
|
hasUnreadNotifications = hasUnread
|
||||||
|
notifMenuItem?.setIcon(if (hasUnread) R.drawable.ic_bell else R.drawable.ic_bell_read)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openNotificationsSheet() {
|
||||||
|
val sheet = NotificationsSheetFragment()
|
||||||
|
sheet.onUnreadCountChanged = { hasUnread -> setNotificationUnread(hasUnread) }
|
||||||
|
sheet.show(supportFragmentManager, "notifications")
|
||||||
|
}
|
||||||
|
|
||||||
fun relogin() {
|
fun relogin() {
|
||||||
val store = CredentialStore(this)
|
val store = CredentialStore(this)
|
||||||
val mibLoginIds = store.getMibLoginIds()
|
val mibLoginIds = store.getMibLoginIds()
|
||||||
val bmlLoginIds = store.getBmlLoginIds()
|
val bmlLoginIds = store.getBmlLoginIds()
|
||||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||||
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) {
|
val mfaisaLoginIds = store.getMfaisaLoginIds()
|
||||||
|
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty() && mfaisaLoginIds.isEmpty()) {
|
||||||
startActivity(Intent(this, LoginActivity::class.java))
|
startActivity(Intent(this, LoginActivity::class.java))
|
||||||
finish()
|
finish()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Immediately drop accounts for logged-out logins from the displayed list
|
// Immediately drop accounts for logged-out logins from the displayed list
|
||||||
val current = viewModel.accounts.value ?: emptyList()
|
val current = viewModel.accounts.value ?: emptyList()
|
||||||
|
val mfaisaLoginIdsForFilter = store.getMfaisaLoginIds()
|
||||||
viewModel.accounts.value = current.filter { acc ->
|
viewModel.accounts.value = current.filter { acc ->
|
||||||
if (acc.bank == "MIB") return@filter acc.loginTag.removePrefix("mib_") in mibLoginIds
|
if (acc.bank == "MIB") return@filter acc.loginTag.removePrefix("mib_") in mibLoginIds
|
||||||
if (acc.bank == "BML") return@filter acc.loginTag.removePrefix("bml_") in bmlLoginIds
|
if (acc.bank == "BML") return@filter acc.loginTag.removePrefix("bml_") in bmlLoginIds
|
||||||
if (acc.bank == "FAHIPAY") return@filter acc.loginTag.removePrefix("fahipay_") in fahipayLoginIds
|
if (acc.bank == "FAHIPAY") return@filter acc.loginTag.removePrefix("fahipay_") in fahipayLoginIds
|
||||||
|
if (acc.bank == "MFAISA") return@filter acc.loginTag.removePrefix("mfaisa_") in mfaisaLoginIdsForFilter
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
autoRefresh(store)
|
autoRefresh(store)
|
||||||
@@ -586,7 +800,8 @@ fun applyNavLabelVisibility() {
|
|||||||
val mibLoginIds = store.getMibLoginIds()
|
val mibLoginIds = store.getMibLoginIds()
|
||||||
val bmlLoginIds = store.getBmlLoginIds()
|
val bmlLoginIds = store.getBmlLoginIds()
|
||||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||||
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) return
|
val mfaisaLoginIds = store.getMfaisaLoginIds()
|
||||||
|
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty() && mfaisaLoginIds.isEmpty()) return
|
||||||
binding.refreshIndicator.visibility = View.VISIBLE
|
binding.refreshIndicator.visibility = View.VISIBLE
|
||||||
hideConnectivityBanner()
|
hideConnectivityBanner()
|
||||||
|
|
||||||
@@ -627,47 +842,13 @@ fun applyNavLabelVisibility() {
|
|||||||
|
|
||||||
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
|
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
|
||||||
|
|
||||||
val bmlClient = BmlAccountClient()
|
// Hidden profiles are never fetched; unhiding one fetches it on its own (fetchEnabledBmlProfiles)
|
||||||
|
val hiddenProfiles = store.getHiddenBmlProfileIds(loginId)
|
||||||
for (profile in savedProfiles) {
|
for (profile in savedProfiles) {
|
||||||
val saved = store.loadBmlProfileSession(profile.profileId)
|
if (profile.profileId in hiddenProfiles) continue
|
||||||
val refreshToken = store.loadBmlProfileRefreshToken(profile.profileId)
|
|
||||||
if (saved == null) {
|
|
||||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
|
||||||
.filter { it.profileId == profile.profileId }
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
val expiresAt = store.loadBmlProfileExpiresAt(profile.profileId)
|
|
||||||
val tokenKnownExpired = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
|
|
||||||
|
|
||||||
suspend fun fetchWithSession(session: BmlSession) {
|
|
||||||
bmlClient.checkProfile(session)
|
|
||||||
val accounts = bmlClient.fetchAccounts(session, loginTag, profile.name, profile.profileId)
|
|
||||||
app.bmlSessions[profile.profileId] = session
|
|
||||||
allAccounts += accounts
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun tryRefresh() {
|
|
||||||
if (refreshToken == null) throw Exception("No refresh token")
|
|
||||||
val oldSession = BmlSession(saved.first, saved.second, refreshToken)
|
|
||||||
val newSession = app.bmlFlowFor(loginId).refreshSession(oldSession)
|
|
||||||
store.saveBmlProfileSession(profile.profileId, newSession.accessToken, newSession.deviceId)
|
|
||||||
if (newSession.refreshToken.isNotBlank())
|
|
||||||
store.saveBmlProfileRefreshToken(profile.profileId, newSession.refreshToken)
|
|
||||||
if (newSession.expiresAt > 0)
|
|
||||||
store.saveBmlProfileExpiresAt(profile.profileId, newSession.expiresAt)
|
|
||||||
fetchWithSession(newSession)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (tokenKnownExpired) {
|
allAccounts += fetchBmlProfileAccounts(store, loginId, profile)
|
||||||
tryRefresh()
|
?: AccountCache.loadBml(this@HomeActivity, loginId).filter { it.profileId == profile.profileId }
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
fetchWithSession(BmlSession(saved.first, saved.second))
|
|
||||||
} catch (_: AuthExpiredException) {
|
|
||||||
tryRefresh()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: java.io.IOException) {
|
} catch (e: java.io.IOException) {
|
||||||
refreshErrors.add("NO_INTERNET")
|
refreshErrors.add("NO_INTERNET")
|
||||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||||
@@ -760,17 +941,58 @@ fun applyNavLabelVisibility() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One async job per M-Faisa login, all run in parallel.
|
||||||
|
// M-Faisa has no session refresh — sessions expire after ~240s — so we re-login each refresh.
|
||||||
|
val mfaisaJobs = mfaisaLoginIds.mapNotNull { loginId ->
|
||||||
|
val creds = store.loadMfaisaCredentials(loginId) ?: return@mapNotNull null
|
||||||
|
loginId to async(Dispatchers.IO) {
|
||||||
|
val loginTag = "mfaisa_$loginId"
|
||||||
|
val flow = sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow(this@HomeActivity)
|
||||||
|
try {
|
||||||
|
flow.fetchSubscriber(creds.msisdn)
|
||||||
|
val result = flow.doMobileLogin(creds.msisdn, creds.pin)
|
||||||
|
val accounts = sh.sar.basedbank.api.mfaisa.MfaisaAccountClient.buildAccounts(result, loginTag)
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
app.mfaisaSessions[loginId] = result.session
|
||||||
|
store.saveMfaisaUserProfile(
|
||||||
|
loginId,
|
||||||
|
CredentialStore.MfaisaUserProfile(
|
||||||
|
name = result.profile.name,
|
||||||
|
email = result.profile.email,
|
||||||
|
mdnId = result.profile.mdnId,
|
||||||
|
subscriberId = result.profile.subscriberId,
|
||||||
|
walletId = result.profile.walletId,
|
||||||
|
roleId = result.profile.roleId,
|
||||||
|
offerId = result.profile.offerId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AccountCache.saveMfaisa(this@HomeActivity, loginId, accounts)
|
||||||
|
accounts
|
||||||
|
} catch (e: java.io.IOException) {
|
||||||
|
refreshErrors.add("NO_INTERNET")
|
||||||
|
AccountCache.loadMfaisa(this@HomeActivity, loginId)
|
||||||
|
} catch (e: BankServerException) {
|
||||||
|
refreshErrors.add("SERVER:${e.bankName}")
|
||||||
|
AccountCache.loadMfaisa(this@HomeActivity, loginId)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
AccountCache.loadMfaisa(this@HomeActivity, loginId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() }
|
val mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() }
|
||||||
val mibAccounts = mibResults.flatMap { it.second }
|
val mibAccounts = mibResults.flatMap { it.second }
|
||||||
val bmlAccounts = bmlJobs.flatMap { (_, job) -> job.await() }
|
val bmlAccounts = bmlJobs.flatMap { (_, job) -> job.await() }
|
||||||
val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() }
|
val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() }
|
||||||
|
val mfaisaAccounts = mfaisaJobs.flatMap { (_, job) -> job.await() }
|
||||||
|
|
||||||
val app = application as BasedBankApp
|
val app = application as BasedBankApp
|
||||||
app.mibAccounts = mibAccounts
|
app.mibAccounts = mibAccounts
|
||||||
AccountCache.save(this@HomeActivity, mibAccounts)
|
AccountCache.save(this@HomeActivity, mibAccounts)
|
||||||
app.bmlAccounts = bmlAccounts
|
app.bmlAccounts = bmlAccounts
|
||||||
app.fahipayAccounts = fahipayAccounts
|
app.fahipayAccounts = fahipayAccounts
|
||||||
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts())
|
app.mfaisaAccounts = mfaisaAccounts
|
||||||
|
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts + mfaisaAccounts).filterVisibleAccounts())
|
||||||
binding.refreshIndicator.visibility = View.GONE
|
binding.refreshIndicator.visibility = View.GONE
|
||||||
|
|
||||||
val noInternet = refreshErrors.any { it == "NO_INTERNET" }
|
val noInternet = refreshErrors.any { it == "NO_INTERNET" }
|
||||||
@@ -797,15 +1019,17 @@ fun applyNavLabelVisibility() {
|
|||||||
refreshBmlLoanDetails()
|
refreshBmlLoanDetails()
|
||||||
for ((loginId, session) in app.mibSessions) {
|
for ((loginId, session) in app.mibSessions) {
|
||||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
refreshMibCards(loginId, session, profiles)
|
refreshMibCards(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Filters accounts whose profileId the user has hidden in settings. */
|
/** Filters accounts whose profileId or account number the user has hidden in settings. */
|
||||||
private fun List<BankAccount>.filterVisibleAccounts(): List<BankAccount> {
|
private fun List<BankAccount>.filterVisibleAccounts(): List<BankAccount> {
|
||||||
val store = CredentialStore(this@HomeActivity)
|
val store = CredentialStore(this@HomeActivity)
|
||||||
|
val hiddenAccountNumbers = store.getHiddenAccountNumbers()
|
||||||
return filter { acc ->
|
return filter { acc ->
|
||||||
|
if (acc.accountNumber in hiddenAccountNumbers) return@filter false
|
||||||
when (acc.bank) {
|
when (acc.bank) {
|
||||||
"MIB" -> {
|
"MIB" -> {
|
||||||
val loginId = acc.loginTag.removePrefix("mib_")
|
val loginId = acc.loginTag.removePrefix("mib_")
|
||||||
@@ -817,6 +1041,11 @@ fun applyNavLabelVisibility() {
|
|||||||
val hidden = store.getHiddenBmlProfileIds(loginId)
|
val hidden = store.getHiddenBmlProfileIds(loginId)
|
||||||
hidden.isEmpty() || acc.profileId !in hidden
|
hidden.isEmpty() || acc.profileId !in hidden
|
||||||
}
|
}
|
||||||
|
"MFAISA" -> {
|
||||||
|
val loginId = acc.loginTag.removePrefix("mfaisa_")
|
||||||
|
val hidden = store.getHiddenMfaisaPocketIds(loginId)
|
||||||
|
hidden.isEmpty() || acc.accountNumber !in hidden
|
||||||
|
}
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -829,10 +1058,140 @@ fun applyNavLabelVisibility() {
|
|||||||
return filter { it.profileId !in hidden }
|
return filter { it.profileId !in hidden }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Called by SettingsLoginsFragment after the user changes profile visibility. */
|
/** Drops MIB cards belonging to profiles the user has hidden. */
|
||||||
fun applyProfileVisibility() {
|
private fun List<sh.sar.basedbank.api.mib.MibCard>.filterVisibleCards(): List<sh.sar.basedbank.api.mib.MibCard> {
|
||||||
val current = viewModel.accounts.value ?: return
|
val store = CredentialStore(this@HomeActivity)
|
||||||
viewModel.accounts.value = current.filterVisibleAccounts()
|
return filter { card ->
|
||||||
|
card.profileId.isEmpty() || card.profileId !in store.getHiddenMibProfileIds(card.loginTag.removePrefix("mib_"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by SettingsLoginsFragment after the user hides/unhides profiles or accounts.
|
||||||
|
* Re-filters the in-memory data only — no network. Newly unhidden profiles are fetched
|
||||||
|
* separately via [fetchEnabledMibProfiles] / [fetchEnabledBmlProfiles].
|
||||||
|
*/
|
||||||
|
fun applyVisibility() {
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
viewModel.accounts.value = (app.mibAccounts + app.bmlAccounts + app.fahipayAccounts + app.mfaisaAccounts).filterVisibleAccounts()
|
||||||
|
viewModel.mibCards.value?.let { cards ->
|
||||||
|
val visible = cards.filterVisibleCards()
|
||||||
|
viewModel.mibCards.value = visible
|
||||||
|
CardsCache.save(this, visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches accounts, cards and financing for MIB profiles the user just unhid (hidden ones are skipped on refresh). */
|
||||||
|
fun fetchEnabledMibProfiles(loginId: String, profileIds: Set<String>) {
|
||||||
|
if (profileIds.isEmpty()) return
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
val loginTag = "mib_$loginId"
|
||||||
|
val enabled = (app.mibProfilesMap[loginId] ?: emptyList()).filter { it.profileId in profileIds }
|
||||||
|
binding.refreshIndicator.visibility = View.VISIBLE
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val fresh = withContext(Dispatchers.IO) {
|
||||||
|
val session = app.mibSessions[loginId]
|
||||||
|
if (session != null && enabled.isNotEmpty()) {
|
||||||
|
try {
|
||||||
|
val accounts = app.mibFlowFor(loginId).fetchAllProfiles(session, enabled, loginTag)
|
||||||
|
if (accounts.isNotEmpty()) {
|
||||||
|
return@withContext app.mibAccounts.filter { it.loginTag != loginTag || it.profileId !in profileIds } + accounts
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
// Session expired — log this one login in again (it now includes the unhidden profiles)
|
||||||
|
val store = CredentialStore(this@HomeActivity)
|
||||||
|
val creds = store.loadMibCredentials(loginId) ?: return@withContext null
|
||||||
|
try {
|
||||||
|
val flow = MibLoginFlow(store)
|
||||||
|
val accounts = flow.login(creds.username, creds.passwordHash, creds.otpSeed)
|
||||||
|
app.mibSessions[loginId] = flow.lastSession!!
|
||||||
|
app.mibProfilesMap[loginId] = flow.lastProfiles
|
||||||
|
app.mibLoginFlows[loginId] = flow
|
||||||
|
store.saveMibProfiles(loginId, flow.lastProfiles)
|
||||||
|
app.mibAccounts.filter { it.loginTag != loginTag } + accounts
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
binding.refreshIndicator.visibility = View.GONE
|
||||||
|
if (fresh == null) return@launch
|
||||||
|
app.mibAccounts = fresh
|
||||||
|
AccountCache.save(this@HomeActivity, fresh)
|
||||||
|
applyVisibility()
|
||||||
|
val session = app.mibSessions[loginId] ?: return@launch
|
||||||
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
|
refreshMibCards(loginId, session, profiles.filter { it.profileId in profileIds })
|
||||||
|
// Financing deals carry no profile id, so the login's visible profiles are fetched together
|
||||||
|
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetches accounts (and loan details / limits) for BML profiles the user just unhid. */
|
||||||
|
fun fetchEnabledBmlProfiles(loginId: String, profileIds: Set<String>) {
|
||||||
|
if (profileIds.isEmpty()) return
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
val store = CredentialStore(this)
|
||||||
|
val enabled = store.loadBmlProfiles(loginId).filter { it.profileId in profileIds }
|
||||||
|
if (enabled.isEmpty()) return
|
||||||
|
binding.refreshIndicator.visibility = View.VISIBLE
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val fetched = withContext(Dispatchers.IO) {
|
||||||
|
enabled.mapNotNull { profile ->
|
||||||
|
try { fetchBmlProfileAccounts(store, loginId, profile)?.let { profile.profileId to it } } catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding.refreshIndicator.visibility = View.GONE
|
||||||
|
if (fetched.isEmpty()) return@launch
|
||||||
|
val fetchedIds = fetched.map { it.first }.toSet()
|
||||||
|
val loginTag = "bml_$loginId"
|
||||||
|
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != loginTag || it.profileId !in fetchedIds } +
|
||||||
|
fetched.flatMap { it.second }
|
||||||
|
AccountCache.saveBml(this@HomeActivity, loginId, app.bmlAccounts.filter { it.loginTag == loginTag })
|
||||||
|
applyVisibility()
|
||||||
|
fetchedIds.forEach { id -> app.bmlSessions[id]?.let { refreshBmlLimits(it) } }
|
||||||
|
refreshBmlLoanDetails()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches one BML profile's accounts with its saved session, refreshing the token when expired.
|
||||||
|
* Returns null when the profile has no saved session; throws on network/server errors.
|
||||||
|
*/
|
||||||
|
private fun fetchBmlProfileAccounts(store: CredentialStore, loginId: String, profile: BmlProfile): List<BankAccount>? {
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
val saved = store.loadBmlProfileSession(profile.profileId) ?: return null
|
||||||
|
val refreshToken = store.loadBmlProfileRefreshToken(profile.profileId)
|
||||||
|
val expiresAt = store.loadBmlProfileExpiresAt(profile.profileId)
|
||||||
|
val tokenKnownExpired = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
|
||||||
|
val bmlClient = BmlAccountClient()
|
||||||
|
|
||||||
|
fun fetchWithSession(session: BmlSession): List<BankAccount> {
|
||||||
|
bmlClient.checkProfile(session)
|
||||||
|
val accounts = bmlClient.fetchAccounts(session, "bml_$loginId", profile.name, profile.profileId)
|
||||||
|
app.bmlSessions[profile.profileId] = session
|
||||||
|
return accounts
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tryRefresh(): List<BankAccount> {
|
||||||
|
if (refreshToken == null) throw Exception("No refresh token")
|
||||||
|
val oldSession = BmlSession(saved.first, saved.second, refreshToken)
|
||||||
|
val newSession = app.bmlFlowFor(loginId).refreshSession(oldSession)
|
||||||
|
store.saveBmlProfileSession(profile.profileId, newSession.accessToken, newSession.deviceId)
|
||||||
|
if (newSession.refreshToken.isNotBlank())
|
||||||
|
store.saveBmlProfileRefreshToken(profile.profileId, newSession.refreshToken)
|
||||||
|
if (newSession.expiresAt > 0)
|
||||||
|
store.saveBmlProfileExpiresAt(profile.profileId, newSession.expiresAt)
|
||||||
|
return fetchWithSession(newSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (tokenKnownExpired) {
|
||||||
|
tryRefresh()
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
fetchWithSession(BmlSession(saved.first, saved.second))
|
||||||
|
} catch (_: AuthExpiredException) {
|
||||||
|
tryRefresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun refreshBmlLimits(session: BmlSession) {
|
private fun refreshBmlLimits(session: BmlSession) {
|
||||||
@@ -992,14 +1351,14 @@ fun applyNavLabelVisibility() {
|
|||||||
val fresh = withContext(Dispatchers.IO) {
|
val fresh = withContext(Dispatchers.IO) {
|
||||||
val sess = app.bmlSessionFor(src) ?: return@withContext null
|
val sess = app.bmlSessionFor(src) ?: return@withContext null
|
||||||
try {
|
try {
|
||||||
val accounts = BmlAccountClient().fetchAccounts(sess, src.loginTag)
|
val accounts = BmlAccountClient().fetchAccounts(sess, src.loginTag, src.profileName, src.profileId)
|
||||||
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
|
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
|
||||||
val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag }
|
val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag || it.profileId != src.profileId }
|
||||||
app.bmlAccounts = otherBml + accounts
|
app.bmlAccounts = otherBml + accounts
|
||||||
accounts
|
accounts
|
||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
} ?: return@launch
|
} ?: return@launch
|
||||||
val otherAccounts = current.filter { it.bank != "BML" || it.loginTag != src.loginTag }
|
val otherAccounts = current.filter { it.bank != "BML" || it.loginTag != src.loginTag || it.profileId != src.profileId }
|
||||||
viewModel.accounts.postValue(otherAccounts + fresh)
|
viewModel.accounts.postValue(otherAccounts + fresh)
|
||||||
} else {
|
} else {
|
||||||
val loginId = src.loginTag.removePrefix("mib_")
|
val loginId = src.loginTag.removePrefix("mib_")
|
||||||
@@ -1066,7 +1425,7 @@ fun applyNavLabelVisibility() {
|
|||||||
val app = application as BasedBankApp
|
val app = application as BasedBankApp
|
||||||
for ((loginId, session) in app.mibSessions) {
|
for ((loginId, session) in app.mibSessions) {
|
||||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
refreshMibCards(loginId, session, profiles)
|
refreshMibCards(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1082,7 +1441,7 @@ fun applyNavLabelVisibility() {
|
|||||||
for (profile in profiles) {
|
for (profile in profiles) {
|
||||||
try {
|
try {
|
||||||
flow.switchProfile(session, profile)
|
flow.switchProfile(session, profile)
|
||||||
for (card in client.fetchCards(session, "mib_$loginId")) {
|
for (card in client.fetchCards(session, "mib_$loginId", profile.profileId)) {
|
||||||
if (seen.add(card.cardId)) result += card
|
if (seen.add(card.cardId)) result += card
|
||||||
}
|
}
|
||||||
} catch (_: Exception) { }
|
} catch (_: Exception) { }
|
||||||
@@ -1091,7 +1450,8 @@ fun applyNavLabelVisibility() {
|
|||||||
}
|
}
|
||||||
if (cards.isNotEmpty()) {
|
if (cards.isNotEmpty()) {
|
||||||
val existing = viewModel.mibCards.value?.toMutableList() ?: mutableListOf()
|
val existing = viewModel.mibCards.value?.toMutableList() ?: mutableListOf()
|
||||||
existing.removeAll { it.loginTag == "mib_$loginId" }
|
val fetchedIds = profiles.map { it.profileId }.toSet()
|
||||||
|
existing.removeAll { it.loginTag == "mib_$loginId" && (it.profileId in fetchedIds || it.profileId.isEmpty()) }
|
||||||
existing += cards
|
existing += cards
|
||||||
viewModel.mibCards.postValue(existing)
|
viewModel.mibCards.postValue(existing)
|
||||||
CardsCache.save(this@HomeActivity, existing)
|
CardsCache.save(this@HomeActivity, existing)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
||||||
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
@@ -9,14 +10,21 @@ import sh.sar.basedbank.api.models.BankContact
|
|||||||
import sh.sar.basedbank.api.models.BankContactCategory
|
import sh.sar.basedbank.api.models.BankContactCategory
|
||||||
import sh.sar.basedbank.api.mib.MibCard
|
import sh.sar.basedbank.api.mib.MibCard
|
||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
|
||||||
sealed class CardItem {
|
sealed class CardItem {
|
||||||
data class Mib(val card: MibCard) : CardItem()
|
abstract val loginTag: String
|
||||||
data class Bml(val account: BankAccount) : CardItem()
|
data class Mib(val card: MibCard) : CardItem() { override val loginTag get() = card.loginTag }
|
||||||
|
data class Bml(val account: BankAccount) : CardItem() { override val loginTag get() = account.loginTag }
|
||||||
}
|
}
|
||||||
|
|
||||||
class HomeViewModel : ViewModel() {
|
class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||||
val accounts = MutableLiveData<List<BankAccount>>(emptyList())
|
/** Always kept in the user's login order (Settings > Logins), so every screen lists accounts the same way. */
|
||||||
|
val accounts: MutableLiveData<List<BankAccount>> = object : MutableLiveData<List<BankAccount>>(emptyList()) {
|
||||||
|
override fun setValue(value: List<BankAccount>?) {
|
||||||
|
super.setValue(value?.let { list -> loginRank().let { rank -> list.sortedBy { rank(it.loginTag) } } })
|
||||||
|
}
|
||||||
|
}
|
||||||
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
|
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
|
||||||
/** BML loan details keyed by account internalId. */
|
/** BML loan details keyed by account internalId. */
|
||||||
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
|
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
|
||||||
@@ -26,7 +34,19 @@ class HomeViewModel : ViewModel() {
|
|||||||
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
||||||
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
||||||
|
|
||||||
val mibCards = MutableLiveData<List<MibCard>?>(null)
|
val mibCards: MutableLiveData<List<MibCard>?> = object : MutableLiveData<List<MibCard>?>(null) {
|
||||||
|
override fun setValue(value: List<MibCard>?) {
|
||||||
|
super.setValue(value?.let { list -> loginRank().let { rank -> list.sortedBy { rank(it.loginTag) } } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loginRank(): (String) -> Int = CredentialStore(getApplication()).loginRank()
|
||||||
|
|
||||||
|
/** Re-applies the login order after the user changes it. */
|
||||||
|
fun resortByLoginOrder() {
|
||||||
|
accounts.value = accounts.value
|
||||||
|
mibCards.value = mibCards.value
|
||||||
|
}
|
||||||
|
|
||||||
val hideAmounts = MutableLiveData<Boolean>(false)
|
val hideAmounts = MutableLiveData<Boolean>(false)
|
||||||
|
|
||||||
@@ -36,4 +56,7 @@ class HomeViewModel : ViewModel() {
|
|||||||
* for HTTP 5xx server errors from specific banks.
|
* for HTTP 5xx server errors from specific banks.
|
||||||
*/
|
*/
|
||||||
val connectivityErrors = MutableLiveData<Set<String>>(emptySet())
|
val connectivityErrors = MutableLiveData<Set<String>>(emptySet())
|
||||||
|
|
||||||
|
/** The Transfer screen's form, kept here so tab switches and recreation don't lose it. */
|
||||||
|
var transferDraft = sh.sar.basedbank.ui.home.transfer.TransferDraft()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,20 @@ import sh.sar.basedbank.R
|
|||||||
|
|
||||||
object NavCustomization {
|
object NavCustomization {
|
||||||
|
|
||||||
|
const val NAV_MODE_DRAWER = "drawer"
|
||||||
|
const val NAV_MODE_BOTTOM = "bottom"
|
||||||
|
const val NAV_MODE_CIRCULAR = "circular"
|
||||||
|
|
||||||
|
fun getNavMode(prefs: SharedPreferences): String {
|
||||||
|
val explicit = prefs.getString("nav_mode", null)
|
||||||
|
if (explicit != null) return explicit
|
||||||
|
return if (prefs.getBoolean("bottom_nav", false)) NAV_MODE_BOTTOM else NAV_MODE_DRAWER
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveNavMode(prefs: SharedPreferences, mode: String) {
|
||||||
|
prefs.edit().putString("nav_mode", mode).apply()
|
||||||
|
}
|
||||||
|
|
||||||
data class NavItemDef(
|
data class NavItemDef(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
val key: String,
|
val key: String,
|
||||||
@@ -62,8 +76,31 @@ object NavCustomization {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Items that belong in the "More" screen — those not occupying a bottom nav slot. */
|
/** Items that belong in the "More" screen — those not occupying a bottom nav slot. */
|
||||||
|
fun getCircularSlots(prefs: SharedPreferences): List<Int> = listOf(
|
||||||
|
keyToId(prefs.getString("circular_slot_1_key", null), R.id.nav_transfer),
|
||||||
|
keyToId(prefs.getString("circular_slot_2_key", null), R.id.nav_pay_with_card),
|
||||||
|
keyToId(prefs.getString("circular_slot_3_key", null), R.id.nav_contacts),
|
||||||
|
keyToId(prefs.getString("circular_slot_4_key", null), R.id.nav_accounts),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun saveCircularSlots(prefs: SharedPreferences, slots: List<Int>) {
|
||||||
|
prefs.edit()
|
||||||
|
.putString("circular_slot_1_key", idToKey(slots[0]) ?: "nav_transfer")
|
||||||
|
.putString("circular_slot_2_key", idToKey(slots[1]) ?: "nav_pay_with_card")
|
||||||
|
.putString("circular_slot_3_key", idToKey(slots[2]) ?: "nav_contacts")
|
||||||
|
.putString("circular_slot_4_key", idToKey(slots[3]) ?: "nav_accounts")
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
|
||||||
fun getMoreItems(prefs: SharedPreferences): List<NavItemDef> {
|
fun getMoreItems(prefs: SharedPreferences): List<NavItemDef> {
|
||||||
|
if (getNavMode(prefs) == NAV_MODE_CIRCULAR) return getCircularMoreItems(prefs)
|
||||||
val slots = getSlots(prefs).toSet()
|
val slots = getSlots(prefs).toSet()
|
||||||
return ALL_SWAPPABLE.filter { it.id !in slots }
|
return ALL_SWAPPABLE.filter { it.id !in slots }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Items shown in More when circular nav is active — everything not in the saved wheel slots. */
|
||||||
|
private fun getCircularMoreItems(prefs: SharedPreferences): List<NavItemDef> {
|
||||||
|
val slotIds = getCircularSlots(prefs).toSet()
|
||||||
|
return ALL_SWAPPABLE.filter { it.id !in slotIds }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,657 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.app.Dialog
|
||||||
|
import android.content.res.ColorStateList
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ProgressBar
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import androidx.viewpager2.widget.ViewPager2
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.android.material.tabs.TabLayout
|
||||||
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.bml.BmlNotificationsClient
|
||||||
|
import sh.sar.basedbank.api.mib.MibActivityHistoryClient
|
||||||
|
import sh.sar.basedbank.util.NotificationsCache
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
// ── Sealed list item for date-grouped lists ───────────────────────────────────
|
||||||
|
private sealed class NotifListItem {
|
||||||
|
data class Header(val label: String) : NotifListItem()
|
||||||
|
data class Entry(val n: AppNotification) : NotifListItem()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val headerSdf = SimpleDateFormat("EEEE, d MMMM yyyy", Locale.US)
|
||||||
|
private val dateKeySdf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||||
|
|
||||||
|
private fun toGroupedList(notifications: List<AppNotification>): List<NotifListItem> {
|
||||||
|
val result = mutableListOf<NotifListItem>()
|
||||||
|
var lastKey = ""
|
||||||
|
for (n in notifications) {
|
||||||
|
val key = dateKeySdf.format(Date(n.timestampMs))
|
||||||
|
if (key != lastKey) {
|
||||||
|
result.add(NotifListItem.Header(headerSdf.format(Date(n.timestampMs))))
|
||||||
|
lastKey = key
|
||||||
|
}
|
||||||
|
result.add(NotifListItem.Entry(n))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private class NotifDiff(
|
||||||
|
private val old: List<NotifListItem>,
|
||||||
|
private val new: List<NotifListItem>
|
||||||
|
) : DiffUtil.Callback() {
|
||||||
|
override fun getOldListSize() = old.size
|
||||||
|
override fun getNewListSize() = new.size
|
||||||
|
override fun areItemsTheSame(oldPos: Int, newPos: Int): Boolean {
|
||||||
|
val o = old[oldPos]; val n = new[newPos]
|
||||||
|
return when {
|
||||||
|
o is NotifListItem.Header && n is NotifListItem.Header -> o.label == n.label
|
||||||
|
o is NotifListItem.Entry && n is NotifListItem.Entry -> o.n.id == n.n.id
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun areContentsTheSame(oldPos: Int, newPos: Int) = old[oldPos] == new[newPos]
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotificationsSheetFragment : BottomSheetDialogFragment() {
|
||||||
|
|
||||||
|
var onUnreadCountChanged: ((hasUnread: Boolean) -> Unit)? = null
|
||||||
|
|
||||||
|
private val allNotifications = mutableListOf<AppNotification>()
|
||||||
|
|
||||||
|
private val bmlNextPage = mutableMapOf<String, Int>()
|
||||||
|
private val bmlDone = mutableMapOf<String, Boolean>()
|
||||||
|
private val mibNextStart = mutableMapOf<String, Int>()
|
||||||
|
private val mibDone = mutableMapOf<String, Boolean>()
|
||||||
|
|
||||||
|
private var isLoadingMore = false
|
||||||
|
private var mediator: TabLayoutMediator? = null
|
||||||
|
|
||||||
|
private val tabAdapters = arrayOfNulls<NotifPageAdapter>(3)
|
||||||
|
private val tabLabels = listOf("All", "Alerts", "Information")
|
||||||
|
private val tabGroupFilters = listOf<String?>(null, "ALERTS", "INFORMATION")
|
||||||
|
|
||||||
|
private lateinit var viewPager: ViewPager2
|
||||||
|
private lateinit var btnMarkAllRead: TextView
|
||||||
|
|
||||||
|
private val app get() = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
|
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
|
val d = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
|
||||||
|
d.setOnShowListener {
|
||||||
|
val sheet = d.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)!!
|
||||||
|
BottomSheetBehavior.from(sheet).apply {
|
||||||
|
state = BottomSheetBehavior.STATE_EXPANDED
|
||||||
|
skipCollapsed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
||||||
|
inflater.inflate(R.layout.sheet_notifications, container, false)
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val tabLayout = view.findViewById<TabLayout>(R.id.notifTabs)
|
||||||
|
viewPager = view.findViewById(R.id.notifPager)
|
||||||
|
btnMarkAllRead = view.findViewById(R.id.btnMarkAllRead)
|
||||||
|
|
||||||
|
tabAdapters[0] = NotifPageAdapter(null)
|
||||||
|
tabAdapters[1] = NotifPageAdapter("ALERTS")
|
||||||
|
tabAdapters[2] = NotifPageAdapter("INFORMATION")
|
||||||
|
|
||||||
|
viewPager.adapter = PageAdapter()
|
||||||
|
viewPager.offscreenPageLimit = 2
|
||||||
|
|
||||||
|
mediator = TabLayoutMediator(tabLayout, viewPager) { tab, pos ->
|
||||||
|
tab.text = tabLabels[pos]
|
||||||
|
}.also { it.attach() }
|
||||||
|
|
||||||
|
btnMarkAllRead.setOnClickListener { markAllRead() }
|
||||||
|
|
||||||
|
loadFromCache()
|
||||||
|
refreshFromNetwork()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
mediator?.detach()
|
||||||
|
mediator = null
|
||||||
|
super.onDestroyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data loading ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun loadFromCache() {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val readIds = NotificationsCache.getMibReadIds(ctx)
|
||||||
|
val cached = mutableListOf<AppNotification>()
|
||||||
|
app.bmlSessions.forEach { (loginId, _) ->
|
||||||
|
cached.addAll(NotificationsCache.loadBml(ctx, loginId))
|
||||||
|
}
|
||||||
|
app.mibSessions.forEach { (loginId, _) ->
|
||||||
|
cached.addAll(NotificationsCache.loadMib(ctx, loginId, readIds))
|
||||||
|
}
|
||||||
|
if (cached.isNotEmpty()) {
|
||||||
|
mergeInto(allNotifications, cached)
|
||||||
|
refreshAdapters()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setSpinner(show: Boolean) {
|
||||||
|
tabAdapters.forEach { it?.showLoadingSpinner = show }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshFromNetwork() {
|
||||||
|
val bmlSessions = app.bmlSessions.toMap()
|
||||||
|
val mibSessions = app.mibSessions.toMap()
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
setSpinner(true)
|
||||||
|
val bmlClient = BmlNotificationsClient()
|
||||||
|
bmlSessions.forEach { (loginId, session) ->
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
bmlClient.fetchNotifications(session, loginId, page = 1)
|
||||||
|
}
|
||||||
|
if (result.items.isNotEmpty() && isAdded) {
|
||||||
|
allNotifications.removeAll { it.bank == "BML" && it.loginId == loginId }
|
||||||
|
allNotifications.addAll(result.items)
|
||||||
|
allNotifications.sortByDescending { it.timestampMs }
|
||||||
|
bmlNextPage[loginId] = 2
|
||||||
|
bmlDone[loginId] = result.items.size >= result.total
|
||||||
|
NotificationsCache.saveBml(requireContext(), loginId, result.items)
|
||||||
|
refreshAdapters()
|
||||||
|
broadcastUnread()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val mibClient = MibActivityHistoryClient()
|
||||||
|
mibSessions.forEach { (loginId, session) ->
|
||||||
|
val cachedIds = allNotifications
|
||||||
|
.filter { it.bank == "MIB" && it.loginId == loginId }
|
||||||
|
.map { it.id }.toSet()
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
mibClient.fetchActivity(session, loginId, 1, 100)
|
||||||
|
}
|
||||||
|
if (isAdded) {
|
||||||
|
val readIds = NotificationsCache.getMibReadIds(requireContext())
|
||||||
|
val hasOverlap = cachedIds.isNotEmpty() && result.items.any { it.id in cachedIds }
|
||||||
|
val newItems = result.items
|
||||||
|
.filter { it.id !in cachedIds }
|
||||||
|
.map { it.copy(isRead = it.id in readIds) }
|
||||||
|
if (newItems.isNotEmpty()) {
|
||||||
|
allNotifications.addAll(newItems)
|
||||||
|
allNotifications.sortByDescending { it.timestampMs }
|
||||||
|
val allForLogin = allNotifications.filter { it.bank == "MIB" && it.loginId == loginId }
|
||||||
|
NotificationsCache.saveMib(requireContext(), loginId, allForLogin)
|
||||||
|
refreshAdapters()
|
||||||
|
broadcastUnread()
|
||||||
|
}
|
||||||
|
mibNextStart[loginId] = result.nextStart
|
||||||
|
mibDone[loginId] = hasOverlap || result.nextStart > result.totalCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAdded) setSpinner(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadMore() {
|
||||||
|
if (isLoadingMore) return
|
||||||
|
val bmlSessions = app.bmlSessions.toMap()
|
||||||
|
val mibSessions = app.mibSessions.toMap()
|
||||||
|
val anyLeft = bmlSessions.keys.any { bmlDone[it] != true } ||
|
||||||
|
mibSessions.keys.any { mibDone[it] != true }
|
||||||
|
if (!anyLeft) return
|
||||||
|
|
||||||
|
isLoadingMore = true
|
||||||
|
setSpinner(true)
|
||||||
|
lifecycleScope.launch {
|
||||||
|
val bmlClient = BmlNotificationsClient()
|
||||||
|
bmlSessions.forEach { (loginId, session) ->
|
||||||
|
if (bmlDone[loginId] == true) return@forEach
|
||||||
|
val page = bmlNextPage[loginId] ?: 2
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
bmlClient.fetchNotifications(session, loginId, page = page)
|
||||||
|
}
|
||||||
|
if (result.items.isNotEmpty() && isAdded) {
|
||||||
|
allNotifications.addAll(result.items.filter { n -> allNotifications.none { it.id == n.id } })
|
||||||
|
allNotifications.sortByDescending { it.timestampMs }
|
||||||
|
bmlNextPage[loginId] = page + 1
|
||||||
|
bmlDone[loginId] = allNotifications.count { it.bank == "BML" && it.loginId == loginId } >= result.total
|
||||||
|
val allForLogin = allNotifications.filter { it.bank == "BML" && it.loginId == loginId }
|
||||||
|
NotificationsCache.saveBml(requireContext(), loginId, allForLogin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val mibClient = MibActivityHistoryClient()
|
||||||
|
mibSessions.forEach { (loginId, session) ->
|
||||||
|
if (mibDone[loginId] == true) return@forEach
|
||||||
|
while (mibDone[loginId] != true && isAdded) {
|
||||||
|
val start = mibNextStart[loginId] ?: 101
|
||||||
|
val cachedIds = allNotifications
|
||||||
|
.filter { it.bank == "MIB" && it.loginId == loginId }
|
||||||
|
.map { it.id }.toSet()
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
mibClient.fetchActivity(session, loginId, start, start + 99)
|
||||||
|
}
|
||||||
|
if (result.rawCount == 0) break
|
||||||
|
val readIds = NotificationsCache.getMibReadIds(requireContext())
|
||||||
|
val newItems = result.items
|
||||||
|
.filter { it.id !in cachedIds }
|
||||||
|
.map { it.copy(isRead = it.id in readIds) }
|
||||||
|
if (newItems.isNotEmpty()) {
|
||||||
|
allNotifications.addAll(newItems)
|
||||||
|
allNotifications.sortByDescending { it.timestampMs }
|
||||||
|
val allForLogin = allNotifications.filter { it.bank == "MIB" && it.loginId == loginId }
|
||||||
|
NotificationsCache.saveMib(requireContext(), loginId, allForLogin)
|
||||||
|
}
|
||||||
|
mibNextStart[loginId] = result.nextStart
|
||||||
|
mibDone[loginId] = result.nextStart > result.totalCount
|
||||||
|
if (newItems.isNotEmpty()) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingMore = false
|
||||||
|
if (isAdded) {
|
||||||
|
setSpinner(false)
|
||||||
|
refreshAdapters()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mark all read ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun markAllRead() {
|
||||||
|
val bmlSessions = app.bmlSessions.toMap()
|
||||||
|
val mibIds = allNotifications.filter { it.bank == "MIB" && !it.isRead }.map { it.id }
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
var bmlOk = true
|
||||||
|
bmlSessions.forEach { (_, session) ->
|
||||||
|
val ok = withContext(Dispatchers.IO) { BmlNotificationsClient().markAllRead(session) }
|
||||||
|
if (!ok) bmlOk = false
|
||||||
|
}
|
||||||
|
if (mibIds.isNotEmpty()) NotificationsCache.addMibReadIds(requireContext(), mibIds)
|
||||||
|
|
||||||
|
val updated = allNotifications.map { it.copy(isRead = true) }
|
||||||
|
allNotifications.clear()
|
||||||
|
allNotifications.addAll(updated)
|
||||||
|
refreshAdapters()
|
||||||
|
broadcastUnread()
|
||||||
|
|
||||||
|
if (isAdded) {
|
||||||
|
val msg = if (bmlOk) "All notifications marked as read"
|
||||||
|
else "Marked read locally — some accounts had a network error"
|
||||||
|
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun mergeInto(target: MutableList<AppNotification>, incoming: List<AppNotification>) {
|
||||||
|
val existingIds = target.map { it.id }.toSet()
|
||||||
|
target.addAll(incoming.filter { it.id !in existingIds })
|
||||||
|
target.sortByDescending { it.timestampMs }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshAdapters() {
|
||||||
|
tabGroupFilters.forEachIndexed { i, filter ->
|
||||||
|
val filtered = if (filter == null) allNotifications
|
||||||
|
else allNotifications.filter { it.group == filter }
|
||||||
|
tabAdapters[i]?.update(filtered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun broadcastUnread() {
|
||||||
|
onUnreadCountChanged?.invoke(allNotifications.any { !it.isRead })
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onNotificationTapped(item: AppNotification) {
|
||||||
|
val idx = allNotifications.indexOfFirst { it.id == item.id }
|
||||||
|
if (idx >= 0 && !allNotifications[idx].isRead) {
|
||||||
|
allNotifications[idx] = allNotifications[idx].copy(isRead = true)
|
||||||
|
if (item.bank == "MIB") NotificationsCache.addMibReadIds(requireContext(), listOf(item.id))
|
||||||
|
refreshAdapters()
|
||||||
|
broadcastUnread()
|
||||||
|
}
|
||||||
|
val detail = item.detailFields.joinToString("\n\n") { (k, v) -> "$k\n$v" }
|
||||||
|
MaterialAlertDialogBuilder(requireActivity())
|
||||||
|
.setTitle(item.title)
|
||||||
|
.setMessage(detail.ifBlank { item.message })
|
||||||
|
.setPositiveButton("OK", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ViewPager2 page adapter ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private inner class PageAdapter : RecyclerView.Adapter<PageAdapter.VH>() {
|
||||||
|
inner class VH(val rv: RecyclerView) : RecyclerView.ViewHolder(rv)
|
||||||
|
|
||||||
|
override fun getItemCount() = 3
|
||||||
|
override fun getItemViewType(position: Int) = position
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||||
|
val rv = RecyclerView(parent.context).apply {
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
)
|
||||||
|
layoutManager = LinearLayoutManager(context)
|
||||||
|
adapter = tabAdapters[viewType]
|
||||||
|
addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||||
|
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
|
||||||
|
val lm = rv.layoutManager as LinearLayoutManager
|
||||||
|
if (lm.findLastVisibleItemPosition() >= lm.itemCount - 4) loadMore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return VH(rv)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: VH, position: Int) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-tab list adapter ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private inner class NotifPageAdapter(private val groupFilter: String?) :
|
||||||
|
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
|
private val displayItems = mutableListOf<NotifListItem>()
|
||||||
|
|
||||||
|
var showLoadingSpinner: Boolean = false
|
||||||
|
set(value) {
|
||||||
|
if (field == value) return
|
||||||
|
field = value
|
||||||
|
if (displayItems.isEmpty()) {
|
||||||
|
notifyItemChanged(0)
|
||||||
|
} else if (value) {
|
||||||
|
notifyItemInserted(displayItems.size)
|
||||||
|
} else {
|
||||||
|
notifyItemRemoved(displayItems.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun update(filtered: List<AppNotification>) {
|
||||||
|
val newItems = toGroupedList(filtered)
|
||||||
|
val diff = DiffUtil.calculateDiff(NotifDiff(displayItems.toList(), newItems))
|
||||||
|
displayItems.clear()
|
||||||
|
displayItems.addAll(newItems)
|
||||||
|
diff.dispatchUpdatesTo(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int {
|
||||||
|
if (displayItems.isEmpty()) return 1
|
||||||
|
return displayItems.size + if (showLoadingSpinner) 1 else 0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemViewType(position: Int): Int {
|
||||||
|
if (displayItems.isEmpty()) return if (showLoadingSpinner) 3 else 2
|
||||||
|
if (showLoadingSpinner && position == displayItems.size) return 3
|
||||||
|
return when (displayItems[position]) {
|
||||||
|
is NotifListItem.Header -> 0
|
||||||
|
is NotifListItem.Entry -> 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
|
||||||
|
when (viewType) {
|
||||||
|
0 -> HeaderVH(buildHeaderView(parent.context))
|
||||||
|
1 -> ItemVH(buildRowView(parent.context))
|
||||||
|
3 -> SpinnerVH(buildSpinnerView(parent.context))
|
||||||
|
else -> EmptyVH(buildEmptyView(parent.context))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
when (holder) {
|
||||||
|
is HeaderVH -> holder.bind((displayItems[position] as NotifListItem.Header).label)
|
||||||
|
is ItemVH -> holder.bind((displayItems[position] as NotifListItem.Entry).n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Date header ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
inner class HeaderVH(private val tv: TextView) : RecyclerView.ViewHolder(tv) {
|
||||||
|
fun bind(label: String) { tv.text = label }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildHeaderView(ctx: android.content.Context): TextView {
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
return TextView(ctx).apply {
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
|
||||||
|
setTextColor(MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimary, Color.CYAN))
|
||||||
|
setPadding((16 * dp).toInt(), (20 * dp).toInt(), (16 * dp).toInt(), (6 * dp).toInt())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty state ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
inner class EmptyVH(v: View) : RecyclerView.ViewHolder(v)
|
||||||
|
|
||||||
|
private fun buildEmptyView(ctx: android.content.Context): View {
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
return LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
(300 * dp).toInt()
|
||||||
|
)
|
||||||
|
addView(ImageView(ctx).apply {
|
||||||
|
setImageResource(R.drawable.ic_bell_read)
|
||||||
|
val s = (48 * dp).toInt()
|
||||||
|
layoutParams = LinearLayout.LayoutParams(s, s).apply {
|
||||||
|
gravity = Gravity.CENTER_HORIZONTAL
|
||||||
|
bottomMargin = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
alpha = 0.35f
|
||||||
|
})
|
||||||
|
addView(TextView(ctx).apply {
|
||||||
|
text = "No notifications"
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||||
|
alpha = 0.5f
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading spinner ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
inner class SpinnerVH(v: View) : RecyclerView.ViewHolder(v)
|
||||||
|
|
||||||
|
private fun buildSpinnerView(ctx: android.content.Context): View {
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val pad = (16 * dp).toInt()
|
||||||
|
val size = (28 * dp).toInt()
|
||||||
|
return LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
setPadding(pad, pad, pad, pad)
|
||||||
|
addView(ProgressBar(ctx).apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(size, size)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notification row ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
inner class ItemVH(v: View) : RecyclerView.ViewHolder(v) {
|
||||||
|
val iconBg: View = v.findViewWithTag("iconBg")
|
||||||
|
val iconIv: ImageView = v.findViewWithTag("icon")
|
||||||
|
val unreadBadge: View = v.findViewWithTag("badge")
|
||||||
|
val titleTv: TextView = v.findViewWithTag("title")
|
||||||
|
val messageTv: TextView = v.findViewWithTag("message")
|
||||||
|
val bankBadge: TextView = v.findViewWithTag("bank")
|
||||||
|
|
||||||
|
fun bind(item: AppNotification) {
|
||||||
|
titleTv.text = item.title
|
||||||
|
messageTv.text = item.message
|
||||||
|
bankBadge.text = item.bank
|
||||||
|
unreadBadge.isVisible = !item.isRead
|
||||||
|
|
||||||
|
val (iconRes, colorHex) = iconAndColor(item)
|
||||||
|
iconIv.setImageResource(iconRes)
|
||||||
|
iconIv.imageTintList = ColorStateList.valueOf(Color.parseColor(colorHex))
|
||||||
|
(iconBg.background as? GradientDrawable)
|
||||||
|
?.setColor(Color.parseColor(colorHex.replace("#", "#22")))
|
||||||
|
|
||||||
|
itemView.alpha = if (item.isRead) 0.65f else 1f
|
||||||
|
itemView.setOnClickListener { onNotificationTapped(item) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildRowView(ctx: android.content.Context): View {
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val surfaceColor = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSurface, Color.BLACK)
|
||||||
|
|
||||||
|
return LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||||
|
background = ta.getDrawable(0); ta.recycle()
|
||||||
|
isClickable = true; isFocusable = true
|
||||||
|
setPadding((16 * dp).toInt(), (12 * dp).toInt(), (16 * dp).toInt(), (12 * dp).toInt())
|
||||||
|
|
||||||
|
// Icon circle + badge overlay
|
||||||
|
val frameSize = (44 * dp).toInt()
|
||||||
|
val iconFrame = FrameLayout(ctx).apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(frameSize, frameSize).apply {
|
||||||
|
marginEnd = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Circle background (fills the frame)
|
||||||
|
val circleSize = (40 * dp).toInt()
|
||||||
|
iconFrame.addView(View(ctx).apply {
|
||||||
|
tag = "iconBg"
|
||||||
|
layoutParams = FrameLayout.LayoutParams(circleSize, circleSize, Gravity.CENTER)
|
||||||
|
background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.OVAL
|
||||||
|
setColor(Color.parseColor("#33FFFFFF"))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Icon
|
||||||
|
val iconSize = (22 * dp).toInt()
|
||||||
|
iconFrame.addView(ImageView(ctx).apply {
|
||||||
|
tag = "icon"
|
||||||
|
layoutParams = FrameLayout.LayoutParams(iconSize, iconSize, Gravity.CENTER)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Unread badge — bottom-right corner
|
||||||
|
val badgeSize = (12 * dp).toInt()
|
||||||
|
iconFrame.addView(View(ctx).apply {
|
||||||
|
tag = "badge"
|
||||||
|
layoutParams = FrameLayout.LayoutParams(badgeSize, badgeSize, Gravity.BOTTOM or Gravity.END)
|
||||||
|
background = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.OVAL
|
||||||
|
setColor(Color.parseColor("#EF5350"))
|
||||||
|
setStroke((2 * dp).toInt(), surfaceColor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
addView(iconFrame)
|
||||||
|
|
||||||
|
// Text column
|
||||||
|
val textCol = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title + bank badge row
|
||||||
|
val titleRow = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
titleRow.addView(TextView(ctx).apply {
|
||||||
|
tag = "title"
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||||
|
setTypeface(null, Typeface.BOLD)
|
||||||
|
maxLines = 1
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
|
})
|
||||||
|
titleRow.addView(TextView(ctx).apply {
|
||||||
|
tag = "bank"
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelSmall)
|
||||||
|
alpha = 0.55f
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
).apply { marginStart = (6 * dp).toInt() }
|
||||||
|
})
|
||||||
|
|
||||||
|
textCol.addView(titleRow)
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
tag = "message"
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||||
|
alpha = 0.7f
|
||||||
|
maxLines = 2
|
||||||
|
})
|
||||||
|
addView(textCol)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun iconAndColor(item: AppNotification): Pair<Int, String> {
|
||||||
|
if (item.bank == "MIB") return when {
|
||||||
|
item.title.contains("Transfer", ignoreCase = true) ||
|
||||||
|
item.title.contains("Payment", ignoreCase = true) -> R.drawable.ic_send to "#4CAF50"
|
||||||
|
item.title.contains("Log in", ignoreCase = true) -> R.drawable.ic_lock_open to "#2196F3"
|
||||||
|
else -> R.drawable.ic_receipt_check to "#9C27B0"
|
||||||
|
}
|
||||||
|
return when {
|
||||||
|
item.group == "INFORMATION" -> R.drawable.ic_receipt_check to "#2196F3"
|
||||||
|
item.title.contains("Received", ignoreCase = true) ||
|
||||||
|
item.title.contains("Sent", ignoreCase = true) ||
|
||||||
|
item.title.contains("Transfer", ignoreCase = true) ||
|
||||||
|
item.title.contains("Payment", ignoreCase = true) ||
|
||||||
|
item.title.contains("Paid", ignoreCase = true) ||
|
||||||
|
item.title.contains("Funds", ignoreCase = true) -> R.drawable.ic_send to "#4CAF50"
|
||||||
|
else -> R.drawable.ic_lock to "#EF5350"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,25 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
|
import android.content.ClipDescription
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.DialogInterface
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.PersistableBundle
|
||||||
|
import android.text.Editable
|
||||||
|
import android.text.TextWatcher
|
||||||
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.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.widget.PopupMenu
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
@@ -18,13 +29,23 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.zxing.BarcodeFormat
|
||||||
|
import com.google.zxing.EncodeHintType
|
||||||
|
import com.google.zxing.qrcode.QRCodeWriter
|
||||||
|
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||||
import sh.sar.basedbank.api.mib.MibProfileClient
|
import sh.sar.basedbank.api.mib.MibProfileClient
|
||||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||||
|
import sh.sar.basedbank.databinding.DialogOtpExportSeedBinding
|
||||||
|
import sh.sar.basedbank.databinding.DialogOtpUpdateSeedBinding
|
||||||
import sh.sar.basedbank.databinding.FragmentOtpBinding
|
import sh.sar.basedbank.databinding.FragmentOtpBinding
|
||||||
import sh.sar.basedbank.databinding.ItemOtpCardBinding
|
import sh.sar.basedbank.databinding.ItemOtpCardBinding
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.OtpauthParser
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
|
|
||||||
class OtpFragment : Fragment() {
|
class OtpFragment : Fragment() {
|
||||||
@@ -32,7 +53,35 @@ class OtpFragment : Fragment() {
|
|||||||
private var _binding: FragmentOtpBinding? = null
|
private var _binding: FragmentOtpBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
private data class OtpEntry(val label: String, val seed: String)
|
private data class OtpEntry(
|
||||||
|
val bank: String, val loginId: String, val account: String, val name: String?, val seed: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private val entries = mutableListOf<OtpEntry>()
|
||||||
|
private var adapter: OtpAdapter? = null
|
||||||
|
|
||||||
|
/** Seed field of the open "Update seed" dialog, filled by the QR scanner result. */
|
||||||
|
private var scanTarget: android.widget.EditText? = null
|
||||||
|
|
||||||
|
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
|
val target = scanTarget ?: return@registerForActivityResult
|
||||||
|
val found = OtpauthParser.parse(raw)
|
||||||
|
when {
|
||||||
|
found.isEmpty() -> Toast.makeText(requireContext(), "No OTP data found in QR", Toast.LENGTH_SHORT).show()
|
||||||
|
found.size == 1 -> target.setText(found[0].secret)
|
||||||
|
else -> {
|
||||||
|
val labels = found.map { e ->
|
||||||
|
if (e.issuer.isNotBlank()) "${e.issuer} (${e.name})" else e.name.ifBlank { e.secret.take(8) + "…" }
|
||||||
|
}.toTypedArray()
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle("Choose account")
|
||||||
|
.setItems(labels) { _, i -> target.setText(found[i].secret) }
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private inner class OtpAdapter(private val entries: List<OtpEntry>) :
|
private inner class OtpAdapter(private val entries: List<OtpEntry>) :
|
||||||
RecyclerView.Adapter<OtpAdapter.VH>() {
|
RecyclerView.Adapter<OtpAdapter.VH>() {
|
||||||
@@ -45,18 +94,19 @@ class OtpFragment : Fragment() {
|
|||||||
VH(ItemOtpCardBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
VH(ItemOtpCardBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||||
holder.b.tvOtpLabel.text = entries[position].label
|
val entry = entries[position]
|
||||||
update(holder.b, entries[position].seed)
|
val b = holder.b
|
||||||
holder.b.root.setOnClickListener {
|
b.tvOtpBank.text = entry.bank
|
||||||
val code = holder.b.tvOtpCode.text.toString().replace(" ", "")
|
b.tvOtpLabel.text = entry.name ?: "Authenticator"
|
||||||
if (code.isNotEmpty()) {
|
b.ivBankLogo.setImageResource(
|
||||||
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
if (entry.bank == "BML") R.drawable.bml_logo_vector else R.drawable.mib_logo
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
)
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
update(b, entry.seed)
|
||||||
Toast.makeText(it.context, "OTP copied", Toast.LENGTH_SHORT).show()
|
b.root.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
|
||||||
}
|
// Long-press opens the seed menu (export / update)
|
||||||
}
|
b.root.setOnLongClickListener { showSeedMenu(it, holder.bindingAdapterPosition); true }
|
||||||
}
|
b.btnCopyOtp.setOnClickListener { copyCode(it.context, b.tvOtpCode.text, "OTP copied") }
|
||||||
|
b.btnCopyNextOtp.setOnClickListener { copyCode(it.context, b.tvNextOtpCode.text, "Next OTP copied") }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun tick() {
|
fun tick() {
|
||||||
@@ -71,9 +121,196 @@ class OtpFragment : Fragment() {
|
|||||||
val secondsInPeriod = (epochSeconds % 30).toInt()
|
val secondsInPeriod = (epochSeconds % 30).toInt()
|
||||||
val remaining = 30 - secondsInPeriod
|
val remaining = 30 - secondsInPeriod
|
||||||
val code = try { Totp.generate(seed) } catch (_: Exception) { "------" }
|
val code = try { Totp.generate(seed) } catch (_: Exception) { "------" }
|
||||||
|
val next = try { Totp.generate(seed, periodOffset = 1) } catch (_: Exception) { "------" }
|
||||||
b.tvOtpCode.text = "${code.take(3)} ${code.drop(3)}"
|
b.tvOtpCode.text = "${code.take(3)} ${code.drop(3)}"
|
||||||
|
b.tvNextOtpCode.text = "${next.take(3)} ${next.drop(3)}"
|
||||||
b.otpProgress.progress = remaining
|
b.otpProgress.progress = remaining
|
||||||
b.tvOtpCountdown.text = "Refreshes in $remaining second${if (remaining == 1) "" else "s"}"
|
b.tvOtpCountdown.text = remaining.toString()
|
||||||
|
|
||||||
|
// Turn the ring and code red in the last few seconds of the window
|
||||||
|
val expiring = remaining <= 5
|
||||||
|
val accent = MaterialColors.getColor(b.root,
|
||||||
|
if (expiring) com.google.android.material.R.attr.colorError else com.google.android.material.R.attr.colorPrimary)
|
||||||
|
b.otpProgress.setIndicatorColor(accent)
|
||||||
|
b.tvOtpCode.setTextColor(accent)
|
||||||
|
b.tvOtpCountdown.setTextColor(
|
||||||
|
if (expiring) accent
|
||||||
|
else MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnSurfaceVariant)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showSeedMenu(anchor: View, position: Int) {
|
||||||
|
if (position == RecyclerView.NO_POSITION) return
|
||||||
|
val popup = PopupMenu(anchor.context, anchor)
|
||||||
|
popup.menu.add(0, 1, 0, "Export seed")
|
||||||
|
popup.menu.add(0, 2, 1, "Update seed")
|
||||||
|
popup.setOnMenuItemClickListener { item ->
|
||||||
|
val entry = entries.getOrNull(position) ?: return@setOnMenuItemClickListener false
|
||||||
|
when (item.itemId) {
|
||||||
|
1 -> showExportDialog(entry)
|
||||||
|
2 -> showUpdateDialog(position)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
popup.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun entryTitle(entry: OtpEntry) = "${entry.bank} · ${entry.name ?: entry.account}"
|
||||||
|
|
||||||
|
// ── Export ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun showExportDialog(entry: OtpEntry) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val d = DialogOtpExportSeedBinding.inflate(layoutInflater)
|
||||||
|
val uri = OtpauthParser.buildUri(entry.bank, entry.seed)
|
||||||
|
d.tvSeed.text = entry.seed.chunked(4).joinToString(" ")
|
||||||
|
renderQr(uri, (220 * resources.displayMetrics.density).toInt())?.let { d.ivSeedQr.setImageBitmap(it) }
|
||||||
|
d.btnCopySeed.setOnClickListener { copySensitive(ctx, entry.seed, "Seed copied") }
|
||||||
|
MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(entryTitle(entry))
|
||||||
|
.setView(d.root)
|
||||||
|
.setPositiveButton("Done", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun renderQr(content: String, size: Int): Bitmap? = try {
|
||||||
|
val hints = mapOf(
|
||||||
|
EncodeHintType.MARGIN to 0,
|
||||||
|
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
|
||||||
|
)
|
||||||
|
val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, size, size, hints)
|
||||||
|
val pixels = IntArray(size * size) { i -> if (matrix[i % size, i / size]) Color.BLACK else Color.WHITE }
|
||||||
|
Bitmap.createBitmap(pixels, size, size, Bitmap.Config.ARGB_8888)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
|
||||||
|
/** Copy a secret, flagged sensitive so Android 13+ hides it in the clipboard preview. */
|
||||||
|
private fun copySensitive(context: Context, text: String, message: String) {
|
||||||
|
val clip = ClipData.newPlainText("OTP seed", text)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
clip.description.extras = PersistableBundle().apply {
|
||||||
|
putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(clip)
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun showUpdateDialog(position: Int) {
|
||||||
|
val entry = entries.getOrNull(position) ?: return
|
||||||
|
val ctx = requireContext()
|
||||||
|
val d = DialogOtpUpdateSeedBinding.inflate(layoutInflater)
|
||||||
|
d.tvSeedWarning.text = "Saving will replace the current seed for this login, and the old one " +
|
||||||
|
"can't be recovered. If you might still need it, export it first."
|
||||||
|
|
||||||
|
var newSeed: String? = null
|
||||||
|
// Same behaviour as the sign-in screen's preview card
|
||||||
|
val preview = d.cardOtp
|
||||||
|
preview.root.setOnClickListener { copyCode(it.context, preview.tvOtpCode.text, "OTP copied") }
|
||||||
|
fun refreshPreview() {
|
||||||
|
val seed = newSeed
|
||||||
|
try {
|
||||||
|
if (seed == null) throw IllegalArgumentException()
|
||||||
|
preview.tvOtpCode.text = Totp.generate(seed)
|
||||||
|
preview.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
|
||||||
|
preview.otpTimer.max = 30
|
||||||
|
preview.otpTimer.progress = 30 - (System.currentTimeMillis() / 1000L % 30).toInt()
|
||||||
|
preview.root.visibility = View.VISIBLE
|
||||||
|
} catch (_: Exception) {
|
||||||
|
preview.root.visibility = View.INVISIBLE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle("Update seed · ${entry.bank}")
|
||||||
|
.setView(d.root)
|
||||||
|
.setPositiveButton("Replace", null)
|
||||||
|
.setNegativeButton("Cancel", null)
|
||||||
|
.create()
|
||||||
|
|
||||||
|
fun validate() {
|
||||||
|
val raw = d.etNewSeed.text?.toString().orEmpty()
|
||||||
|
newSeed = OtpauthParser.resolveSecret(raw)
|
||||||
|
d.tilNewSeed.error = when {
|
||||||
|
raw.isBlank() -> null
|
||||||
|
newSeed == null -> "Not a valid TOTP seed"
|
||||||
|
newSeed == entry.seed -> "This is already the current seed"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (newSeed == entry.seed) newSeed = null
|
||||||
|
dialog.getButton(DialogInterface.BUTTON_POSITIVE)?.isEnabled = newSeed != null
|
||||||
|
refreshPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
d.etNewSeed.addTextChangedListener(object : TextWatcher {
|
||||||
|
override fun afterTextChanged(s: Editable?) = validate()
|
||||||
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
|
})
|
||||||
|
d.btnScanNewSeed.setOnClickListener {
|
||||||
|
scanTarget = d.etNewSeed
|
||||||
|
qrLauncher.launch(Intent(ctx, QrScannerActivity::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
val ticker = viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
while (isActive) { refreshPreview(); delay(1_000) }
|
||||||
|
}
|
||||||
|
dialog.setOnDismissListener {
|
||||||
|
ticker.cancel()
|
||||||
|
if (scanTarget === d.etNewSeed) scanTarget = null
|
||||||
|
}
|
||||||
|
dialog.setOnShowListener {
|
||||||
|
val replace = dialog.getButton(DialogInterface.BUTTON_POSITIVE)
|
||||||
|
replace.isEnabled = false
|
||||||
|
replace.setOnClickListener {
|
||||||
|
val seed = newSeed ?: return@setOnClickListener
|
||||||
|
confirmReplace(entry) {
|
||||||
|
saveSeed(position, seed)
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun confirmReplace(entry: OtpEntry, onConfirm: () -> Unit) {
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle("Delete old seed?")
|
||||||
|
.setMessage("The current seed for ${entryTitle(entry)} will be replaced and can't be " +
|
||||||
|
"recovered afterwards.")
|
||||||
|
.setPositiveButton("Replace") { _, _ -> onConfirm() }
|
||||||
|
.setNegativeButton("Cancel", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveSeed(position: Int, seed: String) {
|
||||||
|
val entry = entries.getOrNull(position) ?: return
|
||||||
|
val store = CredentialStore(requireContext())
|
||||||
|
when (entry.bank) {
|
||||||
|
"MIB" -> {
|
||||||
|
store.updateMibOtpSeed(entry.loginId, seed)
|
||||||
|
// The live flow keeps the seed in memory for silent re-login
|
||||||
|
(requireActivity().application as BasedBankApp).mibFlowFor(entry.loginId).updateOtpSeed(seed)
|
||||||
|
}
|
||||||
|
"BML" -> store.updateBmlOtpSeed(entry.loginId, seed)
|
||||||
|
}
|
||||||
|
entries[position] = entry.copy(seed = seed)
|
||||||
|
adapter?.notifyItemChanged(position)
|
||||||
|
Toast.makeText(requireContext(), "Seed updated", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyCode(context: Context, text: CharSequence, message: String) {
|
||||||
|
val code = text.toString().replace(" ", "")
|
||||||
|
if (code.isEmpty() || code.contains('-')) return
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,21 +323,27 @@ class OtpFragment : Fragment() {
|
|||||||
val store = CredentialStore(requireContext())
|
val store = CredentialStore(requireContext())
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
val entries = mutableListOf<OtpEntry>()
|
val rank = store.loginRank()
|
||||||
|
val tagged = mutableListOf<Pair<String, OtpEntry>>()
|
||||||
for (loginId in store.getMibLoginIds()) {
|
for (loginId in store.getMibLoginIds()) {
|
||||||
val creds = store.loadMibCredentials(loginId) ?: continue
|
val creds = store.loadMibCredentials(loginId) ?: continue
|
||||||
val name = store.loadMibFullName(loginId)
|
val name = store.loadMibFullName(loginId)
|
||||||
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
|
tagged.add(CredentialStore.loginKey("mib", loginId) to
|
||||||
|
OtpEntry("MIB", loginId, creds.username, name, creds.otpSeed))
|
||||||
}
|
}
|
||||||
for (loginId in store.getBmlLoginIds()) {
|
for (loginId in store.getBmlLoginIds()) {
|
||||||
val creds = store.loadBmlCredentials(loginId) ?: continue
|
val creds = store.loadBmlCredentials(loginId) ?: continue
|
||||||
val name = store.loadBmlUserProfile(loginId)?.fullName
|
val name = store.loadBmlUserProfile(loginId)?.fullName
|
||||||
entries.add(OtpEntry(if (!name.isNullOrBlank()) "BML · $name" else "BML", creds.otpSeed))
|
tagged.add(CredentialStore.loginKey("bml", loginId) to
|
||||||
|
OtpEntry("BML", loginId, creds.username, name?.takeIf { it.isNotBlank() }, creds.otpSeed))
|
||||||
}
|
}
|
||||||
|
entries.clear()
|
||||||
|
entries.addAll(tagged.sortedBy { rank(it.first) }.map { it.second })
|
||||||
|
|
||||||
val adapter = OtpAdapter(entries)
|
val adapter = OtpAdapter(entries).also { this.adapter = it }
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
binding.emptyState.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
// Fetch real names in background if not yet cached, then refresh labels
|
// Fetch real names in background if not yet cached, then refresh labels
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
@@ -120,9 +363,8 @@ class OtpFragment : Fragment() {
|
|||||||
mobile = profile.mobile,
|
mobile = profile.mobile,
|
||||||
enrolled = profile.enrolled
|
enrolled = profile.enrolled
|
||||||
))
|
))
|
||||||
val seed = store.loadMibCredentials(loginId)?.otpSeed
|
val idx = entries.indexOfFirst { it.bank == "MIB" && it.loginId == loginId }
|
||||||
val idx = entries.indexOfFirst { it.seed == seed }
|
if (idx >= 0) { entries[idx] = entries[idx].copy(name = profile.fullName); changed = true }
|
||||||
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "MIB · ${profile.fullName}"); changed = true }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,9 +383,8 @@ class OtpFragment : Fragment() {
|
|||||||
idCard = info.idCard,
|
idCard = info.idCard,
|
||||||
birthdate = info.birthdate
|
birthdate = info.birthdate
|
||||||
))
|
))
|
||||||
val seed = store.loadBmlCredentials(loginId)?.otpSeed
|
val idx = entries.indexOfFirst { it.bank == "BML" && it.loginId == loginId }
|
||||||
val idx = entries.indexOfFirst { it.seed == seed }
|
if (idx >= 0) { entries[idx] = entries[idx].copy(name = info.fullName); changed = true }
|
||||||
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "BML · ${info.fullName}"); changed = true }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -165,6 +406,8 @@ class OtpFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
|
adapter = null
|
||||||
|
scanTarget = null
|
||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,15 @@ import android.os.Build
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.app.Activity
|
import android.text.TextPaint
|
||||||
import android.content.Intent
|
import android.text.TextUtils
|
||||||
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.*
|
import android.widget.*
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.appcompat.content.res.AppCompatResources
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.content.res.ResourcesCompat
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.core.view.updatePadding
|
import androidx.core.view.updatePadding
|
||||||
@@ -39,7 +39,6 @@ import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
|
|||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||||
import sh.sar.basedbank.util.AccountListParser
|
import sh.sar.basedbank.util.AccountListParser
|
||||||
import sh.sar.basedbank.util.PaymvQrParser
|
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -52,35 +51,12 @@ class PayMvQrFragment : Fragment() {
|
|||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
|
||||||
private var selectedAccount: BankAccount? = null
|
private var selectedAccount: BankAccount? = null
|
||||||
|
/** Set when opened for a saved contact: the QR is for their account, not one of ours. */
|
||||||
|
private var contactTarget: QrTarget? = null
|
||||||
private var generatedBitmap: Bitmap? = null
|
private var generatedBitmap: Bitmap? = null
|
||||||
private var generateJob: Job? = null
|
private var generateJob: Job? = null
|
||||||
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
||||||
|
|
||||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
|
||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
|
||||||
|
|
||||||
// BML card/gateway QR — hand off to dedicated payment screen
|
|
||||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
|
|
||||||
raw.startsWith("https://pay.bml.com.mv/app/")) {
|
|
||||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw))
|
|
||||||
return@registerForActivityResult
|
|
||||||
}
|
|
||||||
|
|
||||||
val qr = PaymvQrParser.parse(raw)
|
|
||||||
if (qr == null || qr.accountNumber == null) {
|
|
||||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
|
||||||
return@registerForActivityResult
|
|
||||||
}
|
|
||||||
val activity = requireActivity() as HomeActivity
|
|
||||||
activity.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromQr(
|
|
||||||
accountNumber = qr.accountNumber,
|
|
||||||
displayName = qr.merchantName ?: qr.accountNumber,
|
|
||||||
amount = qr.amount,
|
|
||||||
remarks = qr.purpose
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateView(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||||
): View {
|
): View {
|
||||||
@@ -88,7 +64,28 @@ class PayMvQrFragment : Fragment() {
|
|||||||
return binding.root
|
return binding.root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Who the QR pays into — one of our own accounts, or a contact's. */
|
||||||
|
private data class QrTarget(val accountNumber: String, val name: String, val bank: String)
|
||||||
|
|
||||||
|
private fun currentTarget(): QrTarget? = contactTarget
|
||||||
|
?: selectedAccount?.let { QrTarget(it.accountNumber, qrHolderName(it), it.bank) }
|
||||||
|
|
||||||
|
/** Name printed on the card and put in the payload (tag 59). */
|
||||||
|
private fun qrHolderName(account: BankAccount): String = when {
|
||||||
|
// Fahipay's brief name is the generic "Fahipay Wallet"; the holder's name is on the profile
|
||||||
|
account.bank == "FAHIPAY" -> account.profileName.takeIf { it.isNotBlank() && it != "Fahipay" }
|
||||||
|
?: CredentialStore(requireContext())
|
||||||
|
.loadFahipayUserProfile(sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag))
|
||||||
|
?.fullName?.takeIf { it.isNotBlank() }
|
||||||
|
?: account.accountBriefName
|
||||||
|
else -> account.accountBriefName
|
||||||
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
contactTarget = arguments?.let { args ->
|
||||||
|
val number = args.getString(ARG_ACCOUNT_NUMBER) ?: return@let null
|
||||||
|
QrTarget(number, args.getString(ARG_ACCOUNT_NAME).orEmpty(), args.getString(ARG_BANK).orEmpty())
|
||||||
|
}
|
||||||
val basePaddingBottom = view.paddingBottom
|
val basePaddingBottom = view.paddingBottom
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
||||||
val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
|
val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
|
||||||
@@ -97,7 +94,15 @@ class PayMvQrFragment : Fragment() {
|
|||||||
v.updatePadding(bottom = basePaddingBottom + navBarBottom)
|
v.updatePadding(bottom = basePaddingBottom + navBarBottom)
|
||||||
insets
|
insets
|
||||||
}
|
}
|
||||||
|
if (contactTarget != null) {
|
||||||
|
// Not our account, so no account picker and our phone number doesn't belong on it
|
||||||
|
binding.tilAccount.visibility = View.GONE
|
||||||
|
binding.layoutIncludePhone.visibility = View.GONE
|
||||||
|
binding.switchIncludePhone.isChecked = false
|
||||||
|
scheduleGenerate()
|
||||||
|
} else {
|
||||||
setupDropdown()
|
setupDropdown()
|
||||||
|
}
|
||||||
binding.etAmount.addTextChangedListener { scheduleGenerate() }
|
binding.etAmount.addTextChangedListener { scheduleGenerate() }
|
||||||
binding.etReference.addTextChangedListener { scheduleGenerate() }
|
binding.etReference.addTextChangedListener { scheduleGenerate() }
|
||||||
binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() }
|
binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() }
|
||||||
@@ -105,9 +110,6 @@ class PayMvQrFragment : Fragment() {
|
|||||||
binding.btnSave.isEnabled = false
|
binding.btnSave.isEnabled = false
|
||||||
binding.btnShare.setOnClickListener { shareQr() }
|
binding.btnShare.setOnClickListener { shareQr() }
|
||||||
binding.btnSave.setOnClickListener { saveQr() }
|
binding.btnSave.setOnClickListener { saveQr() }
|
||||||
binding.btnScanQr.setOnClickListener {
|
|
||||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupDropdown() {
|
private fun setupDropdown() {
|
||||||
@@ -115,6 +117,7 @@ class PayMvQrFragment : Fragment() {
|
|||||||
val eligible = accounts.filter {
|
val eligible = accounts.filter {
|
||||||
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" &&
|
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" &&
|
||||||
it.bank != "MIB" && // TODO: MIB does not support PayMV QR
|
it.bank != "MIB" && // TODO: MIB does not support PayMV QR
|
||||||
|
it.bank != "MFAISA" && // TODO: M-Faisa PayMV QR not implemented yet
|
||||||
!(it.bank == "BML" && it.currencyName.contains("USD", ignoreCase = true)) // TODO: BML USD not supported by MMA
|
!(it.bank == "BML" && it.currencyName.contains("USD", ignoreCase = true)) // TODO: BML USD not supported by MMA
|
||||||
}
|
}
|
||||||
val adapter = QrAccountAdapter(requireContext(), eligible)
|
val adapter = QrAccountAdapter(requireContext(), eligible)
|
||||||
@@ -124,6 +127,20 @@ class PayMvQrFragment : Fragment() {
|
|||||||
selectedAccount = picked
|
selectedAccount = picked
|
||||||
scheduleGenerate()
|
scheduleGenerate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-select default account if none is selected yet
|
||||||
|
if (selectedAccount == null) {
|
||||||
|
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||||
|
if (defaultNum != null) {
|
||||||
|
val defaultAcc = eligible.firstOrNull { it.accountNumber == defaultNum }
|
||||||
|
if (defaultAcc != null) {
|
||||||
|
selectedAccount = defaultAcc
|
||||||
|
val prefix = if (defaultAcc.bank == "BML" && defaultAcc.profileName.isNotBlank()) "${defaultAcc.profileName} · " else ""
|
||||||
|
binding.actvAccount.setText("$prefix${defaultAcc.accountBriefName}", false)
|
||||||
|
scheduleGenerate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,24 +153,26 @@ class PayMvQrFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun generateQr() {
|
private suspend fun generateQr() {
|
||||||
val account = selectedAccount ?: return
|
val target = currentTarget() ?: return
|
||||||
val acquirer = when (account.bank) {
|
val acquirer = when (target.bank) {
|
||||||
"BML" -> "MALBMVMV"
|
"BML" -> "MALBMVMV"
|
||||||
"MIB" -> "MADVMVMV"
|
"MIB" -> "MADVMVMV"
|
||||||
"FAHIPAY" -> "FAHIMVMV"
|
"FAHIPAY" -> "FAHIMVMV"
|
||||||
else -> "MADVMVMV"
|
else -> "MADVMVMV"
|
||||||
}
|
}
|
||||||
val amountFormatted = binding.etAmount.text?.toString()?.trim()
|
val amountRaw = binding.etAmount.text?.toString()?.trim()?.replace(",", "")
|
||||||
?.replace(",", "")
|
val amountFormatted = amountRaw
|
||||||
?.toDoubleOrNull()
|
?.toDoubleOrNull()
|
||||||
?.takeIf { it > 0 }
|
?.takeIf { it > 0 }
|
||||||
?.let { "%.2f".format(it) }
|
?.let { "%.2f".format(it) }
|
||||||
|
// BML shows the amount on the card as typed (no forced decimals)
|
||||||
|
val amountDisplay = amountRaw?.takeIf { amountFormatted != null }
|
||||||
|
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val includePhone = binding.switchIncludePhone.isChecked
|
val account = selectedAccount
|
||||||
|
val mobile = if (binding.switchIncludePhone.isChecked && contactTarget == null && account != null) {
|
||||||
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
|
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
|
||||||
val store = CredentialStore(ctx)
|
val store = CredentialStore(ctx)
|
||||||
val mobile = if (includePhone) {
|
|
||||||
when (account.bank) {
|
when (account.bank) {
|
||||||
"BML" -> store.loadBmlUserProfile(loginId)?.mobile
|
"BML" -> store.loadBmlUserProfile(loginId)?.mobile
|
||||||
"FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile
|
"FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile
|
||||||
@@ -162,17 +181,28 @@ class PayMvQrFragment : Fragment() {
|
|||||||
when {
|
when {
|
||||||
m.startsWith("+") -> m
|
m.startsWith("+") -> m
|
||||||
m.length == 7 -> "+960$m"
|
m.length == 7 -> "+960$m"
|
||||||
|
m.length == 10 && m.startsWith("960") -> "+$m" // Fahipay stores 960XXXXXXX
|
||||||
else -> m
|
else -> m
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else null
|
} else null
|
||||||
|
|
||||||
val purpose = binding.etReference.text?.toString()?.trim()
|
val purpose = binding.etReference.text?.toString()?.trim()
|
||||||
?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default)
|
?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
// The reference (62/05) is also printed vertically beside the QR, as each bank does
|
||||||
|
val reference = when (target.bank) {
|
||||||
|
// BML: base-32 account number followed by the amount as typed
|
||||||
|
"BML" -> ((target.accountNumber.toBigIntegerOrNull()?.toString(32)?.uppercase() ?: "") +
|
||||||
|
(amountDisplay ?: "")).take(25).ifEmpty { generateReference(9) }
|
||||||
|
"FAHIPAY" -> "P" + generateReference(9) // Fahipay's own references are P + 9 chars
|
||||||
|
else -> generateReference(9)
|
||||||
|
}
|
||||||
|
|
||||||
val bmp = withContext(Dispatchers.Default) {
|
val bmp = withContext(Dispatchers.Default) {
|
||||||
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted, mobile, purpose)
|
val payload = buildQrPayload(target.accountNumber, target.name, acquirer, amountFormatted, mobile, purpose, reference, target.bank)
|
||||||
renderQrCard(ctx, account, payload, amountFormatted)
|
if (target.bank == "FAHIPAY") renderFahipayQrCard(ctx, target, payload, reference)
|
||||||
|
else renderQrCard(ctx, target, payload, reference)
|
||||||
}
|
}
|
||||||
if (_binding == null) return
|
if (_binding == null) return
|
||||||
generatedBitmap = bmp
|
generatedBitmap = bmp
|
||||||
@@ -191,14 +221,19 @@ class PayMvQrFragment : Fragment() {
|
|||||||
acquirer: String,
|
acquirer: String,
|
||||||
amountStr: String?,
|
amountStr: String?,
|
||||||
mobile: String?,
|
mobile: String?,
|
||||||
purpose: String
|
purpose: String?,
|
||||||
|
ref: String,
|
||||||
|
bank: String
|
||||||
): String {
|
): String {
|
||||||
fun tlv(tag: String, value: String): String {
|
fun tlv(tag: String, value: String): String {
|
||||||
val len = value.length
|
val len = value.length
|
||||||
return tag + (if (len < 10) "0$len" else "$len") + value
|
return tag + (if (len < 10) "0$len" else "$len") + value
|
||||||
}
|
}
|
||||||
val format = tlv("00", "01")
|
val format = tlv("00", "01")
|
||||||
val poi = tlv("01", "11")
|
// Fahipay's own QRs are dynamic (12) when they carry an amount and mask the amount
|
||||||
|
// as "***" when they don't; its scanner may reject QRs that differ
|
||||||
|
val fahipay = bank == "FAHIPAY"
|
||||||
|
val poi = tlv("01", if (fahipay && !amountStr.isNullOrBlank()) "12" else "11")
|
||||||
val sub00 = tlv("00", "mv.favara.mpqr")
|
val sub00 = tlv("00", "mv.favara.mpqr")
|
||||||
val sub01 = tlv("01", acquirer)
|
val sub01 = tlv("01", acquirer)
|
||||||
val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app
|
val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app
|
||||||
@@ -208,21 +243,28 @@ class PayMvQrFragment : Fragment() {
|
|||||||
val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10)
|
val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10)
|
||||||
val mcc = tlv("52", "0000")
|
val mcc = tlv("52", "0000")
|
||||||
val currency = tlv("53", "462")
|
val currency = tlv("53", "462")
|
||||||
val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else ""
|
val amountTLV = when {
|
||||||
|
!amountStr.isNullOrBlank() -> tlv("54", amountStr)
|
||||||
|
fahipay -> tlv("54", "***")
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
val country = tlv("58", "MV")
|
val country = tlv("58", "MV")
|
||||||
val name = tlv("59", accountName.take(25))
|
val name = tlv("59", accountName.uppercase().take(25))
|
||||||
val ref = generateReference()
|
// Fahipay's QRs always carry a city ("LD" + 4 digits) and default the purpose to PAYMENT
|
||||||
val addlData = tlv("62", tlv("05", ref) + tlv("08", purpose))
|
val city = if (fahipay) tlv("60", "LD" + (0..9999).random().toString().padStart(4, '0')) else ""
|
||||||
|
val purposeText = purpose?.takeIf { it.isNotBlank() } ?: if (fahipay) "PAYMENT" else null
|
||||||
|
val purposeTLV = if (purposeText != null) tlv("08", purposeText) else ""
|
||||||
|
val addlData = tlv("62", tlv("05", ref) + purposeTLV)
|
||||||
val timestamp = java.time.LocalDateTime.now()
|
val timestamp = java.time.LocalDateTime.now()
|
||||||
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.00000"))
|
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.00000"))
|
||||||
val tag80 = tlv("80", tlv("00", "mv.favara.mpqr") + tlv("01", timestamp))
|
val tag80 = tlv("80", tlv("00", "mv.favara.mpqr") + tlv("01", timestamp))
|
||||||
val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + addlData + tag80 + "6304"
|
val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + city + addlData + tag80 + "6304"
|
||||||
return prefix + crc16(prefix)
|
return prefix + crc16(prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun generateReference(): String {
|
private fun generateReference(length: Int): String {
|
||||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
return (1..9).map { chars.random() }.joinToString("")
|
return (1..length).map { chars.random() }.joinToString("")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun crc16(data: String): String {
|
private fun crc16(data: String): String {
|
||||||
@@ -239,88 +281,127 @@ class PayMvQrFragment : Fragment() {
|
|||||||
|
|
||||||
// ── QR card rendering ────────────────────────────────────────────────────
|
// ── QR card rendering ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replicates the BML app's ReceiveCard (React Native, v2.1.47) 1:1. All measurements are in
|
||||||
|
* dp, as in BML's StyleSheet, laid out for BML's reference screen width and drawn at
|
||||||
|
* [PX_PER_DP] pixels per dp.
|
||||||
|
*/
|
||||||
private fun renderQrCard(
|
private fun renderQrCard(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
account: BankAccount,
|
target: QrTarget,
|
||||||
qrPayload: String,
|
qrPayload: String,
|
||||||
amountStr: String?
|
qrId: String
|
||||||
): Bitmap {
|
): Bitmap {
|
||||||
val W = 900
|
val sw = SCREEN_WIDTH_DP
|
||||||
val H = 1080
|
fun px(dp: Float) = dp * PX_PER_DP
|
||||||
val outerCorner = 48f
|
val mmaBlue = Color.parseColor("#0E5CA4")
|
||||||
val boxBlue = Color.parseColor("#2272B7")
|
|
||||||
val footerBlue = Color.parseColor("#1A5799")
|
|
||||||
val boxL = 24f; val boxT = 110f; val boxR = 876f; val boxB = 962f
|
|
||||||
|
|
||||||
val bm = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888)
|
val cardW = sw - 48f // screen's horizontal margins, spacing[5] each side
|
||||||
|
val qrSize = sw * 0.5f
|
||||||
|
val railW = sw / 1.85f
|
||||||
|
|
||||||
|
val namePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.WHITE
|
||||||
|
textSize = px(14f)
|
||||||
|
typeface = Typeface.DEFAULT
|
||||||
|
textAlign = Paint.Align.CENTER
|
||||||
|
}
|
||||||
|
val footerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
color = Color.WHITE
|
||||||
|
textSize = px(sw * 0.046f)
|
||||||
|
typeface = ResourcesCompat.getFont(ctx, R.font.sofia_pro_bold) ?: Typeface.DEFAULT_BOLD
|
||||||
|
letterSpacing = 1.2f / (sw * 0.046f) // RN letterSpacing is in dp, Paint's is in em
|
||||||
|
textAlign = Paint.Align.CENTER
|
||||||
|
}
|
||||||
|
// Android RN Text lines include font padding: line height = bottom - top
|
||||||
|
fun lineHeight(p: Paint) = p.fontMetrics.let { it.bottom - it.top } / PX_PER_DP
|
||||||
|
|
||||||
|
// --- Vertical layout (dp, card-local) ---
|
||||||
|
val topCardTop = 2f
|
||||||
|
val brandTop = topCardTop + 32f // brandRow marginTop spacing[6]
|
||||||
|
val logoBoxW = sw * 0.38f // bml-logo-paymv box: 0.38·sw wide
|
||||||
|
val logoBoxH = logoBoxW * 0.1116751269035533f
|
||||||
|
val payMvBoxW = sw * 0.2f // paymv-logo box: 0.2·sw wide
|
||||||
|
val payMvBoxH = payMvBoxW * 0.17333333333333334f
|
||||||
|
val brandH = maxOf(logoBoxH, payMvBoxH)
|
||||||
|
val nameText = target.name.uppercase()
|
||||||
|
val hasName = nameText.isNotBlank()
|
||||||
|
val qrCardTop = brandTop + brandH + if (hasName) 24f else 32f
|
||||||
|
val nameTop = qrCardTop + 8f + 12f // qrCard paddingTop spacing[2], name marginTop spacing[3]
|
||||||
|
val nameH = if (hasName) lineHeight(namePaint) else 0f
|
||||||
|
val qrTop = if (hasName) nameTop + nameH + 16f else qrCardTop + 37f
|
||||||
|
val qrCardBottom = qrTop + qrSize + 37f // paddingBottom spacing[6] + 5
|
||||||
|
val topCardBottom = qrCardBottom + 24f + 8f // qrCard marginBottom, topCard paddingBottom
|
||||||
|
val footerLineH = lineHeight(footerPaint)
|
||||||
|
val cardH = topCardBottom + 12f + footerLineH + 12f + 2f
|
||||||
|
|
||||||
|
val bm = Bitmap.createBitmap(px(cardW).toInt(), px(cardH).toInt(), Bitmap.Config.ARGB_8888)
|
||||||
val canvas = Canvas(bm)
|
val canvas = Canvas(bm)
|
||||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
|
||||||
// Clip to outer rounded card shape
|
// captureWrapper: mmaBlue, radius 20 — shows as a 2dp border around the white top card
|
||||||
val outerPath = Path()
|
val outerPath = Path().apply {
|
||||||
outerPath.addRoundRect(RectF(0f, 0f, W.toFloat(), H.toFloat()), outerCorner, outerCorner, Path.Direction.CW)
|
addRoundRect(RectF(0f, 0f, px(cardW), px(cardH)), px(20f), px(20f), Path.Direction.CW)
|
||||||
canvas.clipPath(outerPath)
|
|
||||||
canvas.drawColor(Color.WHITE)
|
|
||||||
|
|
||||||
// --- Bank logo top-left ---
|
|
||||||
val logoRes = when (account.bank) {
|
|
||||||
"BML" -> R.drawable.bml_logo_vector
|
|
||||||
"MIB" -> R.drawable.mib_faisanet_logo
|
|
||||||
else -> R.drawable.fahipay_logo_long
|
|
||||||
}
|
}
|
||||||
|
canvas.clipPath(outerPath)
|
||||||
|
canvas.drawColor(mmaBlue)
|
||||||
|
|
||||||
|
// topCard: white, 2dp inset, top corners 18
|
||||||
|
paint.color = Color.WHITE
|
||||||
|
val r = px(18f)
|
||||||
|
canvas.drawPath(Path().apply {
|
||||||
|
addRoundRect(
|
||||||
|
RectF(px(2f), px(topCardTop), px(cardW - 2f), px(topCardBottom)),
|
||||||
|
floatArrayOf(r, r, r, r, 0f, 0f, 0f, 0f), Path.Direction.CW
|
||||||
|
)
|
||||||
|
}, paint)
|
||||||
|
|
||||||
|
// --- brandRow: "BANK OF MALDIVES" wordmark left, "PayMV QR" right, 40dp side margins ---
|
||||||
|
val rowL = 2f + 40f
|
||||||
|
val rowR = cardW - 2f - 40f
|
||||||
|
val rowCenterY = brandTop + brandH / 2
|
||||||
|
val logoRes = if (target.bank == "BML") R.drawable.bml_logo_paymv else R.drawable.mib_faisanet_logo
|
||||||
AppCompatResources.getDrawable(ctx, logoRes)?.let { d ->
|
AppCompatResources.getDrawable(ctx, logoRes)?.let { d ->
|
||||||
val nW = d.intrinsicWidth.coerceAtLeast(1)
|
val nW = d.intrinsicWidth.coerceAtLeast(1)
|
||||||
val nH = d.intrinsicHeight.coerceAtLeast(1)
|
val nH = d.intrinsicHeight.coerceAtLeast(1)
|
||||||
val maxW = 180f; val maxH = 76f
|
// resizeMode "contain" inside the logo box, left-aligned in the row
|
||||||
val scale = minOf(maxW / nW, maxH / nH)
|
val scale = minOf(px(logoBoxW) / nW, px(logoBoxH) / nH)
|
||||||
val lW = (nW * scale).toInt()
|
val lW = (nW * scale).toInt()
|
||||||
val lH = (nH * scale).toInt()
|
val lH = (nH * scale).toInt()
|
||||||
val lTop = ((boxT - lH) / 2).toInt().coerceAtLeast(10)
|
val lLeft = (px(rowL) + (px(logoBoxW) - lW) / 2f).toInt()
|
||||||
d.setBounds(24, lTop, 24 + lW, lTop + lH)
|
val lTop = (px(rowCenterY) - lH / 2f).toInt()
|
||||||
|
d.setBounds(lLeft, lTop, lLeft + lW, lTop + lH)
|
||||||
d.draw(canvas)
|
d.draw(canvas)
|
||||||
}
|
}
|
||||||
|
// BML draws the paymv-logo image (fills its box exactly), nudged down 1dp
|
||||||
// --- "PayMV QR" top-right ---
|
paint.color = mmaBlue
|
||||||
paint.color = Color.parseColor("#1A1A2E")
|
paint.typeface = footerPaint.typeface
|
||||||
paint.textSize = 36f
|
|
||||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
|
||||||
paint.textAlign = Paint.Align.RIGHT
|
paint.textAlign = Paint.Align.RIGHT
|
||||||
canvas.drawText("PayMV QR", W - 28f, 66f, paint)
|
paint.textSize = px(payMvBoxH)
|
||||||
|
paint.textSize *= px(payMvBoxW) / paint.measureText("PayMV QR")
|
||||||
|
val payMvBounds = Rect().also { paint.getTextBounds("PayMV QR", 0, 8, it) }
|
||||||
|
canvas.drawText(
|
||||||
|
"PayMV QR", px(rowR),
|
||||||
|
px(rowCenterY + 1f) - payMvBounds.exactCenterY(), paint
|
||||||
|
)
|
||||||
|
|
||||||
// --- Blue rounded box ---
|
// --- qrCard: mmaBlue, radius 16, 40dp side margins ---
|
||||||
paint.color = boxBlue
|
val qrCardL = 2f + 40f
|
||||||
paint.textAlign = Paint.Align.LEFT
|
val qrCardR = cardW - 2f - 40f
|
||||||
canvas.drawRoundRect(RectF(boxL, boxT, boxR, boxB), 36f, 36f, paint)
|
paint.color = mmaBlue
|
||||||
|
canvas.drawRoundRect(RectF(px(qrCardL), px(qrCardTop), px(qrCardR), px(qrCardBottom)), px(16f), px(16f), paint)
|
||||||
|
|
||||||
// Account name (white, bold, uppercase, auto-scaled to fit)
|
if (hasName) {
|
||||||
paint.color = Color.WHITE
|
val maxNameW = px(qrCardR - qrCardL)
|
||||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
if (namePaint.measureText(nameText) > maxNameW) {
|
||||||
paint.textAlign = Paint.Align.CENTER
|
namePaint.textSize *= maxNameW / namePaint.measureText(nameText)
|
||||||
val nameText = account.accountBriefName.uppercase()
|
|
||||||
paint.textSize = 36f
|
|
||||||
val maxNameW = boxR - boxL - 48f
|
|
||||||
if (paint.measureText(nameText) > maxNameW) {
|
|
||||||
paint.textSize = 36f * maxNameW / paint.measureText(nameText)
|
|
||||||
}
|
}
|
||||||
val nameBaseline = boxT + 68f
|
canvas.drawText(nameText, px(cardW / 2), px(nameTop) - namePaint.fontMetrics.top, namePaint)
|
||||||
canvas.drawText(nameText, W / 2f, nameBaseline, paint)
|
|
||||||
|
|
||||||
// Optional amount below name
|
|
||||||
val qrTopY: Float
|
|
||||||
if (!amountStr.isNullOrBlank()) {
|
|
||||||
paint.textSize = 28f
|
|
||||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
|
|
||||||
val amtBaseline = nameBaseline + 42f
|
|
||||||
canvas.drawText("MVR $amountStr", W / 2f, amtBaseline, paint)
|
|
||||||
qrTopY = amtBaseline + 20f
|
|
||||||
} else {
|
|
||||||
qrTopY = nameBaseline + 26f
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// QR code — white modules on the same blue as the box background
|
// QR — white modules on mmaBlue, ECL M, no quiet zone (react-native-qrcode-svg defaults)
|
||||||
val availH = boxB - qrTopY - 24f
|
val qrPx = px(qrSize).toInt()
|
||||||
val qrPx = minOf(availH, boxR - boxL - 48f).toInt().coerceAtMost(700).coerceAtLeast(200)
|
val qrLeft = px(cardW / 2) - qrPx / 2f
|
||||||
val qrLeft = ((W - qrPx) / 2).toFloat()
|
|
||||||
try {
|
try {
|
||||||
val hints = mapOf(
|
val hints = mapOf(
|
||||||
EncodeHintType.MARGIN to 0,
|
EncodeHintType.MARGIN to 0,
|
||||||
@@ -330,23 +411,150 @@ class PayMvQrFragment : Fragment() {
|
|||||||
val pixels = IntArray(qrPx * qrPx)
|
val pixels = IntArray(qrPx * qrPx)
|
||||||
for (y in 0 until qrPx) {
|
for (y in 0 until qrPx) {
|
||||||
for (x in 0 until qrPx) {
|
for (x in 0 until qrPx) {
|
||||||
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else boxBlue
|
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else mmaBlue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
|
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
|
||||||
canvas.drawBitmap(qrBm, qrLeft, qrTopY, null)
|
canvas.drawBitmap(qrBm, qrLeft, px(qrTop), null)
|
||||||
qrBm.recycle()
|
qrBm.recycle()
|
||||||
} catch (_: Exception) { /* skip if encoding fails */ }
|
} catch (_: Exception) { /* skip if encoding fails */ }
|
||||||
|
|
||||||
// --- Dark blue footer ---
|
// qrIdRail: the reference, rotated -90°, beside the QR's right edge
|
||||||
paint.color = footerBlue
|
if (qrId.isNotEmpty()) {
|
||||||
paint.textAlign = Paint.Align.LEFT
|
val idPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
canvas.drawRect(RectF(0f, 970f, W.toFloat(), H.toFloat()), paint)
|
color = Color.BLACK
|
||||||
|
alpha = (255 * 0.8f).toInt()
|
||||||
|
textSize = px(10f)
|
||||||
|
typeface = Typeface.DEFAULT
|
||||||
|
textAlign = Paint.Align.CENTER
|
||||||
|
}
|
||||||
|
val qrRight = cardW / 2 + qrSize / 2
|
||||||
|
val cx = px(qrRight + railW / 1.37f - railW / 2)
|
||||||
|
val cy = px(qrTop + (qrSize + railW / 1.5f) / 2)
|
||||||
|
val idText = TextUtils.ellipsize(qrId, TextPaint(idPaint), px(railW), TextUtils.TruncateAt.END).toString()
|
||||||
|
canvas.save()
|
||||||
|
canvas.rotate(-90f, cx, cy)
|
||||||
|
val fm = idPaint.fontMetrics
|
||||||
|
canvas.drawText(idText, cx, cy - (fm.bottom + fm.top) / 2, idPaint)
|
||||||
|
canvas.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- footer: SofiaPro-Bold, letterSpacing 1.2 ---
|
||||||
|
canvas.drawText(
|
||||||
|
"MALDIVES NATIONAL QR", px(cardW / 2),
|
||||||
|
px(topCardBottom + 12f) - footerPaint.fontMetrics.top, footerPaint
|
||||||
|
)
|
||||||
|
|
||||||
|
return bm
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replicates the card Fahipay's server renders for PayMV QR (api/app/qr/), measured
|
||||||
|
* in pixels on its 1240×1322 image. Fonts follow the BML card (Sofia Pro Bold, Roboto),
|
||||||
|
* except the vertical reference, which is Montserrat as on Fahipay's.
|
||||||
|
*/
|
||||||
|
private fun renderFahipayQrCard(
|
||||||
|
ctx: Context,
|
||||||
|
target: QrTarget,
|
||||||
|
qrPayload: String,
|
||||||
|
reference: String
|
||||||
|
): Bitmap {
|
||||||
|
val w = 1240f
|
||||||
|
val h = 1322f
|
||||||
|
val blue = Color.parseColor("#005DA3")
|
||||||
|
val sofiaBold = ResourcesCompat.getFont(ctx, R.font.sofia_pro_bold) ?: Typeface.DEFAULT_BOLD
|
||||||
|
val montserrat = ResourcesCompat.getFont(ctx, R.font.montserrat_regular) ?: Typeface.DEFAULT
|
||||||
|
|
||||||
|
val bm = Bitmap.createBitmap(w.toInt(), h.toInt(), Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bm)
|
||||||
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
|
||||||
|
// Blue card with a 6px border around the white area; footer is the blue below it
|
||||||
|
canvas.clipPath(Path().apply {
|
||||||
|
addRoundRect(RectF(0f, 0f, w, h), 50f, 50f, Path.Direction.CW)
|
||||||
|
})
|
||||||
|
canvas.drawColor(blue)
|
||||||
paint.color = Color.WHITE
|
paint.color = Color.WHITE
|
||||||
paint.textSize = 32f
|
canvas.drawPath(Path().apply {
|
||||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
addRoundRect(
|
||||||
paint.textAlign = Paint.Align.CENTER
|
RectF(6f, 6f, w - 6f, 1174f),
|
||||||
canvas.drawText("MALDIVES NATIONAL QR", W / 2f, 1038f, paint)
|
floatArrayOf(44f, 44f, 44f, 44f, 0f, 0f, 0f, 0f), Path.Direction.CW
|
||||||
|
)
|
||||||
|
}, paint)
|
||||||
|
|
||||||
|
// Square app icon, then the "FahiPay" wordmark, in one row
|
||||||
|
AppCompatResources.getDrawable(ctx, R.drawable.fahipay_logo)?.let { d ->
|
||||||
|
d.setBounds(94, 94, 163, 163)
|
||||||
|
d.draw(canvas)
|
||||||
|
}
|
||||||
|
AppCompatResources.getDrawable(ctx, R.drawable.fahipay_logo_long)?.let { d ->
|
||||||
|
val lH = 48
|
||||||
|
val lW = (lH * d.intrinsicWidth.toFloat() / d.intrinsicHeight.coerceAtLeast(1)).toInt()
|
||||||
|
d.setBounds(178, 104, 178 + lW, 104 + lH)
|
||||||
|
d.draw(canvas)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sized so capitals match the reference's cap heights; positions below are cap tops
|
||||||
|
fun textPaint(tf: Typeface, capH: Float, spacingEm: Float, align: Paint.Align) =
|
||||||
|
Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||||
|
typeface = tf
|
||||||
|
letterSpacing = spacingEm
|
||||||
|
textAlign = align
|
||||||
|
textSize = 100f
|
||||||
|
val h = Rect().also { getTextBounds("H", 0, 1, it) }.height().coerceAtLeast(1)
|
||||||
|
textSize = 100f * capH / h
|
||||||
|
}
|
||||||
|
fun Paint.capHeight() = Rect().also { getTextBounds("H", 0, 1, it) }.height()
|
||||||
|
|
||||||
|
// "PayMV QR" top-right
|
||||||
|
val payMvPaint = textPaint(sofiaBold, 30f, 0f, Paint.Align.RIGHT).apply { color = blue }
|
||||||
|
canvas.drawText("PayMV QR", 1147f, 101f + 30f, payMvPaint)
|
||||||
|
|
||||||
|
// Blue QR panel
|
||||||
|
paint.color = blue
|
||||||
|
canvas.drawRoundRect(RectF(166f, 218f, 1074f, 1126f), 55f, 55f, paint)
|
||||||
|
|
||||||
|
// Account name
|
||||||
|
val nameText = target.name.uppercase()
|
||||||
|
if (nameText.isNotBlank()) {
|
||||||
|
val namePaint = textPaint(Typeface.DEFAULT, 30f, 0f, Paint.Align.CENTER).apply { color = Color.WHITE }
|
||||||
|
val maxNameW = 1074f - 166f - 80f
|
||||||
|
if (namePaint.measureText(nameText) > maxNameW) {
|
||||||
|
namePaint.textSize *= maxNameW / namePaint.measureText(nameText)
|
||||||
|
}
|
||||||
|
canvas.drawText(nameText, 619f, 314f + namePaint.capHeight(), namePaint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QR — white modules on blue, no quiet zone
|
||||||
|
val qrPx = 562
|
||||||
|
try {
|
||||||
|
val hints = mapOf(
|
||||||
|
EncodeHintType.MARGIN to 0,
|
||||||
|
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
|
||||||
|
)
|
||||||
|
val matrix = QRCodeWriter().encode(qrPayload, BarcodeFormat.QR_CODE, qrPx, qrPx, hints)
|
||||||
|
val pixels = IntArray(qrPx * qrPx)
|
||||||
|
for (y in 0 until qrPx) {
|
||||||
|
for (x in 0 until qrPx) {
|
||||||
|
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else blue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
|
||||||
|
canvas.drawBitmap(qrBm, 338f, 417f, null)
|
||||||
|
qrBm.recycle()
|
||||||
|
} catch (_: Exception) { /* skip if encoding fails */ }
|
||||||
|
|
||||||
|
// Reference, blue, reading bottom-to-top in the white margin right of the panel,
|
||||||
|
// starting level with y=1087 and with its baseline at x=1171
|
||||||
|
val refPaint = textPaint(montserrat, 27f, 0.005f, Paint.Align.LEFT).apply { color = blue }
|
||||||
|
canvas.save()
|
||||||
|
canvas.rotate(-90f, 1171f, 1087f)
|
||||||
|
canvas.drawText(reference, 1171f, 1087f, refPaint)
|
||||||
|
canvas.restore()
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
val footerPaint = textPaint(sofiaBold, 55.5f, 1.2f / 25.76f, Paint.Align.CENTER).apply { color = Color.WHITE }
|
||||||
|
canvas.drawText("MALDIVES NATIONAL QR", w / 2, 1220f + 55.5f, footerPaint)
|
||||||
|
|
||||||
return bm
|
return bm
|
||||||
}
|
}
|
||||||
@@ -355,13 +563,13 @@ class PayMvQrFragment : Fragment() {
|
|||||||
|
|
||||||
private fun shareQr() {
|
private fun shareQr() {
|
||||||
val bmp = generatedBitmap ?: return
|
val bmp = generatedBitmap ?: return
|
||||||
val account = selectedAccount ?: return
|
val target = currentTarget() ?: return
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val uri = withContext(Dispatchers.IO) {
|
val uri = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val dir = File(requireContext().cacheDir, "qr")
|
val dir = File(requireContext().cacheDir, "qr")
|
||||||
dir.mkdirs()
|
dir.mkdirs()
|
||||||
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
|
val safeName = target.name.replace(Regex("[^A-Za-z0-9_]"), "_")
|
||||||
val file = File(dir, "${safeName}_paymv_qr.png")
|
val file = File(dir, "${safeName}_paymv_qr.png")
|
||||||
FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||||
FileProvider.getUriForFile(
|
FileProvider.getUriForFile(
|
||||||
@@ -383,11 +591,11 @@ class PayMvQrFragment : Fragment() {
|
|||||||
|
|
||||||
private fun saveQr() {
|
private fun saveQr() {
|
||||||
val bmp = generatedBitmap ?: return
|
val bmp = generatedBitmap ?: return
|
||||||
val account = selectedAccount ?: return
|
val target = currentTarget() ?: return
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
val saved = withContext(Dispatchers.IO) {
|
val saved = withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
|
val safeName = target.name.replace(Regex("[^A-Za-z0-9_]"), "_")
|
||||||
val filename = "${safeName}_PayMV_QR.png"
|
val filename = "${safeName}_PayMV_QR.png"
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
val values = ContentValues().apply {
|
val values = ContentValues().apply {
|
||||||
@@ -429,6 +637,25 @@ class PayMvQrFragment : Fragment() {
|
|||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_ACCOUNT_NUMBER = "account_number"
|
||||||
|
private const val ARG_ACCOUNT_NAME = "account_name"
|
||||||
|
private const val ARG_BANK = "bank"
|
||||||
|
|
||||||
|
/** BML's layout reference: the screen width (dp) its ReceiveCard sizes were captured at. */
|
||||||
|
private const val SCREEN_WIDTH_DP = 560f
|
||||||
|
private const val PX_PER_DP = 2f
|
||||||
|
|
||||||
|
/** QR for a contact's account. [bank] is "BML" / "MIB" / "FAHIPAY", as on [BankAccount.bank]. */
|
||||||
|
fun forContact(accountNumber: String, name: String, bank: String) = PayMvQrFragment().apply {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putString(ARG_ACCOUNT_NUMBER, accountNumber)
|
||||||
|
putString(ARG_ACCOUNT_NAME, name)
|
||||||
|
putString(ARG_BANK, bank)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Account dropdown adapter ──────────────────────────────────────────────
|
// ── Account dropdown adapter ──────────────────────────────────────────────
|
||||||
|
|
||||||
private inner class QrAccountAdapter(
|
private inner class QrAccountAdapter(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,8 @@ class QrScannerActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
|
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||||
binding = ActivityQrScannerBinding.inflate(layoutInflater)
|
binding = ActivityQrScannerBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
// Black camera background — always use light (white) system bar icons
|
// Black camera background — always use light (white) system bar icons
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
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 sh.sar.basedbank.BuildConfig
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.FragmentSettingsAboutBinding
|
||||||
|
|
||||||
|
class SettingsAboutFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentSettingsAboutBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentSettingsAboutBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
|
||||||
|
val basePaddingBottom = binding.root.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
binding.tvAppName.text = getString(R.string.app_name)
|
||||||
|
binding.tvVersion.text = getString(R.string.about_version, BuildConfig.VERSION_NAME)
|
||||||
|
|
||||||
|
binding.rowMibTerms.setOnClickListener { openUrl("https://faisanet.mib.com.mv/terms") }
|
||||||
|
binding.rowBmlTerms.setOnClickListener { openUrl("https://www.bankofmaldives.com.mv/storage/file/121/10289/terms-conditions-online-banking-en.pdf") }
|
||||||
|
binding.rowFahipayTerms.setOnClickListener { openUrl("https://fahipay.mv/tos/") }
|
||||||
|
|
||||||
|
val hasMvr = BuildConfig.ACCOUNT_MVR.isNotEmpty()
|
||||||
|
val hasUsd = BuildConfig.ACCOUNT_USD.isNotEmpty()
|
||||||
|
|
||||||
|
if (!hasMvr && !hasUsd) {
|
||||||
|
binding.sectionDonate.visibility = View.GONE
|
||||||
|
} else {
|
||||||
|
if (!hasMvr) binding.btnDonateMvr.visibility = View.GONE
|
||||||
|
else binding.btnDonateMvr.setOnClickListener { openDonate(BuildConfig.ACCOUNT_MVR) }
|
||||||
|
if (!hasUsd) binding.btnDonateUsd.visibility = View.GONE
|
||||||
|
else binding.btnDonateUsd.setOnClickListener { openDonate(BuildConfig.ACCOUNT_USD) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openUrl(url: String) {
|
||||||
|
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openDonate(accountNumber: String) {
|
||||||
|
val fragment = TransferFragment.newInstance(
|
||||||
|
accountNumber = accountNumber,
|
||||||
|
displayName = getString(R.string.app_name),
|
||||||
|
subtitle = accountNumber,
|
||||||
|
colorHex = "#607D8B",
|
||||||
|
imageHash = null
|
||||||
|
)
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.settings_about)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package sh.sar.basedbank.ui.home
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
@@ -16,6 +17,8 @@ import android.widget.Toast
|
|||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
|
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
@@ -36,8 +39,10 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
private lateinit var prefs: SharedPreferences
|
private lateinit var prefs: SharedPreferences
|
||||||
private val slots = mutableListOf<Int>()
|
private val slots = mutableListOf<Int>()
|
||||||
private val quickActions = mutableListOf<Int>()
|
private val quickActions = mutableListOf<Int>()
|
||||||
|
private val circularSlots = mutableListOf<Int>()
|
||||||
private lateinit var slotAdapter: NavItemAdapter
|
private lateinit var slotAdapter: NavItemAdapter
|
||||||
private lateinit var quickActionAdapter: NavItemAdapter
|
private lateinit var quickActionAdapter: NavItemAdapter
|
||||||
|
private lateinit var circularSlotAdapter: NavItemAdapter
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false)
|
_binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false)
|
||||||
@@ -46,13 +51,30 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
|
||||||
|
val basePaddingBottom = binding.root.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
|
||||||
|
val isBottomNav = prefs.getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
// Navigation mode
|
// Navigation mode
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
val currentMode = NavCustomization.getNavMode(prefs)
|
||||||
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer)
|
binding.navModeToggle.check(when (currentMode) {
|
||||||
|
NavCustomization.NAV_MODE_BOTTOM -> R.id.btnNavBottom
|
||||||
|
NavCustomization.NAV_MODE_CIRCULAR -> R.id.btnNavCircular
|
||||||
|
else -> R.id.btnNavDrawer
|
||||||
|
})
|
||||||
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
if (!isChecked) return@addOnButtonCheckedListener
|
if (!isChecked) return@addOnButtonCheckedListener
|
||||||
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
|
val mode = when (checkedId) {
|
||||||
|
R.id.btnNavBottom -> NavCustomization.NAV_MODE_BOTTOM
|
||||||
|
R.id.btnNavCircular -> NavCustomization.NAV_MODE_CIRCULAR
|
||||||
|
else -> NavCustomization.NAV_MODE_DRAWER
|
||||||
|
}
|
||||||
|
NavCustomization.saveNavMode(prefs, mode)
|
||||||
(activity as? HomeActivity)?.applyNavMode()
|
(activity as? HomeActivity)?.applyNavMode()
|
||||||
updateShortcutsVisibility()
|
updateShortcutsVisibility()
|
||||||
}
|
}
|
||||||
@@ -63,10 +85,22 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
quickActionAdapter = NavItemAdapter(
|
quickActionAdapter = NavItemAdapter(
|
||||||
items = quickActions,
|
items = quickActions,
|
||||||
onSave = { NavCustomization.saveQuickActions(prefs, quickActions) },
|
onSave = { NavCustomization.saveQuickActions(prefs, quickActions) },
|
||||||
isEnabled = { !prefs.getBoolean("bottom_nav", false) }
|
isEnabled = { NavCustomization.getNavMode(prefs) != NavCustomization.NAV_MODE_BOTTOM }
|
||||||
)
|
)
|
||||||
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions) {
|
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions) {
|
||||||
!prefs.getBoolean("bottom_nav", false)
|
NavCustomization.getNavMode(prefs) != NavCustomization.NAV_MODE_BOTTOM
|
||||||
|
}
|
||||||
|
|
||||||
|
// Circular nav shortcuts
|
||||||
|
circularSlots.clear()
|
||||||
|
circularSlots.addAll(NavCustomization.getCircularSlots(prefs))
|
||||||
|
circularSlotAdapter = NavItemAdapter(
|
||||||
|
items = circularSlots,
|
||||||
|
onSave = { NavCustomization.saveCircularSlots(prefs, circularSlots) },
|
||||||
|
isEnabled = { NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_CIRCULAR }
|
||||||
|
)
|
||||||
|
setupNavItemRecyclerView(binding.rvCircularSlots, circularSlotAdapter, circularSlots) {
|
||||||
|
NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_CIRCULAR
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bottom bar shortcuts
|
// Bottom bar shortcuts
|
||||||
@@ -78,10 +112,10 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
NavCustomization.saveSlots(prefs, slots)
|
NavCustomization.saveSlots(prefs, slots)
|
||||||
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
|
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
|
||||||
},
|
},
|
||||||
isEnabled = { prefs.getBoolean("bottom_nav", false) }
|
isEnabled = { NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM }
|
||||||
)
|
)
|
||||||
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) {
|
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) {
|
||||||
prefs.getBoolean("bottom_nav", false)
|
NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM
|
||||||
}
|
}
|
||||||
// Show labels toggle
|
// Show labels toggle
|
||||||
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
|
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
|
||||||
@@ -102,6 +136,7 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
})
|
})
|
||||||
binding.themeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
binding.themeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
if (!isChecked) return@addOnButtonCheckedListener
|
if (!isChecked) return@addOnButtonCheckedListener
|
||||||
|
val previousKey = prefs.getString("theme", "system")
|
||||||
val (key, mode) = when (checkedId) {
|
val (key, mode) = when (checkedId) {
|
||||||
R.id.btnThemeLight -> "light" to AppCompatDelegate.MODE_NIGHT_NO
|
R.id.btnThemeLight -> "light" to AppCompatDelegate.MODE_NIGHT_NO
|
||||||
R.id.btnThemeDark -> "dark" to AppCompatDelegate.MODE_NIGHT_YES
|
R.id.btnThemeDark -> "dark" to AppCompatDelegate.MODE_NIGHT_YES
|
||||||
@@ -111,6 +146,16 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
AppCompatDelegate.setDefaultNightMode(mode)
|
AppCompatDelegate.setDefaultNightMode(mode)
|
||||||
updateAccentState(key == "system")
|
updateAccentState(key == "system")
|
||||||
updatePitchBlackState(key == "dark")
|
updatePitchBlackState(key == "dark")
|
||||||
|
if (key == "system") {
|
||||||
|
requireActivity().recreate()
|
||||||
|
} else if (previousKey == "system") {
|
||||||
|
// setDefaultNightMode only recreates if the effective mode changes.
|
||||||
|
// If system was already dark and we switch to dark (or light→light),
|
||||||
|
// no recreation is triggered and the custom accent never gets applied.
|
||||||
|
val currentIsNight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
val newIsNight = mode == AppCompatDelegate.MODE_NIGHT_YES
|
||||||
|
if (currentIsNight == newIsNight) requireActivity().recreate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pitch black
|
// Pitch black
|
||||||
@@ -122,6 +167,12 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
val isDark = prefs.getString("theme", "system") == "dark"
|
val isDark = prefs.getString("theme", "system") == "dark"
|
||||||
updatePitchBlackState(isDark)
|
updatePitchBlackState(isDark)
|
||||||
|
|
||||||
|
// Receipts
|
||||||
|
binding.switchFullscreenReceipt.isChecked = prefs.getBoolean("always_fullscreen_receipt", false)
|
||||||
|
binding.switchFullscreenReceipt.setOnCheckedChangeListener { _, checked ->
|
||||||
|
prefs.edit().putBoolean("always_fullscreen_receipt", checked).apply()
|
||||||
|
}
|
||||||
|
|
||||||
// Accent color
|
// Accent color
|
||||||
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
||||||
binding.accentToggle.check(when (savedPreset) {
|
binding.accentToggle.check(when (savedPreset) {
|
||||||
@@ -191,11 +242,15 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateShortcutsVisibility() {
|
private fun updateShortcutsVisibility() {
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
val mode = NavCustomization.getNavMode(prefs)
|
||||||
binding.sectionQuickActions.alpha = if (isBottom) 0.38f else 1f
|
val isBottom = mode == NavCustomization.NAV_MODE_BOTTOM
|
||||||
|
val isCircular = mode == NavCustomization.NAV_MODE_CIRCULAR
|
||||||
|
binding.sectionQuickActions.alpha = if (!isBottom) 1f else 0.38f
|
||||||
|
binding.sectionCircularSlots.alpha = if (isCircular) 1f else 0.38f
|
||||||
binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
|
binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
|
||||||
binding.switchShowLabels.isClickable = isBottom
|
binding.switchShowLabels.isClickable = isBottom
|
||||||
quickActionAdapter.notifyDataSetChanged()
|
quickActionAdapter.notifyDataSetChanged()
|
||||||
|
circularSlotAdapter.notifyDataSetChanged()
|
||||||
slotAdapter.notifyDataSetChanged()
|
slotAdapter.notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,9 +317,10 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
|
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
val mode = NavCustomization.getNavMode(prefs)
|
||||||
if (items === slots && !isBottom) return
|
if (items === slots && mode != NavCustomization.NAV_MODE_BOTTOM) return
|
||||||
if (items === quickActions && isBottom) return
|
if (items === quickActions && mode == NavCustomization.NAV_MODE_BOTTOM) return
|
||||||
|
if (items === circularSlots && mode != NavCustomization.NAV_MODE_CIRCULAR) return
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
|
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
|
||||||
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
|
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import android.widget.LinearLayout
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
|
|
||||||
@@ -25,13 +27,23 @@ class SettingsFragment : Fragment() {
|
|||||||
SettingsItem(R.drawable.ic_contacts, R.string.settings_logins, R.string.settings_desc_logins) { SettingsLoginsFragment() },
|
SettingsItem(R.drawable.ic_contacts, R.string.settings_logins, R.string.settings_desc_logins) { SettingsLoginsFragment() },
|
||||||
SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance, R.string.settings_desc_appearance) { SettingsAppearanceFragment() },
|
SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance, R.string.settings_desc_appearance) { SettingsAppearanceFragment() },
|
||||||
SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security, R.string.settings_desc_privacy_security) { SettingsSecurityFragment() },
|
SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security, R.string.settings_desc_privacy_security) { SettingsSecurityFragment() },
|
||||||
|
SettingsItem(R.drawable.ic_bell_filled, R.string.settings_notifications, R.string.settings_desc_notifications) { SettingsNotificationsFragment() },
|
||||||
SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage, R.string.settings_desc_storage) { SettingsStorageFragment() },
|
SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage, R.string.settings_desc_storage) { SettingsStorageFragment() },
|
||||||
|
SettingsItem(R.drawable.ic_info, R.string.settings_about, R.string.settings_desc_about) { SettingsAboutFragment() },
|
||||||
)
|
)
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
||||||
inflater.inflate(R.layout.fragment_settings, container, false)
|
inflater.inflate(R.layout.fragment_settings, container, false)
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
(view as? android.widget.ScrollView)?.clipToPadding = false
|
||||||
|
val basePaddingBottom = view.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
val list = view.findViewById<LinearLayout>(R.id.settingsList)
|
val list = view.findViewById<LinearLayout>(R.id.settingsList)
|
||||||
val inflater = LayoutInflater.from(requireContext())
|
val inflater = LayoutInflater.from(requireContext())
|
||||||
for (item in items) {
|
for (item in items) {
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import android.graphics.BitmapFactory
|
|||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.view.Gravity
|
import android.view.Gravity
|
||||||
|
import android.view.HapticFeedbackConstants
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
@@ -20,6 +23,8 @@ import android.widget.TextView
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
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 com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
@@ -28,6 +33,7 @@ import sh.sar.basedbank.BasedBankApp
|
|||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlProfile
|
import sh.sar.basedbank.api.bml.BmlProfile
|
||||||
import sh.sar.basedbank.api.mib.MibProfile
|
import sh.sar.basedbank.api.mib.MibProfile
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.TransactionCache
|
import sh.sar.basedbank.api.mib.TransactionCache
|
||||||
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
||||||
import sh.sar.basedbank.ui.login.LoginActivity
|
import sh.sar.basedbank.ui.login.LoginActivity
|
||||||
@@ -333,6 +339,14 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
|
||||||
|
val basePaddingBottom = binding.root.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
binding.btnAddAccount.setOnClickListener {
|
binding.btnAddAccount.setOnClickListener {
|
||||||
startActivity(Intent(requireContext(), LoginActivity::class.java))
|
startActivity(Intent(requireContext(), LoginActivity::class.java))
|
||||||
}
|
}
|
||||||
@@ -349,56 +363,86 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class LoginEntry(val key: String, val logoRes: Int, val displayName: String, val onClick: () -> Unit)
|
||||||
|
|
||||||
private fun buildLoginsSection() {
|
private fun buildLoginsSection() {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val store = CredentialStore(ctx)
|
val store = CredentialStore(ctx)
|
||||||
val container = binding.loginsContainer
|
val container = binding.loginsContainer
|
||||||
container.removeAllViews()
|
container.removeAllViews()
|
||||||
|
|
||||||
val mibLoginIds = store.getMibLoginIds()
|
val entries = mutableListOf<LoginEntry>()
|
||||||
val bmlLoginIds = store.getBmlLoginIds()
|
|
||||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
|
||||||
|
|
||||||
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
|
for (loginId in store.getMibLoginIds()) {
|
||||||
|
|
||||||
for (loginId in mibLoginIds) {
|
|
||||||
val profile = store.loadMibUserProfile(loginId)
|
val profile = store.loadMibUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
||||||
val mibProfiles = store.loadMibProfiles(loginId)
|
val mibProfiles = store.loadMibProfiles(loginId)
|
||||||
addLoginRow(container, R.drawable.mib_logo, displayName) {
|
entries += LoginEntry(CredentialStore.loginKey("mib", loginId), R.drawable.mib_logo, displayName) {
|
||||||
showMibLoginDetails(store, loginId, profile, mibProfiles)
|
showMibLoginDetails(store, loginId, profile, mibProfiles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (loginId in bmlLoginIds) {
|
for (loginId in store.getBmlLoginIds()) {
|
||||||
val profile = store.loadBmlUserProfile(loginId)
|
val profile = store.loadBmlUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
||||||
val bmlProfiles = store.loadBmlProfiles(loginId)
|
val bmlProfiles = store.loadBmlProfiles(loginId)
|
||||||
addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
|
entries += LoginEntry(CredentialStore.loginKey("bml", loginId), R.drawable.bml_logo_vector, displayName) {
|
||||||
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
|
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (loginId in fahipayLoginIds) {
|
for (loginId in store.getFahipayLoginIds()) {
|
||||||
val profile = store.loadFahipayUserProfile(loginId)
|
val profile = store.loadFahipayUserProfile(loginId)
|
||||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
||||||
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
|
entries += LoginEntry(CredentialStore.loginKey("fahipay", loginId), R.drawable.fahipay_logo, displayName) {
|
||||||
showFahipayLoginDetails(store, loginId, profile)
|
showFahipayLoginDetails(store, loginId, profile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (loginId in store.getMfaisaLoginIds()) {
|
||||||
|
val profile = store.loadMfaisaUserProfile(loginId)
|
||||||
|
val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name)
|
||||||
|
entries += LoginEntry(CredentialStore.loginKey("mfaisa", loginId), R.drawable.ooredoo_logo, displayName) {
|
||||||
|
showMfaisaLoginDetails(store, loginId, profile)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun addLoginRow(container: LinearLayout, logoRes: Int, displayName: String, onClick: () -> Unit) {
|
val rank = store.loginRank()
|
||||||
|
val sorted = entries.sortedBy { rank(it.key) }
|
||||||
|
val draggable = sorted.size > 1
|
||||||
|
for (entry in sorted) {
|
||||||
|
addLoginRow(
|
||||||
|
container, entry.logoRes, entry.displayName, entry.onClick,
|
||||||
|
dragTag = entry.key.takeIf { draggable },
|
||||||
|
onReordered = { order ->
|
||||||
|
store.setLoginOrder(order)
|
||||||
|
viewModel.resortByLoginOrder()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.tvLoginsTitle.visibility = if (entries.isNotEmpty()) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addLoginRow(
|
||||||
|
container: LinearLayout,
|
||||||
|
logoRes: Int,
|
||||||
|
displayName: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
dragTag: String? = null,
|
||||||
|
onReordered: (List<String>) -> Unit = {}
|
||||||
|
) {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val dp = ctx.resources.displayMetrics.density
|
val dp = ctx.resources.displayMetrics.density
|
||||||
val row = LinearLayout(ctx).apply {
|
val row = LinearLayout(ctx).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
gravity = Gravity.CENTER_VERTICAL
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
setPadding(0, (12 * dp).toInt(), 0, (12 * dp).toInt())
|
setPadding((12 * dp).toInt(), (12 * dp).toInt(), (12 * dp).toInt(), (12 * dp).toInt())
|
||||||
isClickable = true; isFocusable = true
|
isClickable = true; isFocusable = true
|
||||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||||
background = ta.getDrawable(0); ta.recycle()
|
background = ta.getDrawable(0); ta.recycle()
|
||||||
setOnClickListener { onClick() }
|
setOnClickListener { onClick() }
|
||||||
|
tag = dragTag
|
||||||
}
|
}
|
||||||
val logo = ImageView(ctx).apply {
|
val logo = ImageView(ctx).apply {
|
||||||
setImageResource(logoRes)
|
setImageResource(logoRes)
|
||||||
@@ -411,9 +455,195 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
}
|
}
|
||||||
row.addView(logo); row.addView(tvName)
|
row.addView(logo); row.addView(tvName)
|
||||||
|
if (dragTag != null) {
|
||||||
|
val handle = ImageView(ctx).apply {
|
||||||
|
setImageResource(R.drawable.ic_reorder_lines)
|
||||||
|
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||||||
|
contentDescription = getString(R.string.drag_to_reorder)
|
||||||
|
layoutParams = LinearLayout.LayoutParams((40 * dp).toInt(), (40 * dp).toInt()).apply { marginStart = (4 * dp).toInt() }
|
||||||
|
}
|
||||||
|
row.addView(handle)
|
||||||
|
attachDragHandle(handle, row, container, onReordered)
|
||||||
|
}
|
||||||
container.addView(row)
|
container.addView(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Drag [row] vertically within [group] by its [handle]; rows are identified by their String tag. */
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
|
private fun attachDragHandle(handle: View, row: View, group: LinearLayout, onReordered: (List<String>) -> Unit) {
|
||||||
|
val ctx = handle.context
|
||||||
|
val lift = 8 * ctx.resources.displayMetrics.density
|
||||||
|
val dragBg = android.graphics.drawable.ColorDrawable(
|
||||||
|
com.google.android.material.color.MaterialColors.getColor(
|
||||||
|
ctx, com.google.android.material.R.attr.colorSurfaceContainerHigh, android.graphics.Color.LTGRAY))
|
||||||
|
var lastY = 0f
|
||||||
|
var startIndex = 0
|
||||||
|
var savedBg: android.graphics.drawable.Drawable? = null
|
||||||
|
|
||||||
|
handle.setOnTouchListener { v, ev ->
|
||||||
|
when (ev.actionMasked) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
v.parent.requestDisallowInterceptTouchEvent(true)
|
||||||
|
lastY = ev.rawY
|
||||||
|
startIndex = group.indexOfChild(row)
|
||||||
|
row.animate().cancel()
|
||||||
|
savedBg = row.background
|
||||||
|
row.background = dragBg
|
||||||
|
row.translationZ = lift
|
||||||
|
v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_MOVE -> {
|
||||||
|
row.translationY += ev.rawY - lastY
|
||||||
|
lastY = ev.rawY
|
||||||
|
var index = group.indexOfChild(row)
|
||||||
|
while (index < group.childCount - 1) {
|
||||||
|
val next = group.getChildAt(index + 1)
|
||||||
|
if (row.translationY <= next.height / 2f) break
|
||||||
|
group.removeView(next); group.addView(next, index)
|
||||||
|
row.translationY -= next.height
|
||||||
|
next.translationY = row.height.toFloat()
|
||||||
|
next.animate().translationY(0f).setDuration(150).start()
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
while (index > 0) {
|
||||||
|
val prev = group.getChildAt(index - 1)
|
||||||
|
if (row.translationY >= -prev.height / 2f) break
|
||||||
|
group.removeView(prev); group.addView(prev, index)
|
||||||
|
row.translationY += prev.height
|
||||||
|
prev.translationY = -row.height.toFloat()
|
||||||
|
prev.animate().translationY(0f).setDuration(150).start()
|
||||||
|
index--
|
||||||
|
}
|
||||||
|
if (index == 0) row.translationY = row.translationY.coerceAtLeast(0f)
|
||||||
|
if (index == group.childCount - 1) row.translationY = row.translationY.coerceAtMost(0f)
|
||||||
|
}
|
||||||
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||||
|
v.parent.requestDisallowInterceptTouchEvent(false)
|
||||||
|
row.animate().translationY(0f).translationZ(0f).setDuration(150)
|
||||||
|
.withEndAction { row.background = savedBg }
|
||||||
|
.start()
|
||||||
|
if (group.indexOfChild(row) != startIndex) {
|
||||||
|
onReordered((0 until group.childCount).mapNotNull { group.getChildAt(it).tag as? String })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A single account row with a visibility toggle; [indent] nests it under a parent profile row (tree view). */
|
||||||
|
private fun addAccountRow(
|
||||||
|
ctx: Context,
|
||||||
|
container: LinearLayout,
|
||||||
|
dp: Float,
|
||||||
|
acc: BankAccount,
|
||||||
|
hiddenAccounts: MutableSet<String>,
|
||||||
|
indent: Boolean
|
||||||
|
): Pair<BankAccount, MaterialSwitch> {
|
||||||
|
val row = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||||
|
it.bottomMargin = (4 * dp).toInt()
|
||||||
|
if (indent) it.marginStart = (28 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val textCol = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
|
}
|
||||||
|
val nameAppearance = if (indent) com.google.android.material.R.style.TextAppearance_Material3_BodySmall
|
||||||
|
else com.google.android.material.R.style.TextAppearance_Material3_BodyMedium
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = acc.accountBriefName.ifBlank { acc.accountTypeName.ifBlank { acc.accountNumber } }
|
||||||
|
setTextAppearance(nameAppearance)
|
||||||
|
})
|
||||||
|
val typeLabel = sh.sar.basedbank.util.AccountListParser.from(acc)?.typeLabel
|
||||||
|
?: if (acc.bank == "BML") sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(acc.accountTypeName)
|
||||||
|
else acc.accountTypeName.trim()
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = listOfNotNull(acc.accountNumber, typeLabel.takeIf { it.isNotBlank() }, acc.currencyName.takeIf { it.isNotBlank() })
|
||||||
|
.joinToString(" · ")
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||||
|
alpha = 0.6f
|
||||||
|
})
|
||||||
|
val toggle = MaterialSwitch(ctx).apply {
|
||||||
|
isChecked = acc.accountNumber !in hiddenAccounts
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
marginStart = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.addView(textCol)
|
||||||
|
row.addView(toggle)
|
||||||
|
container.addView(row)
|
||||||
|
return acc to toggle
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nests [accounts] directly under their parent profile row, without a header — the tree's leaves. */
|
||||||
|
private fun addNestedAccountRows(
|
||||||
|
ctx: Context,
|
||||||
|
container: LinearLayout,
|
||||||
|
dp: Float,
|
||||||
|
accounts: List<BankAccount>,
|
||||||
|
hiddenAccounts: MutableSet<String>
|
||||||
|
): List<Pair<BankAccount, MaterialSwitch>> =
|
||||||
|
accounts.map { addAccountRow(ctx, container, dp, it, hiddenAccounts, indent = true) }
|
||||||
|
|
||||||
|
/** Builds a headered, flat "Accounts" section for accounts with no profile to nest under. */
|
||||||
|
private fun addAccountsSection(
|
||||||
|
ctx: Context,
|
||||||
|
container: LinearLayout,
|
||||||
|
dp: Float,
|
||||||
|
accounts: List<BankAccount>,
|
||||||
|
hiddenAccounts: MutableSet<String>,
|
||||||
|
showDivider: Boolean
|
||||||
|
): List<Pair<BankAccount, MaterialSwitch>> {
|
||||||
|
if (accounts.isEmpty()) return emptyList()
|
||||||
|
if (showDivider) {
|
||||||
|
container.addView(View(ctx).apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
|
||||||
|
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
setBackgroundColor(0x1F000000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
container.addView(TextView(ctx).apply {
|
||||||
|
text = getString(R.string.accounts)
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||||
|
it.bottomMargin = (8 * dp).toInt()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return accounts.map { addAccountRow(ctx, container, dp, it, hiddenAccounts, indent = false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unchecks and disables account toggles whose parent profile is hidden, and restores the
|
||||||
|
* account's own choice once the profile is shown again. [hiddenAccounts] is left untouched —
|
||||||
|
* the account listeners ignore changes made while a toggle is disabled.
|
||||||
|
*/
|
||||||
|
private fun syncAccountToggles(
|
||||||
|
accountRows: List<Pair<BankAccount, MaterialSwitch>>,
|
||||||
|
hiddenAccounts: Set<String>,
|
||||||
|
isParentHidden: (BankAccount) -> Boolean
|
||||||
|
) {
|
||||||
|
accountRows.forEach { (acc, toggle) ->
|
||||||
|
if (isParentHidden(acc)) {
|
||||||
|
toggle.isEnabled = false
|
||||||
|
toggle.isChecked = false
|
||||||
|
} else {
|
||||||
|
toggle.isChecked = acc.accountNumber !in hiddenAccounts
|
||||||
|
toggle.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merges this login's account-hide choices into the global hidden-accounts set, leaving other logins untouched. */
|
||||||
|
private fun persistHiddenAccounts(store: CredentialStore, scopedAccounts: List<BankAccount>, hiddenAccounts: Set<String>) {
|
||||||
|
val scopedNumbers = scopedAccounts.map { it.accountNumber }.toSet()
|
||||||
|
store.setHiddenAccountNumbers((store.getHiddenAccountNumbers() - scopedNumbers) + hiddenAccounts)
|
||||||
|
}
|
||||||
|
|
||||||
private fun showMibLoginDetails(
|
private fun showMibLoginDetails(
|
||||||
store: CredentialStore,
|
store: CredentialStore,
|
||||||
loginId: String,
|
loginId: String,
|
||||||
@@ -424,6 +654,10 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
val dp = ctx.resources.displayMetrics.density
|
val dp = ctx.resources.displayMetrics.density
|
||||||
val originalHidden = store.getHiddenMibProfileIds(loginId)
|
val originalHidden = store.getHiddenMibProfileIds(loginId)
|
||||||
val hidden = originalHidden.toMutableSet()
|
val hidden = originalHidden.toMutableSet()
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val loginAccounts = app.mibAccounts.filter { it.loginTag == "mib_$loginId" }
|
||||||
|
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
|
||||||
|
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
|
||||||
|
|
||||||
val scroll = android.widget.ScrollView(ctx)
|
val scroll = android.widget.ScrollView(ctx)
|
||||||
val container = LinearLayout(ctx).apply {
|
val container = LinearLayout(ctx).apply {
|
||||||
@@ -466,7 +700,10 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build toggle rows — wired up after dialog.show() so we can reference the Save button
|
// Build toggle rows — wired up after dialog.show() so we can reference the Save button.
|
||||||
|
// Each profile's own accounts nest directly beneath it (tree view), since one profile
|
||||||
|
// can have multiple accounts.
|
||||||
|
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
|
||||||
val toggleRows = mibProfiles.map { p ->
|
val toggleRows = mibProfiles.map { p ->
|
||||||
val row = LinearLayout(ctx).apply {
|
val row = LinearLayout(ctx).apply {
|
||||||
orientation = LinearLayout.HORIZONTAL
|
orientation = LinearLayout.HORIZONTAL
|
||||||
@@ -507,16 +744,25 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
row.addView(pencil)
|
row.addView(pencil)
|
||||||
row.addView(toggle)
|
row.addView(toggle)
|
||||||
container.addView(row)
|
container.addView(row)
|
||||||
|
accountRows += addNestedAccountRows(ctx, container, dp, loginAccounts.filter { it.profileId == p.profileId }, hiddenAccounts)
|
||||||
p to toggle
|
p to toggle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accounts that don't belong to any known profile still need to be reachable.
|
||||||
|
val unassignedAccounts = loginAccounts.filter { acc -> mibProfiles.none { it.profileId == acc.profileId } }
|
||||||
|
accountRows += addAccountsSection(
|
||||||
|
ctx, container, dp, unassignedAccounts, hiddenAccounts,
|
||||||
|
showDivider = mibProfiles.isNotEmpty() || profile != null
|
||||||
|
)
|
||||||
|
|
||||||
fun updateToggleStates(saveBtn: android.widget.Button) {
|
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||||
val visibleCount = mibProfiles.count { it.profileId !in hidden }
|
val visibleCount = mibProfiles.count { it.profileId !in hidden }
|
||||||
toggleRows.forEach { (p, toggle) ->
|
toggleRows.forEach { (p, toggle) ->
|
||||||
// Disable the sole remaining visible toggle so it can't be turned off
|
// Disable the sole remaining visible toggle so it can't be turned off
|
||||||
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||||
}
|
}
|
||||||
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
syncAccountToggles(accountRows, hiddenAccounts) { it.profileId in hidden }
|
||||||
|
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
|
||||||
}
|
}
|
||||||
|
|
||||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
@@ -540,11 +786,22 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accountRows.forEach { (acc, toggle) ->
|
||||||
|
toggle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden parent profile
|
||||||
|
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
saveBtn.setOnClickListener {
|
saveBtn.setOnClickListener {
|
||||||
store.setHiddenMibProfileIds(loginId, hidden)
|
store.setHiddenMibProfileIds(loginId, hidden)
|
||||||
clearAllCaches(ctx)
|
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
(activity as? HomeActivity)?.relogin()
|
// Hiding is applied offline; only profiles that were just unhidden get fetched
|
||||||
|
val home = activity as? HomeActivity
|
||||||
|
home?.applyVisibility()
|
||||||
|
home?.fetchEnabledMibProfiles(loginId, originalHidden - hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -566,6 +823,10 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
if (hidden.add(id)) store.setHiddenBmlProfileIds(loginId, hidden)
|
if (hidden.add(id)) store.setHiddenBmlProfileIds(loginId, hidden)
|
||||||
}
|
}
|
||||||
val originalHidden = hidden.toSet()
|
val originalHidden = hidden.toSet()
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val loginAccounts = app.bmlAccounts.filter { it.loginTag == "bml_$loginId" }
|
||||||
|
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
|
||||||
|
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
|
||||||
|
|
||||||
val scroll = android.widget.ScrollView(ctx)
|
val scroll = android.widget.ScrollView(ctx)
|
||||||
val container = LinearLayout(ctx).apply {
|
val container = LinearLayout(ctx).apply {
|
||||||
@@ -611,6 +872,9 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Each profile's own accounts nest directly beneath it (tree view), since one profile
|
||||||
|
// can have multiple accounts.
|
||||||
|
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
|
||||||
val toggleRows = bmlProfiles.map { p ->
|
val toggleRows = bmlProfiles.map { p ->
|
||||||
val avatarIv = makeCircleAvatarView(ctx, 36)
|
val avatarIv = makeCircleAvatarView(ctx, 36)
|
||||||
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
|
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
|
||||||
@@ -658,15 +922,24 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
row.addView(pencil)
|
row.addView(pencil)
|
||||||
row.addView(toggle)
|
row.addView(toggle)
|
||||||
container.addView(row)
|
container.addView(row)
|
||||||
|
accountRows += addNestedAccountRows(ctx, container, dp, loginAccounts.filter { it.profileId == p.profileId }, hiddenAccounts)
|
||||||
p to toggle
|
p to toggle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accounts that don't belong to any known profile still need to be reachable.
|
||||||
|
val unassignedAccounts = loginAccounts.filter { acc -> bmlProfiles.none { it.profileId == acc.profileId } }
|
||||||
|
accountRows += addAccountsSection(
|
||||||
|
ctx, container, dp, unassignedAccounts, hiddenAccounts,
|
||||||
|
showDivider = bmlProfiles.isNotEmpty() || profile != null
|
||||||
|
)
|
||||||
|
|
||||||
fun updateToggleStates(saveBtn: android.widget.Button) {
|
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||||
val visibleCount = bmlProfiles.count { it.profileId !in hidden }
|
val visibleCount = bmlProfiles.count { it.profileId !in hidden }
|
||||||
toggleRows.forEach { (_, toggle) ->
|
toggleRows.forEach { (_, toggle) ->
|
||||||
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||||
}
|
}
|
||||||
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
syncAccountToggles(accountRows, hiddenAccounts) { it.profileId in hidden }
|
||||||
|
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
|
||||||
}
|
}
|
||||||
|
|
||||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
@@ -701,11 +974,22 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
accountRows.forEach { (acc, toggle) ->
|
||||||
|
toggle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden parent profile
|
||||||
|
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
saveBtn.setOnClickListener {
|
saveBtn.setOnClickListener {
|
||||||
store.setHiddenBmlProfileIds(loginId, hidden)
|
store.setHiddenBmlProfileIds(loginId, hidden)
|
||||||
clearAllCaches(ctx)
|
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
(activity as? HomeActivity)?.relogin()
|
// Hiding is applied offline; only profiles that were just unhidden get fetched
|
||||||
|
val home = activity as? HomeActivity
|
||||||
|
home?.applyVisibility()
|
||||||
|
home?.fetchEnabledBmlProfiles(loginId, originalHidden - hidden)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -928,6 +1212,10 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
val dp = ctx.resources.displayMetrics.density
|
val dp = ctx.resources.displayMetrics.density
|
||||||
val hide = viewModel.hideAmounts.value ?: false
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
val masked = "••••••"
|
val masked = "••••••"
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
val loginAccounts = app.fahipayAccounts.filter { it.loginTag == "fahipay_$loginId" }
|
||||||
|
val originalHiddenAccounts = loginAccounts.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
|
||||||
|
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
|
||||||
|
|
||||||
val scroll = android.widget.ScrollView(ctx)
|
val scroll = android.widget.ScrollView(ctx)
|
||||||
val container = LinearLayout(ctx).apply {
|
val container = LinearLayout(ctx).apply {
|
||||||
@@ -978,14 +1266,37 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialAlertDialogBuilder(ctx)
|
val accountRows = addAccountsSection(ctx, container, dp, loginAccounts, hiddenAccounts, showDivider = true)
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
.setTitle(getString(R.string.fahipay_name))
|
.setTitle(getString(R.string.fahipay_name))
|
||||||
.setView(scroll)
|
.setView(scroll)
|
||||||
.setPositiveButton(R.string.close, null)
|
.apply {
|
||||||
.setNegativeButton(R.string.settings_logout) { _, _ ->
|
if (loginAccounts.isNotEmpty()) setPositiveButton(R.string.save, null)
|
||||||
|
setNeutralButton(R.string.close, null)
|
||||||
|
setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||||
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
|
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
.show()
|
.show()
|
||||||
|
|
||||||
|
if (loginAccounts.isNotEmpty()) {
|
||||||
|
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||||
|
saveBtn.isEnabled = false
|
||||||
|
|
||||||
|
accountRows.forEach { (acc, toggle) ->
|
||||||
|
toggle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
|
||||||
|
saveBtn.isEnabled = hiddenAccounts != originalHiddenAccounts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveBtn.setOnClickListener {
|
||||||
|
persistHiddenAccounts(store, loginAccounts, hiddenAccounts)
|
||||||
|
dialog.dismiss()
|
||||||
|
(activity as? HomeActivity)?.applyVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
||||||
@@ -1055,6 +1366,168 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
buildLoginsSection()
|
buildLoginsSection()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun showMfaisaLoginDetails(
|
||||||
|
store: CredentialStore,
|
||||||
|
loginId: String,
|
||||||
|
profile: CredentialStore.MfaisaUserProfile?
|
||||||
|
) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
val masked = "••••••"
|
||||||
|
|
||||||
|
val pockets = sh.sar.basedbank.util.AccountCache.loadMfaisa(ctx, loginId)
|
||||||
|
val hidden = store.getHiddenMfaisaPocketIds(loginId).toMutableSet()
|
||||||
|
val originalHidden = hidden.toSet()
|
||||||
|
val originalHiddenAccounts = pockets.map { it.accountNumber }.filter { it in store.getHiddenAccountNumbers() }.toSet()
|
||||||
|
val hiddenAccounts = originalHiddenAccounts.toMutableSet()
|
||||||
|
|
||||||
|
// The user-visible "profiles" are: M-Faisa (every non-PayPal pocket) and PayPal (if linked).
|
||||||
|
// Each toggle covers the set of pocket account numbers that belong to that profile.
|
||||||
|
data class MfaisaProfileRow(val label: String, val pocketIds: Set<String>)
|
||||||
|
val mfaisaPockets = pockets.filter { it.profileType != "MFAISA_PAYPAL" }
|
||||||
|
val paypalPockets = pockets.filter { it.profileType == "MFAISA_PAYPAL" }
|
||||||
|
val profileRows = buildList {
|
||||||
|
if (mfaisaPockets.isNotEmpty()) {
|
||||||
|
add(MfaisaProfileRow("M-Faisa", mfaisaPockets.map { it.accountNumber }.toSet()))
|
||||||
|
}
|
||||||
|
if (paypalPockets.isNotEmpty()) {
|
||||||
|
add(MfaisaProfileRow("PayPal", paypalPockets.map { it.accountNumber }.toSet()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val scroll = android.widget.ScrollView(ctx)
|
||||||
|
val container = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val pad = (16 * dp).toInt()
|
||||||
|
setPadding(pad, (8 * dp).toInt(), pad, pad)
|
||||||
|
}
|
||||||
|
scroll.addView(container)
|
||||||
|
|
||||||
|
listOfNotNull(
|
||||||
|
profile?.name?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
|
||||||
|
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: ${if (hide) masked else it}" },
|
||||||
|
profile?.mdnId?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: ${if (hide) masked else it}" }
|
||||||
|
).forEach { line ->
|
||||||
|
container.addView(TextView(ctx).apply {
|
||||||
|
text = line
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
bottomMargin = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profileRows.isNotEmpty()) {
|
||||||
|
if (profile != null) {
|
||||||
|
container.addView(View(ctx).apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
|
||||||
|
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
setBackgroundColor(0x1F000000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
container.addView(TextView(ctx).apply {
|
||||||
|
text = getString(R.string.login_detail_profiles)
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||||
|
it.bottomMargin = (8 * dp).toInt()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each group's own pockets nest directly beneath it (tree view), since a group
|
||||||
|
// ("M-Faisa" / "PayPal") can hold multiple pocket accounts.
|
||||||
|
val accountRows = mutableListOf<Pair<BankAccount, MaterialSwitch>>()
|
||||||
|
val toggleRows = profileRows.map { row ->
|
||||||
|
val v = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||||
|
it.bottomMargin = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val label = TextView(ctx).apply {
|
||||||
|
text = row.label
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
|
}
|
||||||
|
val toggle = MaterialSwitch(ctx).apply {
|
||||||
|
isChecked = row.pocketIds.any { it !in hidden }
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
marginStart = (4 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v.addView(label)
|
||||||
|
v.addView(toggle)
|
||||||
|
container.addView(v)
|
||||||
|
accountRows += addNestedAccountRows(ctx, container, dp, pockets.filter { it.accountNumber in row.pocketIds }, hiddenAccounts)
|
||||||
|
row to toggle
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||||
|
val visibleCount = toggleRows.count { (row, _) -> row.pocketIds.any { it !in hidden } }
|
||||||
|
toggleRows.forEach { (_, toggle) ->
|
||||||
|
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||||
|
}
|
||||||
|
syncAccountToggles(accountRows, hiddenAccounts) { it.accountNumber in hidden }
|
||||||
|
saveBtn.isEnabled = (hidden != originalHidden || hiddenAccounts != originalHiddenAccounts) && visibleCount >= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(getString(R.string.ooredoo_name))
|
||||||
|
.setView(scroll)
|
||||||
|
.apply {
|
||||||
|
if (profileRows.isNotEmpty()) setPositiveButton(R.string.save, null)
|
||||||
|
setNeutralButton(R.string.close, null)
|
||||||
|
setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||||
|
confirmLogout(getString(R.string.ooredoo_name)) { logoutMfaisa(store, loginId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
|
||||||
|
if (profileRows.isNotEmpty()) {
|
||||||
|
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||||
|
saveBtn.isEnabled = false
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
|
||||||
|
toggleRows.forEach { (row, toggle) ->
|
||||||
|
toggle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (checked) hidden.removeAll(row.pocketIds) else hidden.addAll(row.pocketIds)
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
accountRows.forEach { (acc, toggle) ->
|
||||||
|
toggle.setOnCheckedChangeListener { _, checked ->
|
||||||
|
if (!toggle.isEnabled) return@setOnCheckedChangeListener // driven by a hidden pocket group
|
||||||
|
if (checked) hiddenAccounts.remove(acc.accountNumber) else hiddenAccounts.add(acc.accountNumber)
|
||||||
|
updateToggleStates(saveBtn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saveBtn.setOnClickListener {
|
||||||
|
// All pockets come back in one login response, so hiding/unhiding never needs a request
|
||||||
|
store.setHiddenMfaisaPocketIds(loginId, hidden)
|
||||||
|
persistHiddenAccounts(store, pockets, hiddenAccounts)
|
||||||
|
dialog.dismiss()
|
||||||
|
(activity as? HomeActivity)?.applyVisibility()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun logoutMfaisa(store: CredentialStore, loginId: String) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
store.clearMfaisaCredentials(loginId)
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
app.mfaisaSessions.remove(loginId)
|
||||||
|
app.mfaisaAccounts = app.mfaisaAccounts.filter { it.loginTag != "mfaisa_$loginId" }
|
||||||
|
app.accounts = app.accounts.filter { it.loginTag != "mfaisa_$loginId" }
|
||||||
|
clearAllCaches(ctx)
|
||||||
|
(activity as HomeActivity).relogin()
|
||||||
|
buildLoginsSection()
|
||||||
|
}
|
||||||
|
|
||||||
private fun clearAllCaches(ctx: Context) {
|
private fun clearAllCaches(ctx: Context) {
|
||||||
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
||||||
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.PowerManager
|
||||||
|
import android.provider.Settings
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.ScrollView
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.appcompat.widget.SwitchCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.service.NotificationPollingService
|
||||||
|
|
||||||
|
class SettingsNotificationsFragment : Fragment() {
|
||||||
|
|
||||||
|
private var switchView: SwitchCompat? = null
|
||||||
|
|
||||||
|
// Step 1: notification permission — on grant, proceed to battery opt check
|
||||||
|
private val permissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { granted ->
|
||||||
|
if (granted) checkBatteryOptimization() else switchView?.isChecked = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: battery optimization — proceed to enableService regardless of user choice
|
||||||
|
private val batteryOptLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult()
|
||||||
|
) {
|
||||||
|
enableService()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val prefs = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
val scroll = ScrollView(ctx).apply { clipToPadding = false }
|
||||||
|
|
||||||
|
val col = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||||
|
val p = (20 * dp).toInt()
|
||||||
|
setPadding(p, p, p, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section header
|
||||||
|
col.addView(TextView(ctx).apply {
|
||||||
|
setText(R.string.settings_notif_section)
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
|
||||||
|
setTextColor(MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimary, Color.CYAN))
|
||||||
|
setPadding(0, 0, 0, (12 * dp).toInt())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Enable toggle row
|
||||||
|
val sw = SwitchCompat(ctx).apply {
|
||||||
|
isChecked = prefs.getBoolean(PREF_ENABLED, false)
|
||||||
|
}
|
||||||
|
switchView = sw
|
||||||
|
sw.setOnCheckedChangeListener { _, on -> if (on) requestEnableNotifications() else disableService() }
|
||||||
|
|
||||||
|
val toggleRow = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||||
|
val vp = (10 * dp).toInt()
|
||||||
|
setPadding(0, vp, 0, vp)
|
||||||
|
}
|
||||||
|
val textCol = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||||
|
}
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
setText(R.string.settings_notif_enable)
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
||||||
|
})
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
setText(R.string.settings_notif_enable_desc)
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||||
|
alpha = 0.65f
|
||||||
|
})
|
||||||
|
toggleRow.addView(textCol)
|
||||||
|
toggleRow.addView(sw.apply {
|
||||||
|
layoutParams = (layoutParams as? LinearLayout.LayoutParams ?: LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
)).also { it.marginStart = (12 * dp).toInt() }
|
||||||
|
})
|
||||||
|
col.addView(toggleRow)
|
||||||
|
|
||||||
|
// Description
|
||||||
|
col.addView(TextView(ctx).apply {
|
||||||
|
setText(R.string.settings_notif_description)
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||||
|
alpha = 0.65f
|
||||||
|
setPadding(0, (4 * dp).toInt(), 0, (20 * dp).toInt())
|
||||||
|
})
|
||||||
|
|
||||||
|
// Notification channels nav row — same style as settings menu items
|
||||||
|
val colPad = (20 * dp).toInt()
|
||||||
|
val navRow = inflater.inflate(R.layout.item_more_nav, col, false).apply {
|
||||||
|
layoutParams = (layoutParams as LinearLayout.LayoutParams).apply {
|
||||||
|
marginStart = -colPad
|
||||||
|
marginEnd = -colPad
|
||||||
|
topMargin = (8 * dp).toInt()
|
||||||
|
}
|
||||||
|
findViewById<ImageView>(R.id.ivIcon).setImageResource(R.drawable.ic_bell_filled)
|
||||||
|
findViewById<TextView>(R.id.tvLabel).setText(R.string.settings_notif_open_system)
|
||||||
|
findViewById<TextView>(R.id.tvDescription).setText(R.string.settings_notif_channels_desc)
|
||||||
|
setOnClickListener {
|
||||||
|
startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||||
|
putExtra(Settings.EXTRA_APP_PACKAGE, ctx.packageName)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
col.addView(navRow)
|
||||||
|
|
||||||
|
scroll.addView(col)
|
||||||
|
return scroll
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
val basePad = view.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
||||||
|
val isBottom = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
.getBoolean("bottom_nav", false)
|
||||||
|
val nav = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePad + if (isBottom) 0 else nav.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.settings_notifications)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
switchView = null
|
||||||
|
super.onDestroyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Enable flow ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun requestEnableNotifications() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
|
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checkBatteryOptimization()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkBatteryOptimization() {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val pm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
if (!pm.isIgnoringBatteryOptimizations(ctx.packageName)) {
|
||||||
|
batteryOptLauncher.launch(
|
||||||
|
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||||
|
data = Uri.parse("package:${ctx.packageName}")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
enableService()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableService() {
|
||||||
|
val ctx = requireContext()
|
||||||
|
ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
.edit().putBoolean(PREF_ENABLED, true).apply()
|
||||||
|
ctx.startForegroundService(Intent(ctx, NotificationPollingService::class.java))
|
||||||
|
switchView?.isChecked = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disableService() {
|
||||||
|
val ctx = requireContext()
|
||||||
|
ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
.edit().putBoolean(PREF_ENABLED, false).apply()
|
||||||
|
ctx.stopService(Intent(ctx, NotificationPollingService::class.java))
|
||||||
|
switchView?.isChecked = false
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val PREF_ENABLED = "notifications_enabled"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import android.view.LayoutInflater
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.biometric.BiometricManager
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.databinding.FragmentSettingsSecurityBinding
|
import sh.sar.basedbank.databinding.FragmentSettingsSecurityBinding
|
||||||
@@ -22,6 +24,14 @@ class SettingsSecurityFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
|
||||||
|
val basePaddingBottom = binding.root.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
// Change lock
|
// Change lock
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ 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.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 sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
@@ -31,6 +33,14 @@ class SettingsStorageFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
|
||||||
|
val basePaddingBottom = binding.root.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||||
|
insets
|
||||||
|
}
|
||||||
binding.btnClearCache.setOnClickListener {
|
binding.btnClearCache.setOnClickListener {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
clearAllCaches(ctx)
|
clearAllCaches(ctx)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ import sh.sar.basedbank.BasedBankApp
|
|||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
||||||
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaHistoryClient
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||||
@@ -61,12 +62,18 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
var bmlTotalPages: Int = -1,
|
var bmlTotalPages: Int = -1,
|
||||||
var cardMonthOffset: Int = 0,
|
var cardMonthOffset: Int = 0,
|
||||||
var fahipayNextStart: Int = 0,
|
var fahipayNextStart: Int = 0,
|
||||||
var fahipayTotal: Int = -1
|
var fahipayTotal: Int = -1,
|
||||||
|
var mfaisaNextPage: Int = 1,
|
||||||
|
var mfaisaHasMore: Boolean = true
|
||||||
) {
|
) {
|
||||||
|
// PayPal pockets have no known history endpoint, so they don't paginate here.
|
||||||
|
private val isMfaisaPaypal get() = account.profileType == "MFAISA_PAYPAL"
|
||||||
|
|
||||||
fun hasMore(): Boolean = when {
|
fun hasMore(): Boolean = when {
|
||||||
account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||||
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT" -> cardMonthOffset < 2
|
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT" -> cardMonthOffset < 2
|
||||||
account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||||
|
account.bank == "MFAISA" -> !isMfaisaPaypal && mfaisaHasMore
|
||||||
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,13 +216,14 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
cal.add(Calendar.MONTH, -state.cardMonthOffset)
|
cal.add(Calendar.MONTH, -state.cardMonthOffset)
|
||||||
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
||||||
state.cardMonthOffset++
|
state.cardMonthOffset++
|
||||||
BmlHistoryClient().fetchCardHistory(
|
val cardResult = BmlHistoryClient().fetchCardHistory(
|
||||||
session = session,
|
session = session,
|
||||||
cardId = state.account.internalId,
|
cardId = state.account.internalId,
|
||||||
accountDisplayName = state.account.accountBriefName,
|
accountDisplayName = state.account.accountBriefName,
|
||||||
accountNumber = state.account.accountNumber,
|
accountNumber = state.account.accountNumber,
|
||||||
month = month
|
month = month
|
||||||
)
|
)
|
||||||
|
cardResult.statement + cardResult.outstanding + cardResult.unbilled
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
|
val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
|
||||||
@@ -235,6 +243,36 @@ class TransferHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}.awaitAll().flatten())
|
}.awaitAll().flatten())
|
||||||
|
|
||||||
|
// M-Faisa accounts (PayPal pockets are skipped by hasMore())
|
||||||
|
val mfaisaStates = activeStates.filter { it.account.bank == "MFAISA" }
|
||||||
|
for (state in mfaisaStates) {
|
||||||
|
var session = app.mfaisaSessionFor(state.account) ?: continue
|
||||||
|
try {
|
||||||
|
val page = try {
|
||||||
|
MfaisaHistoryClient().fetchHistory(
|
||||||
|
session = session,
|
||||||
|
accountNumber = state.account.accountNumber,
|
||||||
|
accountDisplayName = state.account.accountBriefName,
|
||||||
|
pageNo = state.mfaisaNextPage,
|
||||||
|
recordSize = 70
|
||||||
|
)
|
||||||
|
} catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) {
|
||||||
|
val loginId = state.account.loginTag.removePrefix("mfaisa_")
|
||||||
|
session = app.refreshMfaisaSession(loginId) ?: continue
|
||||||
|
MfaisaHistoryClient().fetchHistory(
|
||||||
|
session = session,
|
||||||
|
accountNumber = state.account.accountNumber,
|
||||||
|
accountDisplayName = state.account.accountBriefName,
|
||||||
|
pageNo = state.mfaisaNextPage,
|
||||||
|
recordSize = 70
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state.mfaisaHasMore = page.hasMore
|
||||||
|
state.mfaisaNextPage++
|
||||||
|
results.addAll(page.transactions)
|
||||||
|
} catch (e: Exception) { trackError(e) }
|
||||||
|
}
|
||||||
|
|
||||||
// Fahipay accounts
|
// Fahipay accounts
|
||||||
val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" }
|
val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" }
|
||||||
for (state in fahipayStates) {
|
for (state in fahipayStates) {
|
||||||
|
|||||||
@@ -14,9 +14,17 @@ data class TransferReceiptData(
|
|||||||
// MIB receipt fields
|
// MIB receipt fields
|
||||||
val mibReferenceNo: String = "",
|
val mibReferenceNo: String = "",
|
||||||
val mibTransactionDate: String = "",
|
val mibTransactionDate: String = "",
|
||||||
|
val mibFromProfileName: String = "",
|
||||||
|
val mibTransactionType: String = "", // "Own Transfer", "MIB Transfer", "Quick Transfer"
|
||||||
// BML receipt fields
|
// BML receipt fields
|
||||||
val bmlFromName: String = "",
|
val bmlFromName: String = "",
|
||||||
val bmlReference: String = "",
|
val bmlReference: String = "",
|
||||||
val bmlTimestamp: String = "",
|
val bmlTimestamp: String = "",
|
||||||
val bmlMessage: String = "",
|
val bmlMessage: String = "",
|
||||||
|
// M-Faisa receipt fields
|
||||||
|
val mfaisaTransactionType: String = "",
|
||||||
|
val mfaisaFromName: String = "",
|
||||||
|
val mfaisaFromMsisdn: String = "",
|
||||||
|
val mfaisaToMsisdn: String = "",
|
||||||
|
val mfaisaTimestamp: Long = 0L,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,21 +11,20 @@ import android.graphics.BitmapFactory
|
|||||||
import android.graphics.Canvas
|
import android.graphics.Canvas
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
import android.graphics.Rect
|
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.util.Base64
|
import android.util.Base64
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.PixelCopy
|
|
||||||
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.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.updatePadding
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
@@ -35,7 +34,10 @@ 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.mib.MibContactsClient
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
|
import sh.sar.basedbank.databinding.DialogReceiptFullscreenBmlBinding
|
||||||
|
import sh.sar.basedbank.databinding.DialogReceiptFullscreenMibBinding
|
||||||
import sh.sar.basedbank.databinding.FragmentReceiptBmlBinding
|
import sh.sar.basedbank.databinding.FragmentReceiptBmlBinding
|
||||||
|
import sh.sar.basedbank.databinding.FragmentReceiptMfaisaBinding
|
||||||
import sh.sar.basedbank.databinding.FragmentReceiptMibBinding
|
import sh.sar.basedbank.databinding.FragmentReceiptMibBinding
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
@@ -62,10 +64,17 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
private const val ARG_REMARKS = "remarks"
|
private const val ARG_REMARKS = "remarks"
|
||||||
private const val ARG_MIB_REF = "mib_ref"
|
private const val ARG_MIB_REF = "mib_ref"
|
||||||
private const val ARG_MIB_DATE = "mib_date"
|
private const val ARG_MIB_DATE = "mib_date"
|
||||||
|
private const val ARG_MIB_FROM_PROFILE = "mib_from_profile"
|
||||||
|
private const val ARG_MIB_TXN_TYPE = "mib_txn_type"
|
||||||
private const val ARG_BML_FROM_NAME = "bml_from_name"
|
private const val ARG_BML_FROM_NAME = "bml_from_name"
|
||||||
private const val ARG_BML_REFERENCE = "bml_reference"
|
private const val ARG_BML_REFERENCE = "bml_reference"
|
||||||
private const val ARG_BML_TIMESTAMP = "bml_timestamp"
|
private const val ARG_BML_TIMESTAMP = "bml_timestamp"
|
||||||
private const val ARG_BML_MESSAGE = "bml_message"
|
private const val ARG_BML_MESSAGE = "bml_message"
|
||||||
|
private const val ARG_MFAISA_TXN_TYPE = "mfaisa_txn_type"
|
||||||
|
private const val ARG_MFAISA_FROM_NAME = "mfaisa_from_name"
|
||||||
|
private const val ARG_MFAISA_FROM_MSISDN = "mfaisa_from_msisdn"
|
||||||
|
private const val ARG_MFAISA_TO_MSISDN = "mfaisa_to_msisdn"
|
||||||
|
private const val ARG_MFAISA_TIMESTAMP = "mfaisa_timestamp"
|
||||||
|
|
||||||
// Holds the already-rendered to-avatar bitmap from TransferFragment
|
// Holds the already-rendered to-avatar bitmap from TransferFragment
|
||||||
var pendingToAvatarBitmap: Bitmap? = null
|
var pendingToAvatarBitmap: Bitmap? = null
|
||||||
@@ -85,41 +94,89 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
putString(ARG_REMARKS, data.remarks)
|
putString(ARG_REMARKS, data.remarks)
|
||||||
putString(ARG_MIB_REF, data.mibReferenceNo)
|
putString(ARG_MIB_REF, data.mibReferenceNo)
|
||||||
putString(ARG_MIB_DATE, data.mibTransactionDate)
|
putString(ARG_MIB_DATE, data.mibTransactionDate)
|
||||||
|
putString(ARG_MIB_FROM_PROFILE, data.mibFromProfileName)
|
||||||
|
putString(ARG_MIB_TXN_TYPE, data.mibTransactionType)
|
||||||
putString(ARG_BML_FROM_NAME, data.bmlFromName)
|
putString(ARG_BML_FROM_NAME, data.bmlFromName)
|
||||||
putString(ARG_BML_REFERENCE, data.bmlReference)
|
putString(ARG_BML_REFERENCE, data.bmlReference)
|
||||||
putString(ARG_BML_TIMESTAMP, data.bmlTimestamp)
|
putString(ARG_BML_TIMESTAMP, data.bmlTimestamp)
|
||||||
putString(ARG_BML_MESSAGE, data.bmlMessage)
|
putString(ARG_BML_MESSAGE, data.bmlMessage)
|
||||||
|
putString(ARG_MFAISA_TXN_TYPE, data.mfaisaTransactionType)
|
||||||
|
putString(ARG_MFAISA_FROM_NAME, data.mfaisaFromName)
|
||||||
|
putString(ARG_MFAISA_FROM_MSISDN, data.mfaisaFromMsisdn)
|
||||||
|
putString(ARG_MFAISA_TO_MSISDN, data.mfaisaToMsisdn)
|
||||||
|
putLong(ARG_MFAISA_TIMESTAMP, data.mfaisaTimestamp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
||||||
return if (bank == "MIB") {
|
return when (bank) {
|
||||||
|
"MIB" -> {
|
||||||
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
|
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
|
||||||
bindMib(binding)
|
bindMib(binding)
|
||||||
_receiptCard = binding.receiptCard
|
_receiptCard = binding.receiptCard
|
||||||
binding.root
|
binding.root
|
||||||
} else {
|
}
|
||||||
|
"MFAISA" -> {
|
||||||
|
val binding = FragmentReceiptMfaisaBinding.inflate(inflater, container, false)
|
||||||
|
bindMfaisa(binding)
|
||||||
|
_receiptCard = binding.receiptCard
|
||||||
|
binding.root
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
val binding = FragmentReceiptBmlBinding.inflate(inflater, container, false)
|
val binding = FragmentReceiptBmlBinding.inflate(inflater, container, false)
|
||||||
bindBml(binding)
|
bindBml(binding)
|
||||||
_receiptCard = binding.receiptCard
|
_receiptCard = binding.receiptCard
|
||||||
binding.root
|
binding.root
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
receiptCard.setOnClickListener { showFullScreenReceipt() }
|
receiptCard.setOnClickListener { showFullScreenReceipt() }
|
||||||
|
|
||||||
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
|
val btnRow = view.findViewById<View>(R.id.btnRow)
|
||||||
parentFragmentManager.popBackStack()
|
val basePaddingBottom = btnRow.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(btnRow) { v, insets ->
|
||||||
|
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||||
|
insets
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val receiptContainer = view.findViewById<android.widget.ScrollView>(R.id.receiptContainer)
|
||||||
|
receiptContainer.setOnTouchListener { _, _ -> true }
|
||||||
|
receiptContainer.viewTreeObserver.addOnGlobalLayoutListener(object : android.view.ViewTreeObserver.OnGlobalLayoutListener {
|
||||||
|
override fun onGlobalLayout() {
|
||||||
|
receiptContainer.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||||
|
val available = receiptContainer.height
|
||||||
|
val natural = receiptCard.height
|
||||||
|
if (natural > available && available > 0) {
|
||||||
|
val scale = available.toFloat() / natural
|
||||||
|
receiptCard.scaleX = scale
|
||||||
|
receiptCard.scaleY = scale
|
||||||
|
receiptCard.pivotX = receiptCard.width / 2f
|
||||||
|
receiptCard.pivotY = 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
view.findViewById<MaterialButton>(R.id.btnShare).setOnClickListener {
|
view.findViewById<MaterialButton>(R.id.btnShare).setOnClickListener {
|
||||||
shareReceipt()
|
shareReceipt()
|
||||||
}
|
}
|
||||||
view.findViewById<MaterialButton>(R.id.btnSave).setOnClickListener {
|
view.findViewById<MaterialButton>(R.id.btnSave).setOnClickListener {
|
||||||
saveReceipt()
|
saveReceipt()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val alwaysFullScreen = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
.getBoolean("always_fullscreen_receipt", false)
|
||||||
|
if (alwaysFullScreen) {
|
||||||
|
// The normal page is skipped: keep it laid out (share/save capture its card) but hidden,
|
||||||
|
// and leave the receipt entirely when the full-screen view is closed
|
||||||
|
view.alpha = 0f
|
||||||
|
view.post { if (_receiptCard != null) showFullScreenReceipt(closePageOnDismiss = true) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Data binding ──────────────────────────────────────────────────────────
|
// ── Data binding ──────────────────────────────────────────────────────────
|
||||||
@@ -127,43 +184,64 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
private fun bindMib(binding: FragmentReceiptMibBinding) {
|
private fun bindMib(binding: FragmentReceiptMibBinding) {
|
||||||
val args = requireArguments()
|
val args = requireArguments()
|
||||||
val fromLabel = args.getString(ARG_FROM_LABEL, "")
|
val fromLabel = args.getString(ARG_FROM_LABEL, "")
|
||||||
val fromColor = args.getString(ARG_FROM_COLOR, "#FE860E")
|
|
||||||
val fromProfileHash = args.getString(ARG_FROM_PROFILE_HASH)
|
val fromProfileHash = args.getString(ARG_FROM_PROFILE_HASH)
|
||||||
val toLabel = args.getString(ARG_TO_LABEL, "")
|
val toLabel = args.getString(ARG_TO_LABEL, "")
|
||||||
val currency = args.getString(ARG_CURRENCY, "MVR")
|
val currency = args.getString(ARG_CURRENCY, "MVR")
|
||||||
val amount = args.getString(ARG_AMOUNT, "")
|
val amount = args.getString(ARG_AMOUNT, "")
|
||||||
|
|
||||||
// From avatar: initials first, then load profile image if hash available
|
// From avatar: initials first, then load profile image if hash available
|
||||||
binding.ivFromAvatar.setImageBitmap(makeInitialsBitmap(fromLabel, fromColor))
|
binding.ivFromAvatar.setImageBitmap(makeMibInitialsBitmap(fromLabel))
|
||||||
binding.tvFromLabel.text = fromLabel
|
binding.tvFromLabel.text = fromLabel
|
||||||
if (fromProfileHash != null) {
|
if (fromProfileHash != null) {
|
||||||
loadProfileImage(fromProfileHash, isProfile = true) { binding.ivFromAvatar.setImageBitmap(it) }
|
loadProfileImage(fromProfileHash, isProfile = true) { binding.ivFromAvatar.setImageBitmap(circleCrop(it)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// To avatar: use already-rendered bitmap from TransferFragment if available
|
// To avatar: use already-rendered bitmap from TransferFragment if available
|
||||||
val toAvatar = pendingToAvatarBitmap
|
val toAvatar = pendingToAvatarBitmap
|
||||||
if (toAvatar != null) {
|
if (toAvatar != null) {
|
||||||
binding.ivToAvatar.setImageBitmap(toAvatar)
|
binding.ivToAvatar.setImageBitmap(circleCrop(toAvatar))
|
||||||
} else {
|
} else {
|
||||||
binding.ivToAvatar.setImageBitmap(makeInitialsBitmap(toLabel, "#607D8B"))
|
binding.ivToAvatar.setImageBitmap(makeMibInitialsBitmap(toLabel))
|
||||||
}
|
}
|
||||||
binding.tvToLabel.text = toLabel
|
binding.tvToLabel.text = toLabel
|
||||||
|
|
||||||
binding.tvAmount.text = "$currency $amount"
|
binding.tvAmount.text = "$currency $amount"
|
||||||
|
|
||||||
|
val toBank = args.getString(ARG_TO_BANK, "")
|
||||||
|
val rawDate = args.getString(ARG_MIB_DATE, "")
|
||||||
binding.tvReferenceNo.text = args.getString(ARG_MIB_REF, "")
|
binding.tvReferenceNo.text = args.getString(ARG_MIB_REF, "")
|
||||||
binding.tvToAccount.text = args.getString(ARG_TO_ACCOUNT, "")
|
binding.tvFromName.text = args.getString(ARG_MIB_FROM_PROFILE, "").ifBlank { fromLabel }
|
||||||
binding.tvToBank.text = args.getString(ARG_TO_BANK, "")
|
binding.tvToAccount.text = listOf(toLabel, args.getString(ARG_TO_ACCOUNT, ""))
|
||||||
binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "")
|
.filter { it.isNotBlank() }.joinToString("\n")
|
||||||
binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "")
|
binding.tvToBank.text = toBank
|
||||||
|
// Receipts saved before the type was recorded fall back to a guess from the bank
|
||||||
|
binding.tvTransactionType.text = args.getString(ARG_MIB_TXN_TYPE, "").ifBlank {
|
||||||
|
if (toBank == "MIB") "MIB Transfer" else "Quick Transfer"
|
||||||
|
}
|
||||||
|
binding.tvTransactionDate.text = formatMibDate(rawDate, "dd MMM yyyy HH:mm")
|
||||||
|
binding.tvValueDate.text = formatMibDate(rawDate, "dd MMM yyyy")
|
||||||
binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
|
binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
|
||||||
|
.takeUnless { it.isNullOrBlank() || it.trim() == "-" } ?: "N/A"
|
||||||
|
|
||||||
copyOnLongClick(
|
copyOnLongClick(
|
||||||
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount,
|
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount, binding.tvStatus,
|
||||||
binding.tvReferenceNo, binding.tvToAccount, binding.tvToBank,
|
binding.tvReferenceNo, binding.tvFromName, binding.tvToAccount, binding.tvToBank,
|
||||||
binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
|
binding.tvTransactionType, binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reformats the MIB transfer date ("2026-05-16 15:10:25") to [pattern]; raw text if unparseable. */
|
||||||
|
private fun formatMibDate(raw: String, pattern: String): String {
|
||||||
|
if (raw.isBlank()) return ""
|
||||||
|
val out = DateTimeFormatter.ofPattern(pattern, Locale.US)
|
||||||
|
for (inPattern in listOf("yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "dd MMM yyyy HH:mm")) {
|
||||||
|
try {
|
||||||
|
return java.time.LocalDateTime.parse(raw.trim(), DateTimeFormatter.ofPattern(inPattern, Locale.US)).format(out)
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
|
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
val sess = app.anyMibSession() ?: return
|
val sess = app.anyMibSession() ?: return
|
||||||
@@ -222,6 +300,49 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun bindMfaisa(binding: FragmentReceiptMfaisaBinding) {
|
||||||
|
val args = requireArguments()
|
||||||
|
val currency = args.getString(ARG_CURRENCY, "MVR")
|
||||||
|
val amountStr = args.getString(ARG_AMOUNT, "")
|
||||||
|
|
||||||
|
val formattedAmount = try {
|
||||||
|
val d = amountStr.toDouble()
|
||||||
|
val intFmt = NumberFormat.getNumberInstance(Locale.US).apply { maximumFractionDigits = 0 }
|
||||||
|
intFmt.format(d.toLong()) + "%.2f".format(d).takeLast(3)
|
||||||
|
} catch (_: Exception) { amountStr }
|
||||||
|
|
||||||
|
binding.tvAmount.text = "$currency $formattedAmount"
|
||||||
|
binding.tvTransactionType.text = args.getString(ARG_MFAISA_TXN_TYPE, "")
|
||||||
|
.ifBlank { "Transfer to mobile" }
|
||||||
|
binding.tvFromName.text = args.getString(ARG_MFAISA_FROM_NAME, "")
|
||||||
|
.ifBlank { args.getString(ARG_FROM_LABEL, "") }
|
||||||
|
binding.tvFromMsisdn.text = args.getString(ARG_MFAISA_FROM_MSISDN, "")
|
||||||
|
binding.tvToName.text = args.getString(ARG_TO_LABEL, "")
|
||||||
|
binding.tvToMsisdn.text = args.getString(ARG_MFAISA_TO_MSISDN, "")
|
||||||
|
.ifBlank { args.getString(ARG_TO_ACCOUNT, "") }
|
||||||
|
binding.tvDateTime.text = formatMfaisaTimestamp(args.getLong(ARG_MFAISA_TIMESTAMP, 0L))
|
||||||
|
|
||||||
|
val remarks = args.getString(ARG_REMARKS, "")
|
||||||
|
if (!remarks.isNullOrBlank()) {
|
||||||
|
binding.tvRemarks.text = remarks
|
||||||
|
binding.remarksDivider.visibility = View.VISIBLE
|
||||||
|
binding.remarksRow.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
copyOnLongClick(
|
||||||
|
binding.tvAmount, binding.tvStatus, binding.tvTransactionType,
|
||||||
|
binding.tvFromName, binding.tvFromMsisdn,
|
||||||
|
binding.tvToName, binding.tvToMsisdn,
|
||||||
|
binding.tvDateTime, binding.tvRemarks
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatMfaisaTimestamp(millis: Long): String {
|
||||||
|
val effective = if (millis > 0) millis else System.currentTimeMillis()
|
||||||
|
val sdf = java.text.SimpleDateFormat("EEEE d MMMM yyyy HH:mm:ss z", Locale.US)
|
||||||
|
return sdf.format(java.util.Date(effective))
|
||||||
|
}
|
||||||
|
|
||||||
// ── Share / Save ──────────────────────────────────────────────────────────
|
// ── Share / Save ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun shareReceipt() {
|
private fun shareReceipt() {
|
||||||
@@ -286,21 +407,45 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Captures the receipt card using PixelCopy, which correctly handles
|
* Draws the receipt card to an offscreen bitmap at its natural (unscaled)
|
||||||
* hardware-accelerated views (avoids the black-square problem with view.draw()).
|
* dimensions, so the captured image isn't affected by the on-screen scale
|
||||||
|
* applied to fit small viewports and doesn't pick up overlapping siblings.
|
||||||
*/
|
*/
|
||||||
private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) {
|
private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) {
|
||||||
val view = _receiptCard ?: run { callback(null); return }
|
val shown = _receiptCard ?: run { callback(null); return }
|
||||||
if (view.width == 0 || view.height == 0) { callback(null); return }
|
if (shown.width == 0 || shown.height == 0) { callback(null); return }
|
||||||
|
|
||||||
|
// BML: the preview follows the app theme, but shared/saved images are always light
|
||||||
|
val view = if (arguments?.getString(ARG_BANK, "MIB") == "BML") {
|
||||||
|
inflateLightBmlCard(shown.width)
|
||||||
|
} else shown
|
||||||
|
|
||||||
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
|
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
|
||||||
val location = IntArray(2)
|
val canvas = Canvas(bitmap)
|
||||||
view.getLocationInWindow(location)
|
canvas.drawColor(Color.WHITE)
|
||||||
val srcRect = Rect(location[0], location[1], location[0] + view.width, location[1] + view.height)
|
view.draw(canvas)
|
||||||
|
callback(bitmap)
|
||||||
|
}
|
||||||
|
|
||||||
PixelCopy.request(requireActivity().window, srcRect, bitmap, { result ->
|
/** Inflates and lays out an offscreen BML receipt card with light-mode resources. */
|
||||||
callback(if (result == PixelCopy.SUCCESS) bitmap else null)
|
private fun inflateLightBmlCard(widthPx: Int): View {
|
||||||
}, Handler(Looper.getMainLooper()))
|
val config = android.content.res.Configuration(resources.configuration).apply {
|
||||||
|
uiMode = (uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK.inv()) or
|
||||||
|
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||||
|
}
|
||||||
|
val lightCtx = android.view.ContextThemeWrapper(requireContext(), R.style.Theme_BasedBank).apply {
|
||||||
|
applyOverrideConfiguration(config)
|
||||||
|
}
|
||||||
|
val binding = FragmentReceiptBmlBinding.inflate(LayoutInflater.from(lightCtx))
|
||||||
|
bindBml(binding)
|
||||||
|
val card = binding.receiptCard
|
||||||
|
(card.parent as? ViewGroup)?.removeView(card)
|
||||||
|
card.measure(
|
||||||
|
View.MeasureSpec.makeMeasureSpec(widthPx, View.MeasureSpec.EXACTLY),
|
||||||
|
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
||||||
|
)
|
||||||
|
card.layout(0, 0, card.measuredWidth, card.measuredHeight)
|
||||||
|
return card
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatBmlTimestamp(raw: String): String {
|
private fun formatBmlTimestamp(raw: String): String {
|
||||||
@@ -312,41 +457,69 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
|
/** Center-crops [src] to a square and masks it to a circle. */
|
||||||
|
private fun circleCrop(src: Bitmap): Bitmap {
|
||||||
|
val size = minOf(src.width, src.height)
|
||||||
|
val out = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||||
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply {
|
||||||
|
shader = android.graphics.BitmapShader(src, android.graphics.Shader.TileMode.CLAMP, android.graphics.Shader.TileMode.CLAMP).apply {
|
||||||
|
setLocalMatrix(android.graphics.Matrix().apply {
|
||||||
|
setTranslate(-(src.width - size) / 2f, -(src.height - size) / 2f)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Canvas(out).drawCircle(size / 2f, size / 2f, size / 2f, paint)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MIB receipt placeholder: up to two initials in #1168F3 on a #C6E1FD circle. */
|
||||||
|
private fun makeMibInitialsBitmap(name: String): Bitmap {
|
||||||
val sizePx = (resources.displayMetrics.density * 52).toInt()
|
val sizePx = (resources.displayMetrics.density * 52).toInt()
|
||||||
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
|
|
||||||
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||||
val canvas = Canvas(bm)
|
val canvas = Canvas(bm)
|
||||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
paint.color = bgColor
|
paint.color = Color.parseColor("#C6E1FD")
|
||||||
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
||||||
paint.color = Color.WHITE
|
paint.color = Color.parseColor("#1168F3")
|
||||||
paint.textSize = sizePx * 0.42f
|
paint.textSize = sizePx * 0.36f
|
||||||
paint.textAlign = Paint.Align.CENTER
|
paint.textAlign = Paint.Align.CENTER
|
||||||
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
paint.typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||||||
|
val initials = name.split(Regex("\\s+"))
|
||||||
|
.mapNotNull { word -> word.firstOrNull { it.isLetterOrDigit() }?.uppercaseChar() }
|
||||||
|
.take(2).joinToString("").ifEmpty { "?" }
|
||||||
val metrics = paint.fontMetrics
|
val metrics = paint.fontMetrics
|
||||||
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
canvas.drawText(initials, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
||||||
return bm
|
return bm
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showFullScreenReceipt() {
|
private fun showFullScreenReceipt(closePageOnDismiss: Boolean = false) {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
||||||
|
if (bank == "BML") { showBmlFullScreenReceipt(closePageOnDismiss); return }
|
||||||
|
if (bank == "MIB") { showMibFullScreenReceipt(closePageOnDismiss); return }
|
||||||
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
|
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
|
||||||
|
|
||||||
val scrollView = android.widget.ScrollView(ctx).apply {
|
val scrollView = android.widget.ScrollView(ctx).apply {
|
||||||
setBackgroundColor(Color.BLACK)
|
setBackgroundColor(Color.BLACK)
|
||||||
}
|
}
|
||||||
|
|
||||||
val cardView = if (bank == "MIB") {
|
val cardView = when (bank) {
|
||||||
|
"MIB" -> {
|
||||||
val binding = FragmentReceiptMibBinding.inflate(layoutInflater)
|
val binding = FragmentReceiptMibBinding.inflate(layoutInflater)
|
||||||
bindMib(binding)
|
bindMib(binding)
|
||||||
binding.receiptCard
|
binding.receiptCard
|
||||||
} else {
|
}
|
||||||
|
"MFAISA" -> {
|
||||||
|
val binding = FragmentReceiptMfaisaBinding.inflate(layoutInflater)
|
||||||
|
bindMfaisa(binding)
|
||||||
|
binding.receiptCard
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
val binding = FragmentReceiptBmlBinding.inflate(layoutInflater)
|
val binding = FragmentReceiptBmlBinding.inflate(layoutInflater)
|
||||||
bindBml(binding)
|
bindBml(binding)
|
||||||
binding.receiptCard
|
binding.receiptCard
|
||||||
}
|
}
|
||||||
|
}
|
||||||
(cardView.parent as? ViewGroup)?.removeView(cardView)
|
(cardView.parent as? ViewGroup)?.removeView(cardView)
|
||||||
cardView.setOnClickListener { dialog.dismiss() }
|
cardView.setOnClickListener { dialog.dismiss() }
|
||||||
scrollView.addView(cardView)
|
scrollView.addView(cardView)
|
||||||
@@ -365,6 +538,7 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||||
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||||
insetsCtrl.isAppearanceLightStatusBars = isLight
|
insetsCtrl.isAppearanceLightStatusBars = isLight
|
||||||
|
if (closePageOnDismiss) closeReceiptPage()
|
||||||
}
|
}
|
||||||
dialog.show()
|
dialog.show()
|
||||||
dialog.window?.let { win ->
|
dialog.window?.let { win ->
|
||||||
@@ -376,6 +550,121 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BML full-screen receipt: status bar stays visible, top bar with back button,
|
||||||
|
* edge-to-edge card right under it, BML-styled Save/Share buttons directly below the card.
|
||||||
|
* Follows the app theme (light/dark).
|
||||||
|
*/
|
||||||
|
private fun showBmlFullScreenReceipt(closePageOnDismiss: Boolean) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
|
||||||
|
val page = DialogReceiptFullscreenBmlBinding.inflate(layoutInflater)
|
||||||
|
|
||||||
|
val card = FragmentReceiptBmlBinding.inflate(layoutInflater).also { bindBml(it) }.receiptCard
|
||||||
|
(card.parent as? ViewGroup)?.removeView(card)
|
||||||
|
page.cardHolder.addView(card, 0, android.widget.LinearLayout.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
))
|
||||||
|
|
||||||
|
page.btnBack.setOnClickListener { dialog.dismiss() }
|
||||||
|
if (closePageOnDismiss) dialog.setOnDismissListener { closeReceiptPage() }
|
||||||
|
page.btnSaveFull.setOnClickListener { saveReceipt() }
|
||||||
|
page.btnShareFull.setOnClickListener { shareReceipt() }
|
||||||
|
|
||||||
|
val topBasePadding = page.topBar.paddingTop
|
||||||
|
val bottomBasePadding = page.bottomBar.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(page.root) { _, insets ->
|
||||||
|
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
page.topBar.updatePadding(top = topBasePadding + bars.top)
|
||||||
|
page.bottomBar.updatePadding(bottom = bottomBasePadding + bars.bottom)
|
||||||
|
page.root.updatePadding(left = bars.left, right = bars.right)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.setContentView(page.root)
|
||||||
|
dialog.window?.let { win ->
|
||||||
|
win.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||||
|
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
win.statusBarColor = Color.TRANSPARENT
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
win.navigationBarColor = Color.TRANSPARENT
|
||||||
|
val isLight = (resources.configuration.uiMode and
|
||||||
|
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||||
|
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||||
|
androidx.core.view.WindowInsetsControllerCompat(win, win.decorView).apply {
|
||||||
|
isAppearanceLightStatusBars = isLight
|
||||||
|
isAppearanceLightNavigationBars = isLight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIB full-screen receipt: edge-to-edge card whose green header runs under the
|
||||||
|
* (visible) status bar, a floating close button top-right just below the status bar,
|
||||||
|
* and MIB-styled Share/Save buttons pinned to the bottom. Follows the app theme.
|
||||||
|
*/
|
||||||
|
private fun showMibFullScreenReceipt(closePageOnDismiss: Boolean) {
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dialog = Dialog(ctx, R.style.Theme_BasedBank)
|
||||||
|
val page = DialogReceiptFullscreenMibBinding.inflate(layoutInflater)
|
||||||
|
|
||||||
|
val cardBinding = FragmentReceiptMibBinding.inflate(layoutInflater).also { bindMib(it) }
|
||||||
|
val card = cardBinding.receiptCard
|
||||||
|
(card.parent as? ViewGroup)?.removeView(card)
|
||||||
|
page.cardHolder.addView(card, ViewGroup.LayoutParams(
|
||||||
|
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||||
|
))
|
||||||
|
|
||||||
|
page.btnClose.setOnClickListener { dialog.dismiss() }
|
||||||
|
if (closePageOnDismiss) dialog.setOnDismissListener { closeReceiptPage() }
|
||||||
|
page.btnShareFull.setOnClickListener { shareReceipt() }
|
||||||
|
page.btnSaveFull.setOnClickListener { saveReceipt() }
|
||||||
|
|
||||||
|
val header = cardBinding.receiptHeader
|
||||||
|
val headerBaseHeight = header.layoutParams.height
|
||||||
|
val headerBasePadding = header.paddingTop
|
||||||
|
val closeBaseMargin = (page.btnClose.layoutParams as ViewGroup.MarginLayoutParams).topMargin
|
||||||
|
val bottomBasePadding = page.bottomBar.paddingBottom
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(page.root) { _, insets ->
|
||||||
|
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
// Grow the green header under the status bar, keeping its content below it
|
||||||
|
header.layoutParams = header.layoutParams.apply { height = headerBaseHeight + bars.top }
|
||||||
|
header.updatePadding(top = headerBasePadding + bars.top)
|
||||||
|
page.btnClose.layoutParams = (page.btnClose.layoutParams as ViewGroup.MarginLayoutParams)
|
||||||
|
.apply { topMargin = closeBaseMargin + bars.top }
|
||||||
|
page.bottomBar.updatePadding(bottom = bottomBasePadding + bars.bottom)
|
||||||
|
page.root.updatePadding(left = bars.left, right = bars.right)
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.setContentView(page.root)
|
||||||
|
dialog.window?.let { win ->
|
||||||
|
win.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||||
|
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
win.statusBarColor = Color.TRANSPARENT
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
win.navigationBarColor = Color.TRANSPARENT
|
||||||
|
val isLight = (resources.configuration.uiMode and
|
||||||
|
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||||
|
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||||
|
androidx.core.view.WindowInsetsControllerCompat(win, win.decorView).apply {
|
||||||
|
// Status bar sits over the green header, so always use light icons
|
||||||
|
isAppearanceLightStatusBars = false
|
||||||
|
isAppearanceLightNavigationBars = isLight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pops this receipt off the back stack, returning to whatever screen opened it. */
|
||||||
|
private fun closeReceiptPage() {
|
||||||
|
if (!isAdded || parentFragmentManager.isStateSaved) return
|
||||||
|
parentFragmentManager.popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
private fun copyOnLongClick(vararg views: android.widget.TextView) {
|
private fun copyOnLongClick(vararg views: android.widget.TextView) {
|
||||||
for (tv in views) {
|
for (tv in views) {
|
||||||
tv.setOnLongClickListener {
|
tv.setOnLongClickListener {
|
||||||
@@ -395,13 +684,9 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
(activity as? HomeActivity)?.setBottomNavVisible(false)
|
(activity as? HomeActivity)?.setBottomNavVisible(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
super.onPause()
|
|
||||||
(activity as? HomeActivity)?.setBottomNavVisible(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
|
(activity as? HomeActivity)?.setBottomNavVisible(true)
|
||||||
_receiptCard = null
|
_receiptCard = null
|
||||||
pendingToAvatarBitmap = null
|
pendingToAvatarBitmap = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,974 @@
|
|||||||
|
package sh.sar.basedbank.ui.home.transfer
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||||
|
import sh.sar.basedbank.api.bml.BmlMerchantCardPayClient
|
||||||
|
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
||||||
|
import sh.sar.basedbank.api.bml.BmlQrPayClient
|
||||||
|
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
||||||
|
import sh.sar.basedbank.api.bml.BmlQrPayLookupException
|
||||||
|
import sh.sar.basedbank.api.bml.BmlQrPayResult
|
||||||
|
import sh.sar.basedbank.api.bml.BmlSession
|
||||||
|
import sh.sar.basedbank.api.bml.BmlTransferClient
|
||||||
|
import sh.sar.basedbank.api.bml.BmlValidateClient
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.api.models.BankContact
|
||||||
|
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
||||||
|
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||||
|
import sh.sar.basedbank.ui.home.HomeActivity
|
||||||
|
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||||
|
import sh.sar.basedbank.ui.home.TransferFragment
|
||||||
|
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
|
import sh.sar.basedbank.util.RecentPick
|
||||||
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
|
import sh.sar.basedbank.util.Totp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the BML-only parts of the Transfer screen: session lookup, the CASA/card transfer itself,
|
||||||
|
* the business-profile OTP state machine (channel pick → initiate → verify), and merchant QR
|
||||||
|
* payment (card/gateway QR lookup + pre-initiate/initiate/confirm).
|
||||||
|
*
|
||||||
|
* Lives alongside [TransferFragment], which dispatches to it whenever the selected source's
|
||||||
|
* [BankAccount.bank] is "BML", and mirrors [MfaisaTransferHandler]'s shape: the fragment keeps
|
||||||
|
* the shared confirm dialog, the "To" lookup and the form state; the handler keeps everything
|
||||||
|
* BML-specific.
|
||||||
|
*
|
||||||
|
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
|
||||||
|
* [androidx.fragment.app.Fragment.viewLifecycleOwner] and Context) — and must be re-created when
|
||||||
|
* the view is recreated.
|
||||||
|
*/
|
||||||
|
class BmlTransferHandler(
|
||||||
|
private val fragment: TransferFragment,
|
||||||
|
private val binding: FragmentTransferBinding,
|
||||||
|
private val viewModel: HomeViewModel,
|
||||||
|
/** Reads the fragment's currently-selected source account. */
|
||||||
|
private val currentSource: () -> BankAccount?,
|
||||||
|
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
||||||
|
private val selectSource: (BankAccount) -> Unit,
|
||||||
|
/** Asks the fragment to drop the selected source and show the empty From picker. */
|
||||||
|
private val clearSource: () -> Unit,
|
||||||
|
/** Hook called whenever handler state changes in a way that affects the Send button. */
|
||||||
|
private val onStateChanged: () -> Unit,
|
||||||
|
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes balances. */
|
||||||
|
private val onTransferSuccess: (TransferReceiptData, Bitmap?) -> Unit,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||||
|
private val ctx get() = fragment.requireContext()
|
||||||
|
private val host get() = fragment.activity as? HomeActivity
|
||||||
|
|
||||||
|
// ─── State ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Business-profile OTP flow. NONE means the Send button behaves normally. */
|
||||||
|
private enum class OtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
|
||||||
|
private var otpState = OtpState.NONE
|
||||||
|
set(value) {
|
||||||
|
// The whole OTP flow counts as a payment in flight: a theme change mid-way would
|
||||||
|
// otherwise recreate the screen between initiate and confirm.
|
||||||
|
if (field == OtpState.NONE && value != OtpState.NONE) {
|
||||||
|
otpGuard = host?.beginPayment(fragment.viewLifecycleOwner)
|
||||||
|
} else if (value == OtpState.NONE) {
|
||||||
|
otpGuard?.end()
|
||||||
|
otpGuard = null
|
||||||
|
}
|
||||||
|
field = value
|
||||||
|
}
|
||||||
|
private var otpGuard: HomeActivity.PaymentGuard? = null
|
||||||
|
private var otpChannel: String? = null
|
||||||
|
|
||||||
|
private data class PendingTransfer(
|
||||||
|
val src: BankAccount,
|
||||||
|
val debitAccount: String,
|
||||||
|
val creditAccount: String,
|
||||||
|
val amount: Double,
|
||||||
|
val amountStr: String,
|
||||||
|
val remarks: String,
|
||||||
|
val transferType: String,
|
||||||
|
val currency: String,
|
||||||
|
val bank: String?,
|
||||||
|
val destDisplay: String,
|
||||||
|
val destAccount: String,
|
||||||
|
val toBank: String,
|
||||||
|
val toAvatar: Bitmap?
|
||||||
|
)
|
||||||
|
private var pendingTransfer: PendingTransfer? = null
|
||||||
|
|
||||||
|
// Merchant QR state lives in the draft so it outlives this handler (dropped with the view).
|
||||||
|
private val draft get() = viewModel.transferDraft
|
||||||
|
|
||||||
|
/** Merchant QR payment mode (set when navigated from a card/gateway QR scan). */
|
||||||
|
val qrInfo: BmlQrPayInfo? get() = draft.bmlQrInfo
|
||||||
|
/** True for pay.bml.com.mv QRs, which need an extra pre-initiate step. */
|
||||||
|
private val gatewayQr: Boolean get() = draft.bmlGatewayQr
|
||||||
|
/** Stops the accounts observer re-firing a lookup that is already running on this view. */
|
||||||
|
private var qrLookupInFlight = false
|
||||||
|
|
||||||
|
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||||
|
|
||||||
|
/** Whether the business OTP flow is mid-way — the fragment freezes the Send button while it is. */
|
||||||
|
val isOtpFlowActive: Boolean get() = otpState != OtpState.NONE
|
||||||
|
|
||||||
|
/** Whether the Send button should verify an OTP instead of starting a new transfer. */
|
||||||
|
val isAwaitingOtp: Boolean get() = otpState == OtpState.AWAITING_OTP
|
||||||
|
|
||||||
|
/** Whether a merchant QR is loaded — counts as a resolved recipient for the Send button. */
|
||||||
|
val hasQrMerchant: Boolean get() = qrInfo != null
|
||||||
|
|
||||||
|
fun sessionFor(account: BankAccount?): BmlSession? =
|
||||||
|
account?.let { app.bmlSessionFor(it) } ?: app.anyBmlSession()
|
||||||
|
|
||||||
|
fun isBusinessProfile(account: BankAccount): Boolean {
|
||||||
|
val loginId = account.loginTag.removePrefix("bml_")
|
||||||
|
val profiles = app.bmlProfilesMap[loginId] ?: return false
|
||||||
|
return profiles.firstOrNull { it.profileId == account.profileId }?.profileType == "business"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a destination through BML — `verifyMibAccount` when [verifyAsMib], otherwise the
|
||||||
|
* regular alias/account validation. Blocking; call from IO. Returns null when BML can't
|
||||||
|
* resolve it, so the caller can fall back to the MIB IPS lookup.
|
||||||
|
*
|
||||||
|
* The result is mapped onto [MibIpsAccountInfo] because that is the Transfer screen's common
|
||||||
|
* "resolved recipient" shape, whichever bank did the resolving. Note that the MIB-verify
|
||||||
|
* endpoint leaves `currency` blank — the caller enriches it via
|
||||||
|
* [MibTransferHandler.lookupCurrency] when a MIB session is available.
|
||||||
|
*/
|
||||||
|
fun validateDestination(
|
||||||
|
session: BmlSession,
|
||||||
|
accountNumber: String,
|
||||||
|
verifyAsMib: Boolean
|
||||||
|
): MibIpsAccountInfo? {
|
||||||
|
val result = try {
|
||||||
|
if (verifyAsMib) BmlValidateClient().verifyMibAccount(session, accountNumber)
|
||||||
|
else BmlValidateClient().validateAccount(session, accountNumber)
|
||||||
|
} catch (_: Exception) { null } ?: return null
|
||||||
|
val bankId = when (result.trnType) {
|
||||||
|
"IAT" -> "MALBMVMV"
|
||||||
|
else -> result.agnt ?: result.account
|
||||||
|
}
|
||||||
|
return MibIpsAccountInfo(
|
||||||
|
accountName = result.name,
|
||||||
|
accountNumber = result.account,
|
||||||
|
bankId = bankId,
|
||||||
|
currency = result.currency
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
|
||||||
|
fun clearQrMerchant() {
|
||||||
|
draft.pendingBmlQrTarget = null
|
||||||
|
if (qrInfo == null) return
|
||||||
|
draft.bmlQrInfo = null
|
||||||
|
draft.bmlGatewayQr = false
|
||||||
|
fragment.setAmountLocked(false)
|
||||||
|
binding.tilRemarks.isEnabled = true
|
||||||
|
binding.tilRemarks.alpha = 1f
|
||||||
|
binding.etAmount.setText("")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Called when the view tears down — an in-flight OTP flow cannot sensibly resume. */
|
||||||
|
fun clearState() {
|
||||||
|
otpState = OtpState.NONE
|
||||||
|
otpChannel = null
|
||||||
|
pendingTransfer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Merchant QR ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a card/gateway/POS QR to its merchant and switches the screen into QR-pay mode.
|
||||||
|
* Until it finishes the QR stays in [TransferDraft.pendingBmlQrTarget], which the fragment
|
||||||
|
* retries once sessions load (cold start) or when the view comes back (tab switched away
|
||||||
|
* mid-lookup).
|
||||||
|
*/
|
||||||
|
fun lookupQrMerchant(qrUrl: String) {
|
||||||
|
// Captured so a lookup finishing after a fresh draft replaced this one can't leak into it
|
||||||
|
val draft = this.draft
|
||||||
|
draft.pendingBmlQrTarget = qrUrl
|
||||||
|
if (qrLookupInFlight) return
|
||||||
|
val session = app.anyBmlSession() ?: return
|
||||||
|
qrLookupInFlight = true
|
||||||
|
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
|
||||||
|
|
||||||
|
// The To row stays on screen with a spinner while loading and is only swapped for the
|
||||||
|
// merchant card once there is a merchant to show — hiding it up front left a gap that
|
||||||
|
// made the form jump twice.
|
||||||
|
fragment.startLookupLoading()
|
||||||
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
|
||||||
|
}
|
||||||
|
host?.setRefreshing(false)
|
||||||
|
qrLookupInFlight = false
|
||||||
|
if (fragment.view == null) return@launch
|
||||||
|
if (draft !== viewModel.transferDraft) return@launch
|
||||||
|
fragment.stopLookupLoading()
|
||||||
|
// Superseded: cleared meanwhile, or another QR was opened while this one ran
|
||||||
|
val latest = draft.pendingBmlQrTarget
|
||||||
|
if (latest != qrUrl) {
|
||||||
|
latest?.let { lookupQrMerchant(it) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
draft.pendingBmlQrTarget = null
|
||||||
|
val info = result.getOrNull()
|
||||||
|
if (info == null) {
|
||||||
|
// An expired or rejected QR is BML telling us something specific — show its own
|
||||||
|
// wording and stay put with the To row as it was, rather than bouncing the user
|
||||||
|
// out of the screen they just scanned from.
|
||||||
|
val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message
|
||||||
|
?: ctx.getString(R.string.bml_qr_lookup_failed)
|
||||||
|
Toast.makeText(ctx, message, Toast.LENGTH_LONG).show()
|
||||||
|
onStateChanged()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
draft.bmlQrInfo = info
|
||||||
|
// Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount
|
||||||
|
// and need the extra pre-initiate POST; ebanking qrpay URLs do not.
|
||||||
|
draft.bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
|
||||||
|
if (info.amount == 0.0) {
|
||||||
|
RecentsCache.save(ctx, RecentPick(
|
||||||
|
accountNumber = "bmlqr:$qrUrl",
|
||||||
|
displayName = info.merchantName,
|
||||||
|
subtitle = info.merchantAddress.ifBlank { "BML Merchant" },
|
||||||
|
colorHex = "#0066A1",
|
||||||
|
imageHash = null,
|
||||||
|
isProfileImage = false
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the To row before touching the source: repainting the From card re-syncs the
|
||||||
|
// picker/scan buttons to the To row's visibility.
|
||||||
|
hideToRow()
|
||||||
|
|
||||||
|
// Only a BML card can pay a merchant QR — drop any other source, then auto-select
|
||||||
|
// the user's default card if no card was pre-selected
|
||||||
|
if (currentSource()?.let { isCard(it) } == false) clearSource()
|
||||||
|
if (currentSource() == null) {
|
||||||
|
val defaultNum = CredentialStore(ctx).getDefaultCardAccountNumber()
|
||||||
|
if (defaultNum != null) {
|
||||||
|
val allAccounts = viewModel.accounts.value ?: emptyList()
|
||||||
|
val defaultCard = allAccounts.firstOrNull {
|
||||||
|
it.accountNumber == defaultNum && isCard(it) &&
|
||||||
|
it.statusDesc.equals("Active", ignoreCase = true)
|
||||||
|
}
|
||||||
|
if (defaultCard != null) selectSource(defaultCard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showQrMerchant(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paints a looked-up merchant into the "To" card and puts the form in QR-pay mode. Also how a
|
||||||
|
* recreated view restores it — no network involved.
|
||||||
|
*/
|
||||||
|
fun showQrMerchant(info: BmlQrPayInfo) {
|
||||||
|
hideToRow()
|
||||||
|
// Clear button hidden (can't change recipient for QR)
|
||||||
|
binding.tvToAccountName.text = info.merchantName
|
||||||
|
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
|
||||||
|
binding.tvToAccountDetails.visibility = View.GONE
|
||||||
|
binding.tvToBalance.visibility = View.GONE
|
||||||
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
|
binding.ivToPhoto.setImageBitmap(fragment.makeInitialsBitmap(info.merchantName, "#0066A1"))
|
||||||
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// Pre-fill amount if dynamic QR
|
||||||
|
if (info.amount > 0.0) {
|
||||||
|
binding.etAmount.setText("%.2f".format(info.amount))
|
||||||
|
fragment.setAmountLocked(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remarks not applicable for merchant QR payments
|
||||||
|
binding.tilRemarks.isEnabled = false
|
||||||
|
binding.tilRemarks.alpha = 0.4f
|
||||||
|
|
||||||
|
onStateChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hideToRow() {
|
||||||
|
binding.tilTo.visibility = View.GONE
|
||||||
|
binding.btnPickContact.visibility = View.GONE
|
||||||
|
binding.btnScanQr.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog and
|
||||||
|
* reports the outcome inside it — there is no receipt screen for merchant payments.
|
||||||
|
*/
|
||||||
|
fun submitQrPayment() {
|
||||||
|
val info = qrInfo ?: return
|
||||||
|
val src = currentSource() ?: run {
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
|
||||||
|
val amount = amountStr.toDoubleOrNull()
|
||||||
|
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
|
||||||
|
binding.tilAmount.error = null
|
||||||
|
val debitAccount = src.internalId.ifBlank {
|
||||||
|
Toast.makeText(ctx, R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val fromTypeLabel = sh.sar.basedbank.util.AccountListParser.from(src)?.typeLabel
|
||||||
|
?: sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(src.accountTypeName)
|
||||||
|
val fromDetail = listOfNotNull("BML", fromTypeLabel.ifBlank { null }).joinToString(" · ")
|
||||||
|
val confirmView = fragment.buildTransferConfirmView(
|
||||||
|
amountCurrency = info.currency,
|
||||||
|
amountValue = "%.2f".format(amount),
|
||||||
|
fromName = src.accountBriefName,
|
||||||
|
fromNumber = src.accountNumber,
|
||||||
|
fromDetail = fromDetail,
|
||||||
|
toName = info.merchantName,
|
||||||
|
toNumber = "",
|
||||||
|
toDetail = info.merchantAddress.ifBlank { "BML Merchant" }
|
||||||
|
)
|
||||||
|
fragment.showConfirmWithBiometric(
|
||||||
|
title = ctx.getString(R.string.transfer),
|
||||||
|
customView = confirmView,
|
||||||
|
biometricSubtitle = "${info.currency} ${"%.2f".format(amount)} → ${info.merchantName}",
|
||||||
|
onConfirmed = { dialog, frame ->
|
||||||
|
fragment.showProcessingInDialog(dialog, frame)
|
||||||
|
executeQrPayment(src, debitAccount, info, amount, dialog, frame)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executeQrPayment(
|
||||||
|
src: BankAccount,
|
||||||
|
debitAccount: String,
|
||||||
|
info: BmlQrPayInfo,
|
||||||
|
amount: Double,
|
||||||
|
dialog: AlertDialog,
|
||||||
|
frame: android.widget.FrameLayout
|
||||||
|
) {
|
||||||
|
val loginId = src.loginTag.removePrefix("bml_")
|
||||||
|
val session = sessionFor(src) ?: run {
|
||||||
|
dialog.dismiss()
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val otp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||||
|
?.let { Totp.generate(it) }
|
||||||
|
?: run { dialog.dismiss(); Toast.makeText(ctx, "OTP unavailable", Toast.LENGTH_SHORT).show(); return }
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
if (gatewayQr) {
|
||||||
|
val preOk = BmlQrPayClient().preInitiatePayment(
|
||||||
|
session, debitAccount, info.requestId, amount, info.currency)
|
||||||
|
if (!preOk) return@withContext null
|
||||||
|
}
|
||||||
|
val initiated = BmlQrPayClient().initiatePayment(
|
||||||
|
session, debitAccount, info.requestId, amount, info.currency)
|
||||||
|
if (!initiated) return@withContext null
|
||||||
|
val confirmOtp = CredentialStore(ctx).loadBmlCredentials(loginId)
|
||||||
|
?.otpSeed?.let { Totp.generate(it) } ?: otp
|
||||||
|
BmlQrPayClient().confirmPayment(
|
||||||
|
session, debitAccount, info.requestId, amount, info.currency, confirmOtp)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fragment.view == null) return@launch
|
||||||
|
|
||||||
|
if (result == null) {
|
||||||
|
dialog.dismiss()
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
Toast.makeText(ctx, "Failed to initiate payment", Toast.LENGTH_LONG).show()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
if (result.success) {
|
||||||
|
fragment.showSuccessInDialog(
|
||||||
|
dialog, frame,
|
||||||
|
amountCurrency = result.currency.ifBlank { info.currency },
|
||||||
|
amountValue = result.amount.ifBlank { "%.2f".format(amount) },
|
||||||
|
fromName = src.accountBriefName,
|
||||||
|
toName = result.merchant.ifBlank { info.merchantName }
|
||||||
|
) {
|
||||||
|
fragment.clearForm()
|
||||||
|
host?.triggerRefresh()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dialog.dismiss()
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
Toast.makeText(ctx, result.errorMessage, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Card-only merchant payment (no BML Pay) ─────────────────────────────
|
||||||
|
|
||||||
|
/** A card-only BML merchant is loaded — the fragment treats it like the QR merchant mode. */
|
||||||
|
val hasCardMerchant: Boolean get() = cardMerchant != null
|
||||||
|
private val cardMerchant get() = draft.bmlCardMerchant
|
||||||
|
|
||||||
|
/** A verified BML card we also hold a login (OTP seed) for — can go through the 3-D Secure step. */
|
||||||
|
private fun verifiedCardCandidates(): List<BankAccount> =
|
||||||
|
BmlVerifiedCards.payable(ctx, viewModel.accounts.value ?: emptyList())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads a BML Merchant Services link whose merchant has no BML Pay into the Transfer screen as
|
||||||
|
* a card payment: paints the merchant as the recipient, locks the amount, and limits the source
|
||||||
|
* to the user's verified BML cards. Send then runs the Pomelo + 3-D Secure flow.
|
||||||
|
*/
|
||||||
|
fun payCardMerchant(page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage) {
|
||||||
|
if (page.isPaid) {
|
||||||
|
Toast.makeText(ctx, R.string.bml_card_pay_already_paid, Toast.LENGTH_LONG).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (verifiedCardCandidates().isEmpty()) {
|
||||||
|
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
draft.bmlCardMerchant = page
|
||||||
|
showCardMerchant(page)
|
||||||
|
|
||||||
|
// Default to a verified card if nothing suitable is already selected.
|
||||||
|
if (currentSource()?.let { isCardVerified(it) } != true) {
|
||||||
|
clearSource()
|
||||||
|
val candidates = verifiedCardCandidates()
|
||||||
|
val default = CredentialStore(ctx).getDefaultCardAccountNumber()
|
||||||
|
(candidates.firstOrNull { it.accountNumber == default } ?: candidates.firstOrNull())
|
||||||
|
?.let { selectSource(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isCardVerified(account: BankAccount): Boolean = BmlVerifiedCards.isPayable(ctx, account)
|
||||||
|
|
||||||
|
/** Paints the loaded card-only merchant into the "To" card and locks the amount. */
|
||||||
|
fun showCardMerchant(page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage) {
|
||||||
|
hideToRow()
|
||||||
|
binding.tvToAccountName.text = page.merchantName
|
||||||
|
binding.tvToBankBic.text = page.merchantAddress.ifBlank { "BML Merchant" }
|
||||||
|
binding.tvToAccountDetails.visibility = View.GONE
|
||||||
|
binding.tvToBalance.visibility = View.GONE
|
||||||
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
|
binding.ivToPhoto.setImageBitmap(fragment.makeInitialsBitmap(page.merchantName, "#0066A1"))
|
||||||
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
binding.etAmount.setText("%.2f".format(page.amount))
|
||||||
|
fragment.setAmountLocked(true)
|
||||||
|
binding.tilRemarks.isEnabled = false
|
||||||
|
binding.tilRemarks.alpha = 0.4f
|
||||||
|
onStateChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops the loaded card merchant and unlocks the amount/remarks fields. */
|
||||||
|
fun clearCardMerchant() {
|
||||||
|
if (cardMerchant == null) return
|
||||||
|
draft.bmlCardMerchant = null
|
||||||
|
fragment.setAmountLocked(false)
|
||||||
|
binding.tilRemarks.isEnabled = true
|
||||||
|
binding.tilRemarks.alpha = 1f
|
||||||
|
binding.etAmount.setText("")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirm-then-pay for the loaded card merchant, using the selected verified card. */
|
||||||
|
fun submitCardPayment() {
|
||||||
|
val page = cardMerchant ?: return
|
||||||
|
val src = currentSource()
|
||||||
|
if (src == null || !isCardVerified(src)) {
|
||||||
|
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
confirmCardMerchant(page, src)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The card payment's confirm dialog (biometric-gated), then the Pomelo + 3-D Secure payment of
|
||||||
|
* [page] with [src]. Also the send step for carrier services paid by card
|
||||||
|
* ([CardPayoutTransferHandler]), once the carrier has created the BML transaction.
|
||||||
|
*/
|
||||||
|
fun confirmCardMerchant(
|
||||||
|
page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage,
|
||||||
|
src: BankAccount
|
||||||
|
) {
|
||||||
|
val fromTypeLabel = sh.sar.basedbank.util.AccountListParser.from(src)?.typeLabel
|
||||||
|
?: sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(src.accountTypeName)
|
||||||
|
val fromDetail = listOfNotNull("BML", fromTypeLabel.ifBlank { null }).joinToString(" · ")
|
||||||
|
val warnings = listOf(
|
||||||
|
"⚠ ${page.merchantName} does not support BML Pay. This transaction will be paid via card. " +
|
||||||
|
"Card payments can be less reliable, and this can take up to a minute to complete. " +
|
||||||
|
"Please keep the app open and don't retry if it seems slow."
|
||||||
|
)
|
||||||
|
val confirmView = fragment.buildTransferConfirmView(
|
||||||
|
amountCurrency = page.currency,
|
||||||
|
amountValue = "%.2f".format(page.amount),
|
||||||
|
fromName = src.accountBriefName,
|
||||||
|
fromNumber = src.accountNumber,
|
||||||
|
fromDetail = fromDetail,
|
||||||
|
toName = page.merchantName,
|
||||||
|
toNumber = "",
|
||||||
|
toDetail = page.merchantAddress.ifBlank { "BML Merchant" },
|
||||||
|
warningTexts = warnings
|
||||||
|
)
|
||||||
|
fragment.showConfirmWithBiometric(
|
||||||
|
title = ctx.getString(R.string.transfer),
|
||||||
|
customView = confirmView,
|
||||||
|
biometricSubtitle = "${page.currency} ${"%.2f".format(page.amount)} → ${page.merchantName}",
|
||||||
|
onConfirmed = { dialog, frame ->
|
||||||
|
fragment.showProcessingInDialog(dialog, frame)
|
||||||
|
executeCardMerchant(page, src, dialog, frame)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executeCardMerchant(
|
||||||
|
page: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage,
|
||||||
|
src: BankAccount,
|
||||||
|
dialog: AlertDialog,
|
||||||
|
frame: android.widget.FrameLayout
|
||||||
|
) {
|
||||||
|
val (card, otpSeed) = BmlVerifiedCards.load(ctx, src) ?: run {
|
||||||
|
dialog.dismiss()
|
||||||
|
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
BmlMerchantCardPayClient().pay(page, card) { retry ->
|
||||||
|
// A code about to roll over can expire before the ACS checks it, and a
|
||||||
|
// retry in the same window would resend the rejected code: wait for the
|
||||||
|
// next window in both cases. Runs on IO.
|
||||||
|
val left = TOTP_WINDOW_MS - System.currentTimeMillis() % TOTP_WINDOW_MS
|
||||||
|
if (retry || left < TOTP_MIN_LEFT_MS) Thread.sleep(left + 500)
|
||||||
|
Totp.generate(otpSeed)
|
||||||
|
}
|
||||||
|
}.getOrElse {
|
||||||
|
BmlMerchantCardPayClient.Result.Failure(it.message ?: "Payment failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fragment.view == null) return@launch
|
||||||
|
when (result) {
|
||||||
|
is BmlMerchantCardPayClient.Result.Success -> {
|
||||||
|
fragment.showSuccessInDialog(
|
||||||
|
dialog, frame,
|
||||||
|
amountCurrency = page.currency,
|
||||||
|
amountValue = "%.2f".format(page.amount),
|
||||||
|
fromName = src.accountBriefName,
|
||||||
|
toName = page.merchantName
|
||||||
|
) {
|
||||||
|
fragment.clearForm()
|
||||||
|
host?.triggerRefresh()
|
||||||
|
}
|
||||||
|
// Charged, but the merchant may not deliver until it's told
|
||||||
|
if (!result.merchantNotified) {
|
||||||
|
Toast.makeText(ctx, ctx.getString(R.string.bml_card_pay_merchant_not_notified,
|
||||||
|
page.merchantName, page.transactionId), Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is BmlMerchantCardPayClient.Result.Failure -> {
|
||||||
|
dialog.dismiss()
|
||||||
|
Toast.makeText(ctx, result.message, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Personal-profile transfer (token OTP, no user interaction) ──────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs initiate + confirm on the calling (IO) thread and returns
|
||||||
|
* `(ok, message, receipt)`. Message is "CONNECTIVITY" when the network was unreachable.
|
||||||
|
*/
|
||||||
|
fun doTransfer(
|
||||||
|
src: BankAccount,
|
||||||
|
destAccount: String,
|
||||||
|
destDisplay: String,
|
||||||
|
amount: Double,
|
||||||
|
amountStr: String,
|
||||||
|
remarks: String,
|
||||||
|
isSrcCard: Boolean,
|
||||||
|
isDestMib: Boolean,
|
||||||
|
currency: String,
|
||||||
|
allAccounts: List<BankAccount>,
|
||||||
|
allContacts: List<BankContact>
|
||||||
|
): Triple<Boolean, String, TransferReceiptData?> {
|
||||||
|
val loginId = src.loginTag.removePrefix("bml_")
|
||||||
|
val sess = sessionFor(src) ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
|
||||||
|
val otp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||||
|
?.let { Totp.generate(it) }
|
||||||
|
?: return Triple(false, "OTP unavailable", null)
|
||||||
|
val debitAccount = src.internalId.ifBlank {
|
||||||
|
return Triple(false, ctx.getString(R.string.transfer_missing_internal_id), null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val routed = routeTransfer(destAccount, isSrcCard, isDestMib, currency, allAccounts, allContacts)
|
||||||
|
?: return Triple(false, "BML contact not found for this account", null)
|
||||||
|
val (transferType, creditAccount, bank) = routed
|
||||||
|
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
|
||||||
|
|
||||||
|
// Step 1: initiate
|
||||||
|
val initiated = try {
|
||||||
|
BmlTransferClient().initiateTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, bank)
|
||||||
|
} catch (e: Exception) { return Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Initiation failed"), null) }
|
||||||
|
|
||||||
|
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
|
||||||
|
|
||||||
|
// Step 2: confirm with fresh OTP
|
||||||
|
val confirmOtp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||||
|
?.let { Totp.generate(it) } ?: otp
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val result = BmlTransferClient().confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
|
||||||
|
if (result.success) {
|
||||||
|
val receipt = TransferReceiptData(
|
||||||
|
bank = "BML",
|
||||||
|
amount = "%.2f".format(amount),
|
||||||
|
currency = currency,
|
||||||
|
fromLabel = src.accountBriefName,
|
||||||
|
fromColorHex = "#0066A1",
|
||||||
|
toLabel = destDisplay.ifBlank { destAccount },
|
||||||
|
toAccount = destAccount,
|
||||||
|
toBank = toBank,
|
||||||
|
remarks = remarks,
|
||||||
|
bmlFromName = src.accountBriefName,
|
||||||
|
bmlReference = result.reference,
|
||||||
|
bmlTimestamp = result.timestamp,
|
||||||
|
bmlMessage = result.message
|
||||||
|
)
|
||||||
|
val time = result.timestamp.take(19).replace("T", " ")
|
||||||
|
Triple(true, "Reference: ${result.reference}\n$time", receipt)
|
||||||
|
} else {
|
||||||
|
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Business profile OTP flow ───────────────────────────────────────────
|
||||||
|
|
||||||
|
fun startBusinessOtpFlow(
|
||||||
|
src: BankAccount,
|
||||||
|
destAccount: String,
|
||||||
|
destDisplay: String,
|
||||||
|
amount: Double,
|
||||||
|
amountStr: String,
|
||||||
|
remarks: String,
|
||||||
|
isSrcCard: Boolean,
|
||||||
|
isDestMib: Boolean,
|
||||||
|
currency: String,
|
||||||
|
allAccounts: List<BankAccount>,
|
||||||
|
allContacts: List<BankContact>,
|
||||||
|
toAvatar: Bitmap?
|
||||||
|
) {
|
||||||
|
val debitAccount = src.internalId.ifBlank {
|
||||||
|
Toast.makeText(ctx, ctx.getString(R.string.transfer_missing_internal_id), Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val routed = routeTransfer(destAccount, isSrcCard, isDestMib, currency, allAccounts, allContacts)
|
||||||
|
if (routed == null) {
|
||||||
|
Toast.makeText(ctx, "BML contact not found for this account", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val (transferType, creditAccount, bank) = routed
|
||||||
|
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
|
||||||
|
|
||||||
|
pendingTransfer = PendingTransfer(
|
||||||
|
src = src,
|
||||||
|
debitAccount = debitAccount,
|
||||||
|
creditAccount = creditAccount,
|
||||||
|
amount = amount,
|
||||||
|
amountStr = amountStr,
|
||||||
|
remarks = remarks,
|
||||||
|
transferType = transferType,
|
||||||
|
currency = currency,
|
||||||
|
bank = bank,
|
||||||
|
destDisplay = destDisplay,
|
||||||
|
destAccount = destAccount,
|
||||||
|
toBank = toBank,
|
||||||
|
toAvatar = toAvatar
|
||||||
|
)
|
||||||
|
|
||||||
|
otpState = OtpState.SELECTING_CHANNEL
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val sess = sessionFor(src)
|
||||||
|
val channels = if (sess != null) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
try { BmlAccountClient().fetchTransferChannels(sess) }
|
||||||
|
catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
} else emptyList<BmlOtpChannel>()
|
||||||
|
|
||||||
|
host?.setRefreshing(false)
|
||||||
|
|
||||||
|
if (channels.isEmpty()) {
|
||||||
|
Toast.makeText(ctx, "Could not load OTP channels", Toast.LENGTH_SHORT).show()
|
||||||
|
resetOtpState()
|
||||||
|
onStateChanged()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
showChannelSelection(channels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showChannelSelection(channels: List<BmlOtpChannel>) {
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
binding.containerBmlChannels.removeAllViews()
|
||||||
|
|
||||||
|
for (channel in channels) {
|
||||||
|
val iconRes = when (channel.channel) {
|
||||||
|
"email" -> R.drawable.ic_channel_email
|
||||||
|
"mobile" -> R.drawable.ic_channel_sms
|
||||||
|
else -> R.drawable.ic_channel_sms
|
||||||
|
}
|
||||||
|
val iconSize = (24 * dp).toInt()
|
||||||
|
|
||||||
|
val textCol = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
|
||||||
|
marginStart = (12 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = channel.description
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
||||||
|
})
|
||||||
|
textCol.addView(TextView(ctx).apply {
|
||||||
|
text = channel.masked
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||||
|
alpha = 0.6f
|
||||||
|
})
|
||||||
|
|
||||||
|
val row = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.HORIZONTAL
|
||||||
|
gravity = Gravity.CENTER_VERTICAL
|
||||||
|
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||||
|
background = ta.getDrawable(0); ta.recycle()
|
||||||
|
isClickable = true; isFocusable = true
|
||||||
|
val hp = (16 * dp).toInt(); val vp = (12 * dp).toInt()
|
||||||
|
setPadding(hp, vp, hp, vp)
|
||||||
|
}
|
||||||
|
row.addView(ImageView(ctx).apply { setImageResource(iconRes) },
|
||||||
|
LinearLayout.LayoutParams(iconSize, iconSize))
|
||||||
|
row.addView(textCol)
|
||||||
|
row.setOnClickListener { selectOtpChannel(channel) }
|
||||||
|
binding.containerBmlChannels.addView(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
disableTransferFields()
|
||||||
|
binding.layoutBmlChannelSelection.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectOtpChannel(channel: BmlOtpChannel) {
|
||||||
|
otpChannel = channel.channel
|
||||||
|
binding.layoutBmlChannelSelection.visibility = View.GONE
|
||||||
|
|
||||||
|
val pending = pendingTransfer ?: return
|
||||||
|
val sess = sessionFor(pending.src) ?: run {
|
||||||
|
Toast.makeText(ctx, ctx.getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
|
||||||
|
resetOtpState()
|
||||||
|
onStateChanged()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val initiated = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
BmlTransferClient().initiateTransfer(
|
||||||
|
sess, pending.debitAccount, pending.creditAccount,
|
||||||
|
pending.amount, pending.transferType, pending.currency,
|
||||||
|
pending.bank, channel.channel
|
||||||
|
)
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
}
|
||||||
|
host?.setRefreshing(false)
|
||||||
|
|
||||||
|
if (!initiated) {
|
||||||
|
Toast.makeText(ctx, "Failed to initiate transfer — check your session", Toast.LENGTH_SHORT).show()
|
||||||
|
resetOtpState()
|
||||||
|
onStateChanged()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
otpState = OtpState.AWAITING_OTP
|
||||||
|
binding.tvBmlOtpSentVia.text = "OTP code sent via: ${channel.description} (${channel.masked})"
|
||||||
|
binding.tvBmlOtpSentVia.visibility = View.VISIBLE
|
||||||
|
binding.tilBmlOtp.visibility = View.VISIBLE
|
||||||
|
binding.etBmlOtp.requestFocus()
|
||||||
|
binding.btnTransfer.text = ctx.getString(R.string.transfer_verify_payment)
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Send-button action while [isAwaitingOtp]: confirms the pending transfer with the typed code. */
|
||||||
|
fun verifyOtp() {
|
||||||
|
val otp = binding.etBmlOtp.text?.toString()?.trim() ?: ""
|
||||||
|
if (otp.isEmpty()) {
|
||||||
|
binding.tilBmlOtp.error = "Enter the verification code"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.tilBmlOtp.error = null
|
||||||
|
val pending = pendingTransfer ?: return
|
||||||
|
val channel = otpChannel ?: return
|
||||||
|
val sess = sessionFor(pending.src) ?: run {
|
||||||
|
Toast.makeText(ctx, ctx.getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
|
val capturedToAvatar = pending.toAvatar
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val result = BmlTransferClient().confirmTransfer(
|
||||||
|
sess, pending.debitAccount, pending.creditAccount,
|
||||||
|
pending.amount, pending.transferType, pending.currency,
|
||||||
|
otp, pending.remarks, pending.bank, channel
|
||||||
|
)
|
||||||
|
if (result.success) {
|
||||||
|
val r = TransferReceiptData(
|
||||||
|
bank = "BML",
|
||||||
|
amount = "%.2f".format(pending.amount),
|
||||||
|
currency = pending.currency,
|
||||||
|
fromLabel = pending.src.accountBriefName,
|
||||||
|
fromColorHex = "#0066A1",
|
||||||
|
toLabel = pending.destDisplay.ifBlank { pending.destAccount },
|
||||||
|
toAccount = pending.destAccount,
|
||||||
|
toBank = pending.toBank,
|
||||||
|
remarks = pending.remarks,
|
||||||
|
bmlFromName = pending.src.accountBriefName,
|
||||||
|
bmlReference = result.reference,
|
||||||
|
bmlTimestamp = result.timestamp,
|
||||||
|
bmlMessage = result.message
|
||||||
|
)
|
||||||
|
Triple(true, "", r)
|
||||||
|
} else {
|
||||||
|
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null as TransferReceiptData?)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null as TransferReceiptData?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
host?.setRefreshing(false)
|
||||||
|
|
||||||
|
if (ok && receipt != null) {
|
||||||
|
resetOtpState()
|
||||||
|
onTransferSuccess(receipt, capturedToAvatar)
|
||||||
|
} else {
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
if (msg == "CONNECTIVITY") {
|
||||||
|
host?.showConnectivityBanner(ctx.getString(R.string.connectivity_no_internet))
|
||||||
|
} else {
|
||||||
|
binding.tilBmlOtp.error = msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tears down the OTP UI and re-enables the form. Safe to call when no flow is running. */
|
||||||
|
fun resetOtpState() {
|
||||||
|
otpState = OtpState.NONE
|
||||||
|
otpChannel = null
|
||||||
|
pendingTransfer = null
|
||||||
|
if (fragment.view == null) return
|
||||||
|
binding.layoutBmlChannelSelection.visibility = View.GONE
|
||||||
|
binding.tvBmlOtpSentVia.visibility = View.GONE
|
||||||
|
binding.tilBmlOtp.visibility = View.GONE
|
||||||
|
binding.etBmlOtp.setText("")
|
||||||
|
binding.tilBmlOtp.error = null
|
||||||
|
enableTransferFields()
|
||||||
|
binding.btnTransfer.text = ctx.getString(R.string.transfer)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disableTransferFields() {
|
||||||
|
binding.tilAmount.isEnabled = false
|
||||||
|
binding.tilRemarks.isEnabled = false
|
||||||
|
binding.cardFromInfo.alpha = 0.5f
|
||||||
|
binding.btnClearFromInfo.isEnabled = false
|
||||||
|
binding.cardToInfo.alpha = 0.5f
|
||||||
|
binding.btnClearToInfo.isEnabled = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enableTransferFields() {
|
||||||
|
binding.tilAmount.isEnabled = true
|
||||||
|
binding.tilRemarks.isEnabled = true
|
||||||
|
binding.cardFromInfo.alpha = 1f
|
||||||
|
binding.btnClearFromInfo.isEnabled = true
|
||||||
|
binding.cardToInfo.alpha = 1f
|
||||||
|
binding.btnClearToInfo.isEnabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Shared routing ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Picks the BML transfer type and the credit-account identifier the API expects.
|
||||||
|
* Returns null only for the USD → MIB case with no matching saved BML contact, which both
|
||||||
|
* callers report as "BML contact not found".
|
||||||
|
*/
|
||||||
|
private fun routeTransfer(
|
||||||
|
destAccount: String,
|
||||||
|
isSrcCard: Boolean,
|
||||||
|
isDestMib: Boolean,
|
||||||
|
currency: String,
|
||||||
|
allAccounts: List<BankAccount>,
|
||||||
|
allContacts: List<BankContact>
|
||||||
|
): Triple<String, String, String?>? {
|
||||||
|
val isDestMyCard = allAccounts.any { isCard(it) && it.accountNumber == destAccount }
|
||||||
|
return when {
|
||||||
|
isSrcCard -> {
|
||||||
|
// CAD: card → own BML account
|
||||||
|
val destBml = allAccounts.firstOrNull { it.accountNumber == destAccount && it.profileType == "BML" }
|
||||||
|
Triple("CAD", destBml?.internalId?.ifBlank { destAccount } ?: destAccount, null)
|
||||||
|
}
|
||||||
|
isDestMyCard -> {
|
||||||
|
// CPA: BML CASA → own card top-up
|
||||||
|
val card = allAccounts.first { isCard(it) && it.accountNumber == destAccount }
|
||||||
|
Triple("CPA", card.internalId.ifBlank { destAccount }, null)
|
||||||
|
}
|
||||||
|
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
|
||||||
|
isDestMib -> {
|
||||||
|
// USD DOT: requires BML contact numeric ID
|
||||||
|
val contact = allContacts.firstOrNull { it.benefCategoryId == "BML" && it.benefAccount == destAccount }
|
||||||
|
?: return null
|
||||||
|
Triple("DOT", contact.benefNo.removePrefix("bml_"), null)
|
||||||
|
}
|
||||||
|
else -> Triple("IAT", destAccount, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isCard(account: BankAccount) = BmlVerifiedCards.isCard(account)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** The BML token's TOTP window. */
|
||||||
|
const val TOTP_WINDOW_MS = 30_000L
|
||||||
|
/** Don't send a card payment's 3-D Secure code with less than this left in its window. */
|
||||||
|
const val TOTP_MIN_LEFT_MS = 5_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package sh.sar.basedbank.ui.home.transfer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import sh.sar.basedbank.api.bml.BmlMerchantCardPayClient
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.VerifiedCardStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BML cards that can pay a merchant by card + 3-D Secure: the card is verified (full details in
|
||||||
|
* [VerifiedCardStore]) and belongs to a BML login we hold the OTP seed for, since the 3-D Secure
|
||||||
|
* step is answered with that login's token code.
|
||||||
|
*/
|
||||||
|
object BmlVerifiedCards {
|
||||||
|
|
||||||
|
/** A payable card's details, ready for [BmlMerchantCardPayClient.pay]. */
|
||||||
|
data class Payable(val card: BmlMerchantCardPayClient.Card, val otpSeed: String)
|
||||||
|
|
||||||
|
fun isCard(account: BankAccount) =
|
||||||
|
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
|
||||||
|
|
||||||
|
fun isPayable(ctx: Context, account: BankAccount): Boolean =
|
||||||
|
isCard(account) && VerifiedCardStore.isVerified(ctx, key(account)) && otpSeed(ctx, account) != null
|
||||||
|
|
||||||
|
fun payable(ctx: Context, accounts: List<BankAccount>): List<BankAccount> {
|
||||||
|
val verified = VerifiedCardStore.keys(ctx)
|
||||||
|
return accounts.filter { isCard(it) && key(it) in verified && otpSeed(ctx, it) != null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stored card details and OTP seed for [account], or null when it isn't payable. */
|
||||||
|
fun load(ctx: Context, account: BankAccount): Payable? {
|
||||||
|
val stored = VerifiedCardStore.load(ctx, key(account)) ?: return null
|
||||||
|
val seed = otpSeed(ctx, account) ?: return null
|
||||||
|
val expiry = stored.expiry.split("/") // "MM/YY"
|
||||||
|
if (expiry.size != 2) return null
|
||||||
|
return Payable(
|
||||||
|
card = BmlMerchantCardPayClient.Card(
|
||||||
|
pan = stored.pan,
|
||||||
|
expiryMonth = expiry[0].padStart(2, '0'),
|
||||||
|
expiryYear = expiry[1].takeLast(2),
|
||||||
|
cvv = stored.cvv,
|
||||||
|
holderName = account.accountBriefName
|
||||||
|
),
|
||||||
|
otpSeed = seed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun key(account: BankAccount) = "bml:${account.accountNumber}"
|
||||||
|
|
||||||
|
private fun otpSeed(ctx: Context, account: BankAccount) =
|
||||||
|
CredentialStore(ctx).loadBmlCredentials(account.loginTag.removePrefix("bml_"))?.otpSeed
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package sh.sar.basedbank.ui.home.transfer
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.bml.BmlMerchantTxnClient
|
||||||
|
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
|
||||||
|
import sh.sar.basedbank.api.dhiraagu.DhiraaguPaymentClient
|
||||||
|
import sh.sar.basedbank.api.fahipay.OoredooClient
|
||||||
|
import sh.sar.basedbank.api.ooredoo.OoredooPaymentClient
|
||||||
|
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||||
|
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||||
|
import sh.sar.basedbank.ui.home.TransferFragment
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A carrier service a verified BML card can pay, through the carrier's own website and its BML
|
||||||
|
* merchant gateway. The limits are the carrier website's, not Fahipay's.
|
||||||
|
*
|
||||||
|
* Add new services as constants; the exhaustive `when`s over this enum point at every site that
|
||||||
|
* needs updating.
|
||||||
|
*/
|
||||||
|
enum class CardPayoutService(
|
||||||
|
override val label: String,
|
||||||
|
override val destinationLabel: String,
|
||||||
|
@param:DrawableRes override val iconRes: Int,
|
||||||
|
override val minAmount: Int,
|
||||||
|
override val maxAmount: Int?,
|
||||||
|
override val decimalsAllowed: Boolean,
|
||||||
|
override val gstPercent: Int?,
|
||||||
|
/**
|
||||||
|
* Tags the recents paid with this service (`RecentPick.contactCategory`), so picking one
|
||||||
|
* again pays the same way. Not a real contact list: card payouts have no favourites.
|
||||||
|
*/
|
||||||
|
val contactCategory: String,
|
||||||
|
override val gstAdded: Boolean = false,
|
||||||
|
) : PayoutService {
|
||||||
|
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo,
|
||||||
|
minAmount = 20, maxAmount = 1000, decimalsAllowed = false, gstPercent = 8,
|
||||||
|
contactCategory = "CARD_DHIRAAGU_RELOAD") {
|
||||||
|
// Dhiraagu rounds the GST to 2 places and credits the rest
|
||||||
|
override fun creditedAfterGst(amount: BigDecimal): BigDecimal =
|
||||||
|
amount - DhiraaguPaymentClient.gstOf(amount.setScale(0, RoundingMode.DOWN).toInt())
|
||||||
|
},
|
||||||
|
// Easy Pay sets no limits of its own: any amount with up to 2 decimals, no GST
|
||||||
|
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo,
|
||||||
|
minAmount = 1, maxAmount = null, decimalsAllowed = true, gstPercent = null,
|
||||||
|
contactCategory = "CARD_DHIRAAGU_BILL"),
|
||||||
|
// Ooredoo credits the whole amount and charges the card 8% GST on top
|
||||||
|
OOREDOO_RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo,
|
||||||
|
minAmount = 20, maxAmount = null, decimalsAllowed = false, gstPercent = 8,
|
||||||
|
contactCategory = "CARD_RAASTAS", gstAdded = true),
|
||||||
|
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo,
|
||||||
|
minAmount = 10, maxAmount = null, decimalsAllowed = true, gstPercent = null,
|
||||||
|
contactCategory = "CARD_OOREDOO_BILL");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** The service a recent was paid with, from its [contactCategory], or null when it isn't one. */
|
||||||
|
fun fromContactCategory(categoryId: String?): CardPayoutService? =
|
||||||
|
entries.firstOrNull { it.contactCategory == categoryId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the Transfer screen's "carrier service by BML card" parts: which services a looked-up
|
||||||
|
* number can be paid with by card, and the picked service's amount rules.
|
||||||
|
*
|
||||||
|
* Sending only differs from paying a card-only BML merchant link in where the BML transaction
|
||||||
|
* comes from: the carrier's website creates it for the number and amount. From there it goes
|
||||||
|
* through [BmlTransferHandler.confirmCardMerchant] — the same confirm dialog, card + 3-D Secure
|
||||||
|
* payment and result as a pasted link.
|
||||||
|
*
|
||||||
|
* Mirrors [FahipayTransferHandler]: the fragment keeps the recipient card and the form state.
|
||||||
|
* Lifetime is bound to the fragment's view.
|
||||||
|
*/
|
||||||
|
class CardPayoutTransferHandler(
|
||||||
|
private val fragment: TransferFragment,
|
||||||
|
private val binding: FragmentTransferBinding,
|
||||||
|
private val viewModel: HomeViewModel,
|
||||||
|
private val bmlHandler: () -> BmlTransferHandler,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val ctx get() = fragment.requireContext()
|
||||||
|
private val amountField = PayoutAmountField(binding) { ctx }
|
||||||
|
|
||||||
|
/** The service picked for the current recipient; null when none is. */
|
||||||
|
var service: CardPayoutService?
|
||||||
|
get() = viewModel.transferDraft.cardPayoutService
|
||||||
|
private set(value) { viewModel.transferDraft.cardPayoutService = value }
|
||||||
|
|
||||||
|
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||||
|
|
||||||
|
/** Account numbers of the cards that can pay by card. Call on the main thread. */
|
||||||
|
fun payableCards(): Set<String> =
|
||||||
|
BmlVerifiedCards.payable(ctx, viewModel.accounts.value ?: emptyList())
|
||||||
|
.map { it.accountNumber }.toSet()
|
||||||
|
|
||||||
|
/** The card transfer types a carrier lookup [result] allows, payable from [cards]. */
|
||||||
|
fun typesFor(result: CarrierLookup.Result, cards: Set<String>): List<TransferType.Card> {
|
||||||
|
if (cards.isEmpty()) return emptyList()
|
||||||
|
return buildList {
|
||||||
|
when (result.dhiraagu.type) {
|
||||||
|
DhiraaguClient.CustType.RELOAD -> add(CardPayoutService.DHIRAAGU_RELOAD)
|
||||||
|
DhiraaguClient.CustType.BILL_PAY -> add(CardPayoutService.DHIRAAGU_BILL)
|
||||||
|
DhiraaguClient.CustType.UNSUPPORTED -> {}
|
||||||
|
}
|
||||||
|
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) {
|
||||||
|
add(CardPayoutService.OOREDOO_RAASTAS)
|
||||||
|
}
|
||||||
|
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) {
|
||||||
|
add(CardPayoutService.OOREDOO_BILL)
|
||||||
|
}
|
||||||
|
}.map { TransferType.Card(it, result.ownerName, cards) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forgets the picked service and gives back the amount and reference fields. */
|
||||||
|
fun clearState() {
|
||||||
|
if (service == null) return
|
||||||
|
service = null
|
||||||
|
amountField.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why the typed amount can't be sent with the picked service, or null when it can. */
|
||||||
|
val amountProblem: String?
|
||||||
|
get() = service?.let(amountField::problem)
|
||||||
|
|
||||||
|
/** Keeps the amount and reference fields in step with the picked service. */
|
||||||
|
fun syncAmountField() {
|
||||||
|
service?.let(amountField::sync)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills the recipient card for a picked card transfer type. The fragment has already
|
||||||
|
* switched the source to one of the type's cards.
|
||||||
|
*/
|
||||||
|
fun applyService(type: TransferType.Card, number: String) {
|
||||||
|
service = type.service
|
||||||
|
// None of the payouts take a reference; syncAmountField() disables the box
|
||||||
|
binding.etRemarks.setText("")
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
val displayName = type.ownerName
|
||||||
|
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
|
||||||
|
?: number
|
||||||
|
fragment.prefillToDirectly(
|
||||||
|
accountNumber = number,
|
||||||
|
displayName = displayName,
|
||||||
|
subtitle = "${type.label} · $number",
|
||||||
|
colorHex = "#E4002B",
|
||||||
|
imageHash = null,
|
||||||
|
contactCategory = type.service.contactCategory
|
||||||
|
)
|
||||||
|
fragment.focusAmount()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Send ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Has the carrier create the BML transaction for the number and amount, then hands its
|
||||||
|
* payment page to the card merchant flow. Nothing is charged until that flow's confirm.
|
||||||
|
*/
|
||||||
|
fun submit() {
|
||||||
|
val svc = service ?: return
|
||||||
|
val src = viewModel.transferDraft.selectedAccount
|
||||||
|
if (src == null || !BmlVerifiedCards.isPayable(ctx, src)) {
|
||||||
|
Toast.makeText(ctx, R.string.bml_card_pay_no_verified, Toast.LENGTH_LONG).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val number = viewModel.transferDraft.transferTypeNumber
|
||||||
|
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
|
||||||
|
if (number.isBlank() || amount == null || amount.signum() <= 0 || amountProblem != null) return
|
||||||
|
val charged = svc.chargedWithGst(amount)
|
||||||
|
|
||||||
|
// Creating the order takes a few round trips; show the payment's processing box meanwhile
|
||||||
|
val processing = fragment.showProcessingDialog(ctx.getString(R.string.transfer))
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val page = withContext(Dispatchers.IO) {
|
||||||
|
runCatching {
|
||||||
|
val txnId = when (svc) {
|
||||||
|
CardPayoutService.DHIRAAGU_RELOAD ->
|
||||||
|
DhiraaguPaymentClient().createReloadTransaction(number, amount.intValueExact())
|
||||||
|
CardPayoutService.DHIRAAGU_BILL ->
|
||||||
|
DhiraaguPaymentClient().createBillPayTransaction(number, amount)
|
||||||
|
CardPayoutService.OOREDOO_RAASTAS ->
|
||||||
|
OoredooPaymentClient().createRaastasTransaction(number, amount.intValueExact(), charged)
|
||||||
|
CardPayoutService.OOREDOO_BILL ->
|
||||||
|
OoredooPaymentClient().createBillPayTransaction(number, amount)
|
||||||
|
}
|
||||||
|
BmlMerchantTxnClient().fetchPayPage(txnId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
processing.dismiss()
|
||||||
|
if (fragment.view == null) return@launch
|
||||||
|
page.onSuccess {
|
||||||
|
when {
|
||||||
|
!it.supportsCard ->
|
||||||
|
Toast.makeText(ctx, R.string.transfer_bml_txn_lookup_failed, Toast.LENGTH_LONG).show()
|
||||||
|
// The carrier's order must be for exactly what was typed (plus GST, if added)
|
||||||
|
it.amount.toBigDecimal().compareTo(charged) != 0 ->
|
||||||
|
Toast.makeText(ctx, R.string.transfer_bml_txn_lookup_failed, Toast.LENGTH_LONG).show()
|
||||||
|
else -> bmlHandler().confirmCardMerchant(it, src)
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
val msg = when {
|
||||||
|
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
|
||||||
|
!e.message.isNullOrBlank() -> e.message!!
|
||||||
|
else -> ctx.getString(R.string.transfer_bml_txn_lookup_failed)
|
||||||
|
}
|
||||||
|
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package sh.sar.basedbank.ui.home.transfer
|
||||||
|
|
||||||
|
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
|
||||||
|
import sh.sar.basedbank.api.fahipay.OoredooClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which carrier a phone number is on, and how it's billed. Feeds both payout routes: the
|
||||||
|
* Fahipay services and the BML card services each map this to what they can pay.
|
||||||
|
*/
|
||||||
|
object CarrierLookup {
|
||||||
|
|
||||||
|
data class Result(
|
||||||
|
val dhiraagu: DhiraaguClient.Result,
|
||||||
|
val ooredoo: OoredooClient.CustType,
|
||||||
|
) {
|
||||||
|
/** Dhiraagu is the only carrier that hands back an owner name. */
|
||||||
|
val ownerName: String? get() = dhiraagu.ownerName.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asks the likelier carrier first based on the leading digit and only falls back to the
|
||||||
|
* other when the first says it doesn't know the number. Blocking — call from IO.
|
||||||
|
*/
|
||||||
|
fun query(number: String): Result =
|
||||||
|
if (number.startsWith("7")) {
|
||||||
|
// Dhiraagu first, fall back to Ooredoo
|
||||||
|
val d = dhiraagu(number)
|
||||||
|
val o = if (d.type == DhiraaguClient.CustType.UNSUPPORTED) ooredoo(number)
|
||||||
|
else OoredooClient.CustType.UNSUPPORTED
|
||||||
|
Result(d, o)
|
||||||
|
} else {
|
||||||
|
// Ooredoo first, fall back to Dhiraagu
|
||||||
|
val o = ooredoo(number)
|
||||||
|
val d = if (o == OoredooClient.CustType.UNSUPPORTED) dhiraagu(number)
|
||||||
|
else DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED)
|
||||||
|
Result(d, o)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dhiraagu(number: String) =
|
||||||
|
try { DhiraaguClient().validateNumber(number) }
|
||||||
|
catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) }
|
||||||
|
|
||||||
|
private fun ooredoo(number: String) =
|
||||||
|
try { OoredooClient().validateNumber(number) }
|
||||||
|
catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED }
|
||||||
|
}
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
package sh.sar.basedbank.ui.home.transfer
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
|
||||||
|
import sh.sar.basedbank.api.fahipay.FahipayPaymentClient
|
||||||
|
import sh.sar.basedbank.api.fahipay.OoredooClient
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||||
|
import sh.sar.basedbank.ui.home.HomeActivity
|
||||||
|
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||||
|
import sh.sar.basedbank.ui.home.TransferFragment
|
||||||
|
import sh.sar.basedbank.util.AccountInputParser
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A service a Fahipay wallet can pay out to. The carrier lookup decides which of these apply to
|
||||||
|
* a given number. The limits are Fahipay's — the same carrier service paid by card
|
||||||
|
* ([CardPayoutService]) has its own.
|
||||||
|
*
|
||||||
|
* Wallet-to-wallet Fahipay transfer is not here yet. Add it as a constant once its send path
|
||||||
|
* lands, and the exhaustive `when`s over this enum will point at every site that needs updating.
|
||||||
|
*/
|
||||||
|
enum class FahipayService(
|
||||||
|
override val label: String,
|
||||||
|
override val destinationLabel: String,
|
||||||
|
@param:DrawableRes override val iconRes: Int,
|
||||||
|
/** The endpoint [FahipayPaymentClient.pay] POSTs this service's payments to. */
|
||||||
|
val paymentPath: String,
|
||||||
|
override val minAmount: Int,
|
||||||
|
override val maxAmount: Int?,
|
||||||
|
override val decimalsAllowed: Boolean,
|
||||||
|
/**
|
||||||
|
* The contact category of this service's Fahipay favourites list (`BankContact.benefCategoryId`,
|
||||||
|
* set by `FahipayContactsClient`). Recents of this service are tagged with it too.
|
||||||
|
*/
|
||||||
|
val contactCategory: String,
|
||||||
|
override val gstPercent: Int? = null,
|
||||||
|
) : PayoutService {
|
||||||
|
RAASTAS("Raastas", "Ooredoo · Raastas", R.drawable.ooredoo_logo,
|
||||||
|
paymentPath = "actions/payment/ooredoo/recharge/",
|
||||||
|
minAmount = 11, maxAmount = null, decimalsAllowed = false,
|
||||||
|
contactCategory = "FAHIPAY_RAASTAS", gstPercent = 8),
|
||||||
|
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay", R.drawable.ooredoo_logo,
|
||||||
|
paymentPath = "actions/payment/ooredoo/billpay/",
|
||||||
|
minAmount = 10, maxAmount = 50000, decimalsAllowed = true,
|
||||||
|
contactCategory = "FAHIPAY_OOREDOO_BILL"),
|
||||||
|
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload", R.drawable.dhiraagu_logo,
|
||||||
|
paymentPath = "actions/payment/dhiraagu/recharge/",
|
||||||
|
minAmount = 8, maxAmount = 1000, decimalsAllowed = false,
|
||||||
|
contactCategory = "FAHIPAY_RELOAD"),
|
||||||
|
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay", R.drawable.dhiraagu_logo,
|
||||||
|
paymentPath = "actions/payment/dhiraagu/billpay/",
|
||||||
|
minAmount = 10, maxAmount = 5000, decimalsAllowed = false,
|
||||||
|
contactCategory = "FAHIPAY_DHIRAAGU_BILL");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* The service a saved Fahipay favourite or recent pays with, from its [contactCategory],
|
||||||
|
* or null when [categoryId] isn't one of them.
|
||||||
|
*/
|
||||||
|
fun fromContactCategory(categoryId: String?): FahipayService? =
|
||||||
|
entries.firstOrNull { it.contactCategory == categoryId }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the Fahipay-only parts of the Transfer screen: the carrier lookup that turns a phone
|
||||||
|
* number into a set of payable services, offering those in the fragment's "Transfer Type"
|
||||||
|
* picker, and the selected service that the confirm dialog labels the destination with.
|
||||||
|
*
|
||||||
|
* Mirrors [BmlTransferHandler] / [MfaisaTransferHandler]: the fragment keeps the shared confirm
|
||||||
|
* dialog, the recipient card and the form state; the handler keeps everything Fahipay-specific.
|
||||||
|
*
|
||||||
|
* [submit] sends the payment through [FahipayPaymentClient], with the shared confirm dialog
|
||||||
|
* and an in-dialog success screen (no receipt page yet).
|
||||||
|
*
|
||||||
|
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment]
|
||||||
|
* (for `viewLifecycleOwner` and Context) — and must be re-created when the view is recreated.
|
||||||
|
*/
|
||||||
|
class FahipayTransferHandler(
|
||||||
|
private val fragment: TransferFragment,
|
||||||
|
private val binding: FragmentTransferBinding,
|
||||||
|
private val viewModel: HomeViewModel,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val ctx get() = fragment.requireContext()
|
||||||
|
|
||||||
|
/** The service picked for the current recipient; null until a lookup resolves one. */
|
||||||
|
var service: FahipayService?
|
||||||
|
get() = viewModel.transferDraft.fahipayService
|
||||||
|
private set(value) { viewModel.transferDraft.fahipayService = value }
|
||||||
|
|
||||||
|
private val amountField = PayoutAmountField(binding) { ctx }
|
||||||
|
|
||||||
|
/** How the confirm dialog names the destination, or "" when nothing is selected. */
|
||||||
|
val destinationLabel: String get() = service?.destinationLabel.orEmpty()
|
||||||
|
|
||||||
|
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a destination for a Fahipay source. Only phone numbers are payable, so anything
|
||||||
|
* else is rejected inline on the "To" field.
|
||||||
|
*/
|
||||||
|
fun lookupRecipient(rawInput: String) {
|
||||||
|
if (AccountInputParser.detect(rawInput) != AccountInputParser.InputType.PHONE) {
|
||||||
|
binding.tilTo.error = ctx.getString(R.string.transfer_fahipay_phone_only)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lookupCarrier(rawInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forgets the selected service and gives back the amount and reference fields it took over.
|
||||||
|
* The picker itself is the fragment's to reset.
|
||||||
|
*/
|
||||||
|
fun clearState() {
|
||||||
|
if (service == null) return
|
||||||
|
service = null
|
||||||
|
amountField.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why the typed amount can't be sent with the selected service, or null when it can (or the
|
||||||
|
* field is empty, or no service is selected).
|
||||||
|
*/
|
||||||
|
val amountProblem: String?
|
||||||
|
get() = service?.let(amountField::problem)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the amount and reference fields in step with the selected service. A no-op when no
|
||||||
|
* service is selected; [clearState] puts the fields back.
|
||||||
|
*/
|
||||||
|
fun syncAmountField() {
|
||||||
|
service?.let(amountField::sync)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fills the recipient card for a picked Fahipay transfer type. The fragment has already
|
||||||
|
* switched the source to a Fahipay account.
|
||||||
|
*/
|
||||||
|
fun applyService(type: TransferType.Fahipay, number: String) {
|
||||||
|
service = type.service
|
||||||
|
// None of the payouts take a reference; syncAmountField() disables the box
|
||||||
|
binding.etRemarks.setText("")
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
val displayName = type.ownerName
|
||||||
|
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
|
||||||
|
?: number
|
||||||
|
fragment.prefillToDirectly(
|
||||||
|
accountNumber = number,
|
||||||
|
displayName = displayName,
|
||||||
|
subtitle = "${type.label} · $number",
|
||||||
|
colorHex = "#FF6B00",
|
||||||
|
imageHash = null,
|
||||||
|
contactCategory = type.service.contactCategory
|
||||||
|
)
|
||||||
|
fragment.focusAmount()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** The Fahipay transfer types a carrier lookup [result] allows. */
|
||||||
|
fun typesFor(result: CarrierLookup.Result): List<TransferType.Fahipay> =
|
||||||
|
servicesFor(result).map { TransferType.Fahipay(it, result.ownerName) }
|
||||||
|
|
||||||
|
// ─── Send ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pays the picked service: confirm dialog (with the GST note for services that charge it),
|
||||||
|
* biometric gate, then the payment POST. Success shows in the dialog; a refusal closes it
|
||||||
|
* and toasts the server's message.
|
||||||
|
*/
|
||||||
|
fun submit() {
|
||||||
|
val svc = service ?: return
|
||||||
|
val src = viewModel.transferDraft.selectedAccount?.takeIf { it.bank == "FAHIPAY" } ?: run {
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val number = viewModel.transferDraft.transferTypeNumber
|
||||||
|
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
|
||||||
|
if (number.isBlank() || amount == null || amount.signum() <= 0 || amountProblem != null) return
|
||||||
|
// Whole-number services get "11", never "11.00"
|
||||||
|
val amountParam = amount.stripTrailingZeros().toPlainString()
|
||||||
|
val amountDisplay = "%,.2f".format(amount)
|
||||||
|
val toName = binding.tvToAccountName.text?.toString().orEmpty().ifBlank { number }
|
||||||
|
|
||||||
|
val confirmView = fragment.buildTransferConfirmView(
|
||||||
|
amountCurrency = "MVR",
|
||||||
|
amountValue = amountDisplay,
|
||||||
|
fromName = src.accountBriefName,
|
||||||
|
fromNumber = src.accountNumber,
|
||||||
|
fromDetail = "Fahipay",
|
||||||
|
toName = toName,
|
||||||
|
toNumber = number,
|
||||||
|
toDetail = svc.destinationLabel,
|
||||||
|
warningTexts = listOfNotNull(amountField.gstNote(svc))
|
||||||
|
)
|
||||||
|
fragment.showConfirmWithBiometric(
|
||||||
|
title = ctx.getString(R.string.transfer),
|
||||||
|
customView = confirmView,
|
||||||
|
biometricSubtitle = "MVR $amountDisplay → ${svc.label} $number",
|
||||||
|
onConfirmed = { dialog, frame ->
|
||||||
|
fragment.showProcessingInDialog(dialog, frame)
|
||||||
|
pay(src, svc, number, amountParam, amountDisplay, toName, dialog, frame)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pay(
|
||||||
|
src: BankAccount,
|
||||||
|
svc: FahipayService,
|
||||||
|
number: String,
|
||||||
|
amountParam: String,
|
||||||
|
amountDisplay: String,
|
||||||
|
toName: String,
|
||||||
|
dialog: AlertDialog,
|
||||||
|
frame: android.widget.FrameLayout,
|
||||||
|
) {
|
||||||
|
val app = fragment.requireActivity().application as BasedBankApp
|
||||||
|
val session = app.fahipaySessionFor(src) ?: run {
|
||||||
|
dialog.dismiss()
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val deviceUuid = CredentialStore(ctx).getOrCreateFahipayDeviceUuid()
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { FahipayPaymentClient().pay(session, svc.paymentPath, number, amountParam, deviceUuid) }
|
||||||
|
}
|
||||||
|
if (fragment.view == null) return@launch
|
||||||
|
result.onSuccess {
|
||||||
|
fragment.showSuccessInDialog(
|
||||||
|
dialog, frame,
|
||||||
|
amountCurrency = "MVR",
|
||||||
|
amountValue = amountDisplay,
|
||||||
|
fromName = src.accountBriefName,
|
||||||
|
toName = "$toName · ${svc.label}"
|
||||||
|
) {
|
||||||
|
fragment.clearForm()
|
||||||
|
(fragment.activity as? HomeActivity)?.triggerRefresh()
|
||||||
|
}
|
||||||
|
}.onFailure { e ->
|
||||||
|
dialog.dismiss()
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
val msg = when {
|
||||||
|
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
|
||||||
|
!e.message.isNullOrBlank() -> e.message!!
|
||||||
|
else -> "Payment failed"
|
||||||
|
}
|
||||||
|
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Carrier lookup ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun lookupCarrier(number: String) {
|
||||||
|
fragment.resetTransferTypes()
|
||||||
|
fragment.startLookupLoading()
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val types = withContext(Dispatchers.IO) { typesFor(CarrierLookup.query(number)) }
|
||||||
|
fragment.stopLookupLoading()
|
||||||
|
if (types.isEmpty()) return@launch
|
||||||
|
fragment.offerTransferTypes(number, types)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun servicesFor(result: CarrierLookup.Result): List<FahipayService> = buildList {
|
||||||
|
if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add(FahipayService.DHIRAAGU_RELOAD)
|
||||||
|
if (result.dhiraagu.type == DhiraaguClient.CustType.BILL_PAY) add(FahipayService.DHIRAAGU_BILL)
|
||||||
|
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.RAASTAS)
|
||||||
|
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.OOREDOO_BILL)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,684 @@
|
|||||||
|
package sh.sar.basedbank.ui.home.transfer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.widget.addTextChangedListener
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaInvalidOtpException
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaRecipientNotFoundException
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||||
|
import sh.sar.basedbank.ui.home.HomeActivity
|
||||||
|
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||||
|
import sh.sar.basedbank.ui.home.TransferFragment
|
||||||
|
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||||
|
import sh.sar.basedbank.util.AccountInputParser
|
||||||
|
import sh.sar.basedbank.util.RecentPick
|
||||||
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the M-Faisa-only parts of the Transfer screen: phone-based recipient lookup,
|
||||||
|
* initiate-with-OTP and confirm-with-OTP for wallet transfers, plus merchant QR pay
|
||||||
|
* (qrCodeId lookup + initiate/confirm purchase). Lives alongside [TransferFragment] which
|
||||||
|
* dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source, or whenever
|
||||||
|
* an M-Faisa merchant QR is loaded.
|
||||||
|
*
|
||||||
|
* Mirrors [BmlTransferHandler]: the fragment keeps the shared confirm dialog, the recipient card
|
||||||
|
* and the form state; the handler keeps everything M-Faisa-specific.
|
||||||
|
*
|
||||||
|
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
|
||||||
|
* [Fragment.viewLifecycleOwner] and Context) — and must be re-created when the view is recreated.
|
||||||
|
*/
|
||||||
|
class MfaisaTransferHandler(
|
||||||
|
private val fragment: TransferFragment,
|
||||||
|
private val binding: FragmentTransferBinding,
|
||||||
|
private val viewModel: HomeViewModel,
|
||||||
|
/** Reads the fragment's currently-selected source account. */
|
||||||
|
private val currentSource: () -> BankAccount?,
|
||||||
|
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
||||||
|
private val selectSource: (BankAccount) -> Unit,
|
||||||
|
/** Hook called when M-Faisa successfully resolves or clears a recipient — fragment uses this to update Send-button state. */
|
||||||
|
private val onRecipientChanged: () -> Unit,
|
||||||
|
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes account balances. */
|
||||||
|
private val onTransferSuccess: (TransferReceiptData, Bitmap?) -> Unit,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||||
|
private val ctx get() = fragment.requireContext()
|
||||||
|
private val host get() = fragment.activity as? HomeActivity
|
||||||
|
|
||||||
|
// Resolved state lives in the draft so it outlives this handler (dropped with the view).
|
||||||
|
private val draft get() = viewModel.transferDraft
|
||||||
|
|
||||||
|
/** Set to the resolved recipient after a successful search; null otherwise. */
|
||||||
|
var recipient: MfaisaTransferClient.Recipient?
|
||||||
|
get() = draft.mfaisaRecipient
|
||||||
|
private set(value) { draft.mfaisaRecipient = value }
|
||||||
|
|
||||||
|
/** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */
|
||||||
|
var qrInfo: MfaisaQrPayClient.QrMerchant?
|
||||||
|
get() = draft.mfaisaQrInfo
|
||||||
|
private set(value) { draft.mfaisaQrInfo = value }
|
||||||
|
|
||||||
|
private var lookupInFlight = false
|
||||||
|
|
||||||
|
/** Held from initiate until the OTP flow ends, so a theme change can't recreate mid-way. */
|
||||||
|
private var transferGuard: HomeActivity.PaymentGuard? = null
|
||||||
|
|
||||||
|
private fun endTransferFlow() {
|
||||||
|
transferGuard?.end()
|
||||||
|
transferGuard = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||||
|
|
||||||
|
/** Whether the recipient lookup has resolved — gates the Send button. */
|
||||||
|
fun isRecipientReady(): Boolean = recipient?.isMvr == true
|
||||||
|
|
||||||
|
/** Triggered when the user taps the search end-icon in `tilTo` (and source bank is MFAISA). */
|
||||||
|
fun searchRecipient(rawInput: String) {
|
||||||
|
if (lookupInFlight) return
|
||||||
|
// Reuse the shared normalizer so "+960", "960", and embedded spaces work the same as
|
||||||
|
// they do for MIB/BML lookup. The result is a bare 7-digit MSISDN when input was a
|
||||||
|
// local phone number, untouched otherwise.
|
||||||
|
val phone = AccountInputParser.normalize(rawInput)
|
||||||
|
if (AccountInputParser.detect(phone) != AccountInputParser.InputType.PHONE) {
|
||||||
|
binding.tilTo.error = "Enter a valid mobile number"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.tilTo.error = null
|
||||||
|
|
||||||
|
val source = mfaisaSource() ?: return
|
||||||
|
val session = app.mfaisaSessionFor(source) ?: run {
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lookupInFlight = true
|
||||||
|
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
MfaisaTransferClient.forContext(ctx).searchRecipient(session, phone)
|
||||||
|
} catch (_: MfaisaSessionExpiredException) {
|
||||||
|
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||||
|
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||||
|
MfaisaTransferClient.forContext(ctx).searchRecipient(fresh, phone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!result.isMvr) {
|
||||||
|
// Server returned the user but only with a PayPal pocket — not supported.
|
||||||
|
binding.tilTo.error = "This number doesn't have an MVR M-Faisa pocket"
|
||||||
|
} else {
|
||||||
|
recipient = result
|
||||||
|
showResolvedRecipient(result)
|
||||||
|
fragment.focusAmount()
|
||||||
|
}
|
||||||
|
} catch (_: MfaisaRecipientNotFoundException) {
|
||||||
|
binding.tilTo.error = "No M-Faisa wallet found for this number"
|
||||||
|
} catch (e: java.io.IOException) {
|
||||||
|
Toast.makeText(ctx, R.string.connectivity_no_internet, Toast.LENGTH_SHORT).show()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
binding.tilTo.error = e.message ?: "Lookup failed"
|
||||||
|
} finally {
|
||||||
|
lookupInFlight = false
|
||||||
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
onRecipientChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Triggered when the user taps the Send button (and source bank is MFAISA). */
|
||||||
|
fun submit() {
|
||||||
|
val source = mfaisaSource() ?: return
|
||||||
|
val r = recipient ?: run {
|
||||||
|
Toast.makeText(ctx, "Search for a recipient first", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val amountStr = binding.etAmount.text?.toString()?.trim().orEmpty()
|
||||||
|
val amount = amountStr.toDoubleOrNull()
|
||||||
|
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
|
||||||
|
binding.tilAmount.error = null
|
||||||
|
val remarks = binding.etRemarks.text?.toString()?.trim().orEmpty()
|
||||||
|
|
||||||
|
val sourcePocketId = source.accountNumber // pocketId IS the accountNumber for M-Faisa accounts
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
endTransferFlow()
|
||||||
|
transferGuard = host?.beginPayment(fragment.viewLifecycleOwner)
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val refId = try {
|
||||||
|
withContext(Dispatchers.IO) { initiateWithRetry(source, sourcePocketId, r, amountStr, remarks) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
endTransferFlow()
|
||||||
|
showError(e)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
// Server has now SMSed an OTP to the user's phone. Prompt them for it.
|
||||||
|
promptForOtp(source, r, amountStr, remarks, refId, errorMsg = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a merchant QR is loaded — counts as a resolved recipient for the Send button. */
|
||||||
|
val hasQrMerchant: Boolean get() = qrInfo != null
|
||||||
|
|
||||||
|
/** Called when the source account changes away from M-Faisa (or the view tears down). */
|
||||||
|
fun clearState() {
|
||||||
|
recipient = null
|
||||||
|
lookupInFlight = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
|
||||||
|
fun clearQrMerchant() {
|
||||||
|
if (qrInfo == null) return
|
||||||
|
qrInfo = null
|
||||||
|
fragment.setAmountLocked(false)
|
||||||
|
binding.tilRemarks.isEnabled = true
|
||||||
|
binding.tilRemarks.alpha = 1f
|
||||||
|
binding.etAmount.setText("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Merchant QR ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves an M-Faisa qrCodeId to a merchant, paints it in the "To" card, auto-selects an
|
||||||
|
* M-Faisa source if none is selected (or the current one is the wrong bank), and pre-fills
|
||||||
|
* the amount if the QR is dynamic.
|
||||||
|
*/
|
||||||
|
fun lookupQrMerchant(qrCodeId: String) {
|
||||||
|
val source = mfaisaSource() ?: run {
|
||||||
|
Toast.makeText(ctx, "No M-Faisa account available", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val session = app.mfaisaSessionFor(source) ?: run {
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually
|
||||||
|
if (currentSource()?.bank != "MFAISA") selectSource(source)
|
||||||
|
|
||||||
|
// The To row stays up with a spinner until there is a merchant to swap in
|
||||||
|
fragment.startLookupLoading()
|
||||||
|
host?.setRefreshing(true)
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val merchant = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
MfaisaQrPayClient().fetchQrDetails(session, qrCodeId)
|
||||||
|
} catch (_: MfaisaSessionExpiredException) {
|
||||||
|
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||||
|
?: return@withContext null
|
||||||
|
try { MfaisaQrPayClient().fetchQrDetails(fresh, qrCodeId) }
|
||||||
|
catch (_: Exception) { null }
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
host?.setRefreshing(false)
|
||||||
|
if (fragment.view == null) return@launch
|
||||||
|
fragment.stopLookupLoading()
|
||||||
|
if (merchant == null) {
|
||||||
|
Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
qrInfo = merchant
|
||||||
|
|
||||||
|
// Static QRs (no preset amount) make sense to keep in Recents — the merchant is
|
||||||
|
// reusable. Dynamic QRs are one-off so we skip them, same rule as BML QR pay.
|
||||||
|
if (merchant.txnAmount.isNullOrBlank()) {
|
||||||
|
RecentsCache.save(ctx, RecentPick(
|
||||||
|
accountNumber = "mfaisaqr:${merchant.qrCodeId}",
|
||||||
|
displayName = merchant.merchantName,
|
||||||
|
subtitle = "M-Faisa merchant · ${merchant.merchantMsisdn}",
|
||||||
|
colorHex = "#ED1C24",
|
||||||
|
imageHash = null,
|
||||||
|
isProfileImage = false,
|
||||||
|
bank = "MFAISA"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
showQrMerchant(merchant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paints a looked-up merchant into the "To" card and locks a dynamic amount. Also how a
|
||||||
|
* recreated view restores it — no network involved.
|
||||||
|
*/
|
||||||
|
fun showQrMerchant(merchant: MfaisaQrPayClient.QrMerchant) {
|
||||||
|
// Clear button is the only way to back out
|
||||||
|
binding.tilTo.visibility = View.GONE
|
||||||
|
binding.btnPickContact.visibility = View.GONE
|
||||||
|
binding.btnScanQr.visibility = View.GONE
|
||||||
|
binding.tvToAccountName.text = merchant.merchantName
|
||||||
|
binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}"
|
||||||
|
binding.tvToAccountDetails.visibility = View.GONE
|
||||||
|
binding.tvToBalance.visibility = View.GONE
|
||||||
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
|
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
|
||||||
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// Pre-fill + lock amount if the QR is dynamic
|
||||||
|
val dynamicAmount = merchant.txnAmount?.toDoubleOrNull()
|
||||||
|
if (dynamicAmount != null && dynamicAmount > 0.0) {
|
||||||
|
binding.etAmount.setText("%.2f".format(dynamicAmount))
|
||||||
|
fragment.setAmountLocked(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
onRecipientChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog —
|
||||||
|
* the /initiateNewBuy + /confirmNewBuy pair does NOT require OTP for wallet QR pay
|
||||||
|
* (2FARequired=NONE), so unlike [submit] there is no code to enter.
|
||||||
|
*/
|
||||||
|
fun submitQrPayment() {
|
||||||
|
val merchant = qrInfo ?: return
|
||||||
|
val src = currentSource() ?: run {
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (src.bank != "MFAISA") {
|
||||||
|
Toast.makeText(ctx, "Switch to an M-Faisa account to pay this QR", Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
|
||||||
|
val amount = amountStr.toDoubleOrNull()
|
||||||
|
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
|
||||||
|
binding.tilAmount.error = null
|
||||||
|
val remarks = binding.etRemarks.text?.toString()?.trim().orEmpty()
|
||||||
|
val amountValue = "%.2f".format(amount)
|
||||||
|
|
||||||
|
val confirmView = fragment.buildTransferConfirmView(
|
||||||
|
amountCurrency = merchant.currencyCode,
|
||||||
|
amountValue = amountValue,
|
||||||
|
fromName = src.accountBriefName,
|
||||||
|
fromNumber = src.accountNumber,
|
||||||
|
fromDetail = "M-Faisa",
|
||||||
|
toName = merchant.merchantName,
|
||||||
|
toNumber = merchant.merchantMsisdn,
|
||||||
|
toDetail = "Ooredoo M-Faisa merchant"
|
||||||
|
)
|
||||||
|
fragment.showConfirmWithBiometric(
|
||||||
|
title = ctx.getString(R.string.transfer),
|
||||||
|
customView = confirmView,
|
||||||
|
biometricSubtitle = "${merchant.currencyCode} $amountValue → ${merchant.merchantName}",
|
||||||
|
onConfirmed = { dialog, frame ->
|
||||||
|
fragment.showProcessingInDialog(dialog, frame)
|
||||||
|
executeQrPayment(src, merchant, amountValue, remarks, dialog)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executeQrPayment(
|
||||||
|
src: BankAccount,
|
||||||
|
merchant: MfaisaQrPayClient.QrMerchant,
|
||||||
|
amountStr: String,
|
||||||
|
remarks: String,
|
||||||
|
dialog: AlertDialog
|
||||||
|
) {
|
||||||
|
val loginId = src.loginTag.removePrefix("mfaisa_")
|
||||||
|
val initialSession = app.mfaisaSessionFor(src) ?: run {
|
||||||
|
dialog.dismiss()
|
||||||
|
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// M-Faisa expects the user's MSISDN with the "960" country prefix (the session stores the
|
||||||
|
// bare 7-digit form). The pocket itself is identified by [BankAccount.accountNumber].
|
||||||
|
val sourceMdn = "960${initialSession.msisdn}"
|
||||||
|
|
||||||
|
binding.btnTransfer.isEnabled = false
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val outcome = withContext(Dispatchers.IO) {
|
||||||
|
val client = MfaisaQrPayClient()
|
||||||
|
try {
|
||||||
|
val refId = try {
|
||||||
|
client.initiatePurchase(initialSession, src.accountNumber, sourceMdn, merchant, amountStr, remarks)
|
||||||
|
} catch (_: MfaisaSessionExpiredException) {
|
||||||
|
val fresh = app.refreshMfaisaSession(loginId)
|
||||||
|
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||||
|
client.initiatePurchase(fresh, src.accountNumber, "960${fresh.msisdn}", merchant, amountStr, remarks)
|
||||||
|
}
|
||||||
|
val confirmSession = app.mfaisaSessionFor(src) ?: initialSession
|
||||||
|
try {
|
||||||
|
client.confirmPurchase(confirmSession, refId)
|
||||||
|
} catch (_: MfaisaSessionExpiredException) {
|
||||||
|
val fresh = app.refreshMfaisaSession(loginId)
|
||||||
|
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||||
|
client.confirmPurchase(fresh, refId)
|
||||||
|
}
|
||||||
|
Result.success(refId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure<String>(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fragment.view == null) return@launch
|
||||||
|
|
||||||
|
outcome.fold(
|
||||||
|
onSuccess = { _ ->
|
||||||
|
val receipt = TransferReceiptData(
|
||||||
|
bank = "MFAISA",
|
||||||
|
amount = amountStr,
|
||||||
|
currency = merchant.currencyCode,
|
||||||
|
fromLabel = src.accountBriefName,
|
||||||
|
fromColorHex = "#ED1C24",
|
||||||
|
toLabel = merchant.merchantName,
|
||||||
|
toAccount = merchant.merchantMsisdn,
|
||||||
|
toBank = "Ooredoo M-Faisa",
|
||||||
|
remarks = remarks,
|
||||||
|
mfaisaTransactionType = "Merchant payment",
|
||||||
|
mfaisaFromName = src.accountBriefName,
|
||||||
|
mfaisaFromMsisdn = src.accountNumber,
|
||||||
|
mfaisaToMsisdn = merchant.merchantMsisdn,
|
||||||
|
mfaisaTimestamp = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
dialog.dismiss()
|
||||||
|
onTransferSuccess(receipt, null)
|
||||||
|
},
|
||||||
|
onFailure = { e ->
|
||||||
|
dialog.dismiss()
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
showError(e, fallback = "Payment failed")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Internal ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The M-Faisa account to act on: the selected source when it is one, otherwise the first
|
||||||
|
* M-Faisa account available. The fallback is what lets a scanned merchant QR auto-switch
|
||||||
|
* the source away from another bank.
|
||||||
|
*/
|
||||||
|
private fun mfaisaSource(): BankAccount? =
|
||||||
|
currentSource()?.takeIf { it.bank == "MFAISA" }
|
||||||
|
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" }
|
||||||
|
|
||||||
|
/** Paints [r] into the "To" card; a recreated view restores it with [saveRecent] off. */
|
||||||
|
fun showResolvedRecipient(r: MfaisaTransferClient.Recipient, saveRecent: Boolean = true) {
|
||||||
|
// Reuse the same recipient card the fragment uses for other banks. The fragment owns the
|
||||||
|
// card view, so we just populate its text fields and toggle visibility.
|
||||||
|
binding.tvToAccountName.text = r.name.ifBlank { r.msisdn }
|
||||||
|
binding.tvToBankBic.text = r.msisdn
|
||||||
|
binding.tvToAccountDetails.text = "Ooredoo M-Faisa · MVR"
|
||||||
|
binding.tvToAccountDetails.visibility = View.VISIBLE
|
||||||
|
binding.tvToBalance.visibility = View.GONE
|
||||||
|
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
|
||||||
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||||
|
|
||||||
|
binding.tilTo.visibility = View.GONE
|
||||||
|
binding.btnPickContact.visibility = View.GONE
|
||||||
|
binding.btnScanQr.visibility = View.GONE
|
||||||
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
if (saveRecent) RecentsCache.save(ctx, RecentPick(
|
||||||
|
accountNumber = r.msisdn,
|
||||||
|
displayName = r.name.ifBlank { r.msisdn },
|
||||||
|
subtitle = "Ooredoo M-Faisa · ${r.msisdn}",
|
||||||
|
colorHex = "#ED1C24",
|
||||||
|
imageHash = null,
|
||||||
|
isProfileImage = false,
|
||||||
|
bank = "MFAISA"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Initiate with one automatic retry if the session has expired. */
|
||||||
|
private fun initiateWithRetry(
|
||||||
|
source: BankAccount,
|
||||||
|
sourcePocketId: String,
|
||||||
|
r: MfaisaTransferClient.Recipient,
|
||||||
|
amountStr: String,
|
||||||
|
remarks: String
|
||||||
|
): String {
|
||||||
|
val session = app.mfaisaSessionFor(source) ?: throw IllegalStateException("No M-Faisa session")
|
||||||
|
return try {
|
||||||
|
MfaisaTransferClient.forContext(ctx).initiateTransfer(session, sourcePocketId, r, amountStr, remarks)
|
||||||
|
} catch (_: MfaisaSessionExpiredException) {
|
||||||
|
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||||
|
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||||
|
MfaisaTransferClient.forContext(ctx).initiateTransfer(fresh, sourcePocketId, r, amountStr, remarks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun confirmWithRetry(source: BankAccount, refId: String, otpCode: String) {
|
||||||
|
val session = app.mfaisaSessionFor(source) ?: throw IllegalStateException("No M-Faisa session")
|
||||||
|
try {
|
||||||
|
MfaisaTransferClient.forContext(ctx).confirmTransfer(session, refId, otpCode)
|
||||||
|
} catch (_: MfaisaSessionExpiredException) {
|
||||||
|
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||||
|
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||||
|
MfaisaTransferClient.forContext(ctx).confirmTransfer(fresh, refId, otpCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the unified "confirm + enter OTP" dialog. Body is the standard transfer-confirm
|
||||||
|
* view (amount + from/to blocks via [TransferFragment.buildTransferConfirmView]) plus an
|
||||||
|
* OTP input. The Confirm button stays disabled until a 6-digit code is entered. Biometric
|
||||||
|
* gating + invalid-OTP re-prompt + session-refresh retry are all preserved.
|
||||||
|
*
|
||||||
|
* The displayed "code sent to" line uses the SOURCE M-Faisa login's MSISDN (where the SMS
|
||||||
|
* was actually sent) — the old standalone OTP dialog mistakenly showed the recipient.
|
||||||
|
*/
|
||||||
|
private fun promptForOtp(
|
||||||
|
source: BankAccount,
|
||||||
|
r: MfaisaTransferClient.Recipient,
|
||||||
|
amountStr: String,
|
||||||
|
remarks: String,
|
||||||
|
refId: String,
|
||||||
|
errorMsg: String?
|
||||||
|
) {
|
||||||
|
val view = fragment.view ?: run { endTransferFlow(); return }
|
||||||
|
val dp = ctx.resources.displayMetrics.density
|
||||||
|
val colorMuted = MaterialColors.getColor(
|
||||||
|
view, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
|
||||||
|
val colorOutline = MaterialColors.getColor(
|
||||||
|
view, com.google.android.material.R.attr.colorOutlineVariant, Color.LTGRAY)
|
||||||
|
|
||||||
|
val amountValue = try { "%.2f".format(amountStr.toDouble()) } catch (_: Exception) { amountStr }
|
||||||
|
val confirmView = fragment.buildTransferConfirmView(
|
||||||
|
amountCurrency = "MVR",
|
||||||
|
amountValue = amountValue,
|
||||||
|
fromName = source.accountBriefName,
|
||||||
|
fromNumber = source.accountNumber,
|
||||||
|
fromDetail = "M-Faisa",
|
||||||
|
toName = r.name.ifBlank { r.msisdn },
|
||||||
|
toNumber = r.msisdn,
|
||||||
|
toDetail = "Ooredoo M-Faisa"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The user's own M-Faisa MSISDN (where the SMS is sent). The session stores the bare
|
||||||
|
// 7 digits; prefix with 960 for display.
|
||||||
|
val userMsisdn = app.mfaisaSessionFor(source)?.msisdn
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { "960$it" }
|
||||||
|
?: "your registered number"
|
||||||
|
|
||||||
|
val otpHeader = TextView(ctx).apply {
|
||||||
|
text = "A 6-digit verification code has been sent to $userMsisdn"
|
||||||
|
textSize = 13f
|
||||||
|
setTextColor(colorMuted)
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
}
|
||||||
|
val otpInput = android.widget.EditText(ctx).apply {
|
||||||
|
hint = "Enter 6-digit code"
|
||||||
|
inputType = android.text.InputType.TYPE_CLASS_NUMBER
|
||||||
|
filters = arrayOf(android.text.InputFilter.LengthFilter(6))
|
||||||
|
textSize = 20f
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
letterSpacing = 0.3f
|
||||||
|
}
|
||||||
|
val errorView = errorMsg?.let {
|
||||||
|
TextView(ctx).apply {
|
||||||
|
text = it
|
||||||
|
textSize = 13f
|
||||||
|
setTextColor(Color.RED)
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val divider = View(ctx).apply {
|
||||||
|
setBackgroundColor(colorOutline)
|
||||||
|
layoutParams = LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).apply {
|
||||||
|
topMargin = (8 * dp).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val otpSection = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
setPadding((20 * dp).toInt(), (12 * dp).toInt(), (20 * dp).toInt(), (4 * dp).toInt())
|
||||||
|
addView(otpHeader, LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT))
|
||||||
|
addView(otpInput, LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
topMargin = (8 * dp).toInt()
|
||||||
|
})
|
||||||
|
if (errorView != null) {
|
||||||
|
addView(errorView, LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||||
|
topMargin = (8 * dp).toInt()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val container = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
addView(confirmView)
|
||||||
|
addView(divider)
|
||||||
|
addView(otpSection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide any previously-open keyboard so the OTP field can claim focus cleanly.
|
||||||
|
val imm = ctx.getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
|
||||||
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
|
|
||||||
|
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(R.string.transfer)
|
||||||
|
.setView(container)
|
||||||
|
.setPositiveButton(R.string.transfer_confirm, null)
|
||||||
|
.setNegativeButton(R.string.cancel) { d, _ ->
|
||||||
|
d.dismiss()
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
endTransferFlow()
|
||||||
|
}
|
||||||
|
.setCancelable(false)
|
||||||
|
.show()
|
||||||
|
|
||||||
|
val confirmBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||||
|
confirmBtn.isEnabled = false
|
||||||
|
otpInput.addTextChangedListener { text ->
|
||||||
|
confirmBtn.isEnabled = (text?.length ?: 0) == 6
|
||||||
|
}
|
||||||
|
|
||||||
|
val prefs = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false)
|
||||||
|
val canAuth = BiometricManager.from(ctx)
|
||||||
|
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
|
||||||
|
|
||||||
|
val runConfirm: () -> Unit = {
|
||||||
|
val otp = otpInput.text?.toString()?.trim().orEmpty()
|
||||||
|
dialog.dismiss()
|
||||||
|
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
||||||
|
|
||||||
|
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) { confirmWithRetry(source, refId, otp) }
|
||||||
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
endTransferFlow()
|
||||||
|
val receipt = TransferReceiptData(
|
||||||
|
bank = "MFAISA",
|
||||||
|
amount = amountValue,
|
||||||
|
currency = "MVR",
|
||||||
|
fromLabel = source.accountBriefName,
|
||||||
|
fromColorHex = "#ED1C24",
|
||||||
|
toLabel = r.name.ifBlank { r.msisdn },
|
||||||
|
toAccount = r.msisdn,
|
||||||
|
toBank = "Ooredoo M-Faisa",
|
||||||
|
remarks = remarks,
|
||||||
|
mfaisaTransactionType = "Transfer to mobile",
|
||||||
|
mfaisaFromName = source.accountBriefName,
|
||||||
|
mfaisaFromMsisdn = source.accountNumber,
|
||||||
|
mfaisaToMsisdn = r.msisdn,
|
||||||
|
mfaisaTimestamp = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
onTransferSuccess(receipt, null)
|
||||||
|
} catch (e: MfaisaInvalidOtpException) {
|
||||||
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
// Server kept the referenceId alive — re-prompt without restarting initiate.
|
||||||
|
promptForOtp(source, r, amountStr, remarks, refId, e.message)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||||
|
binding.btnTransfer.isEnabled = true
|
||||||
|
endTransferFlow()
|
||||||
|
showError(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmBtn.setOnClickListener {
|
||||||
|
val otp = otpInput.text?.toString()?.trim().orEmpty()
|
||||||
|
if (otp.length != 6) { otpInput.error = "Enter 6 digits"; return@setOnClickListener }
|
||||||
|
if (biometricTransferConfirm && canAuth) {
|
||||||
|
val prompt = BiometricPrompt(fragment, ContextCompat.getMainExecutor(ctx),
|
||||||
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||||
|
runConfirm()
|
||||||
|
}
|
||||||
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||||
|
if (errorCode != BiometricPrompt.ERROR_CANCELED &&
|
||||||
|
errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
|
||||||
|
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
|
||||||
|
Toast.makeText(ctx, errString, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
override fun onAuthenticationFailed() { /* keep dialog open */ }
|
||||||
|
})
|
||||||
|
prompt.authenticate(
|
||||||
|
BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle(ctx.getString(R.string.biometric_transfer_title))
|
||||||
|
.setSubtitle("MVR $amountValue → ${r.name.ifBlank { r.msisdn }}")
|
||||||
|
.setNegativeButtonText(ctx.getString(android.R.string.cancel))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
runConfirm()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showError(e: Throwable, fallback: String = "Transfer failed") {
|
||||||
|
val msg = when {
|
||||||
|
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
|
||||||
|
!e.message.isNullOrBlank() -> e.message!!
|
||||||
|
else -> fallback
|
||||||
|
}
|
||||||
|
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package sh.sar.basedbank.ui.home.transfer
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.util.Base64
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||||
|
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
||||||
|
import sh.sar.basedbank.api.mib.MibLookupException
|
||||||
|
import sh.sar.basedbank.api.mib.MibSession
|
||||||
|
import sh.sar.basedbank.api.mib.MibTransferClient
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||||
|
import sh.sar.basedbank.util.AccountInputParser
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.Totp
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the MIB-only parts of the Transfer screen: session resolution, IPS destination lookup,
|
||||||
|
* the transfer itself (profile switch + TOTP + `MibTransferClient`), and the profile/contact
|
||||||
|
* avatar API that every bank's "To" card borrows.
|
||||||
|
*
|
||||||
|
* Unlike [BmlTransferHandler] and [MfaisaTransferHandler] this one holds no view binding and
|
||||||
|
* paints nothing — every method is either a session accessor or a blocking API call meant for
|
||||||
|
* `Dispatchers.IO`. [sh.sar.basedbank.ui.home.TransferFragment] keeps the coroutine plumbing and
|
||||||
|
* the view updates, so this handler can outlive a view recreation.
|
||||||
|
*/
|
||||||
|
class MibTransferHandler(
|
||||||
|
private val fragment: Fragment,
|
||||||
|
/** Reads the fragment's currently-selected source account. */
|
||||||
|
private val currentSource: () -> BankAccount?,
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||||
|
private val ctx get() = fragment.requireContext()
|
||||||
|
|
||||||
|
/** The selected source's MIB session, falling back to any logged-in MIB session. */
|
||||||
|
val session: MibSession?
|
||||||
|
get() = currentSource()?.let { app.mibSessionFor(it) } ?: app.anyMibSession()
|
||||||
|
|
||||||
|
/** Any logged-in MIB session, ignoring which source is selected. */
|
||||||
|
val anySession: MibSession? get() = app.anyMibSession()
|
||||||
|
|
||||||
|
// ─── Destination lookup ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Either a resolved destination or the message to show the user — never both. */
|
||||||
|
data class LookupOutcome(val info: MibIpsAccountInfo?, val error: String?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IPS lookup for a destination account/alias. Blocking — call from IO.
|
||||||
|
* A missing MIB session is reported as "account not found", same as a failed lookup.
|
||||||
|
*/
|
||||||
|
fun lookupDestination(accountNumber: String): LookupOutcome {
|
||||||
|
val sess = session ?: return LookupOutcome(null, notFound())
|
||||||
|
return try {
|
||||||
|
LookupOutcome(MibTransferClient().lookup(sess, accountNumber), null)
|
||||||
|
} catch (e: MibLookupException) {
|
||||||
|
LookupOutcome(null, e.message)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
LookupOutcome(null, notFound())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Currency-only lookup, used to enrich a BML "verify MIB account" result — that endpoint
|
||||||
|
* resolves the name but not the currency. Blocking; returns "" when it can't be determined.
|
||||||
|
*/
|
||||||
|
fun lookupCurrency(accountNumber: String): String {
|
||||||
|
val sess = session ?: return ""
|
||||||
|
return try { MibTransferClient().lookup(sess, accountNumber).currency }
|
||||||
|
catch (_: Exception) { "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notFound() = ctx.getString(R.string.transfer_account_not_found)
|
||||||
|
|
||||||
|
// ─── Avatars ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches and decodes a profile image (P41, [isProfile] true) or a saved contact's image.
|
||||||
|
* Blocking — call from IO. Returns null on any failure, including a missing image.
|
||||||
|
*
|
||||||
|
* The session is passed in rather than read from [session] because callers differ: the "To"
|
||||||
|
* card uses the selected source's session, the account dropdown uses [anySession].
|
||||||
|
*/
|
||||||
|
fun fetchAvatar(session: MibSession, hash: String, isProfile: Boolean): Bitmap? = try {
|
||||||
|
val base64 = if (isProfile) {
|
||||||
|
app.anyMibFlow()?.fetchProfileImage(session, hash)
|
||||||
|
} else {
|
||||||
|
MibContactsClient().fetchProfileImageBase64(session, hash)
|
||||||
|
}
|
||||||
|
if (base64 == null) null else {
|
||||||
|
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||||
|
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
|
||||||
|
// ─── Transfer ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches to the profile that owns [src], then runs the transfer with a freshly generated
|
||||||
|
* TOTP. Blocking — call from IO. Returns `(ok, message, receipt)`; the message is
|
||||||
|
* "CONNECTIVITY" when the network was unreachable.
|
||||||
|
*/
|
||||||
|
fun doTransfer(
|
||||||
|
src: BankAccount,
|
||||||
|
destAccount: String,
|
||||||
|
destName: String,
|
||||||
|
destDisplay: String,
|
||||||
|
amount: String,
|
||||||
|
remarks: String,
|
||||||
|
bankName: String
|
||||||
|
): Triple<Boolean, String, TransferReceiptData?> {
|
||||||
|
val sess = session ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
|
||||||
|
val loginId = src.loginTag.removePrefix("mib_")
|
||||||
|
val otp = CredentialStore(ctx).loadMibCredentials(loginId)?.otpSeed
|
||||||
|
?.let { Totp.generate(it) }
|
||||||
|
?: return Triple(false, "OTP unavailable", null)
|
||||||
|
val currencyCode = if (src.currencyName == "USD") "840" else "462"
|
||||||
|
val currency = if (src.currencyName == "USD") "USD" else "MVR"
|
||||||
|
val isDestMib = AccountInputParser.detect(destAccount) == AccountInputParser.InputType.MIB_ACCOUNT
|
||||||
|
val bankNo = if (isDestMib) 2 else 3
|
||||||
|
val toBank = when {
|
||||||
|
isDestMib -> "MIB"
|
||||||
|
else -> when (bankName.uppercase()) {
|
||||||
|
"MALBMVMV" -> "BML"
|
||||||
|
"MADVMVMV" -> "MIB"
|
||||||
|
else -> bankName.ifBlank { "LOCAL" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val isOwnAccount = isDestMib && app.mibAccounts.any {
|
||||||
|
it.accountNumber == destAccount && it.loginTag == src.loginTag &&
|
||||||
|
(src.profileId.isBlank() || it.profileId == src.profileId)
|
||||||
|
}
|
||||||
|
val transactionType = when {
|
||||||
|
isOwnAccount -> "Own Transfer"
|
||||||
|
isDestMib -> "MIB Transfer"
|
||||||
|
else -> "Quick Transfer"
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
// Switch to the profile that owns the source account
|
||||||
|
if (src.profileId.isNotBlank()) {
|
||||||
|
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||||
|
val profile = profiles.firstOrNull { it.profileId == src.profileId }
|
||||||
|
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
|
||||||
|
}
|
||||||
|
val result = MibTransferClient().transfer(
|
||||||
|
session = sess,
|
||||||
|
fromAccount = src.accountNumber,
|
||||||
|
toAccount = destAccount,
|
||||||
|
amount = amount,
|
||||||
|
currencyCode = currencyCode,
|
||||||
|
benefName = destName.ifBlank { "Recipient" },
|
||||||
|
bankNo = bankNo,
|
||||||
|
purpose = remarks,
|
||||||
|
otp = otp
|
||||||
|
)
|
||||||
|
if (result.success) {
|
||||||
|
val receipt = TransferReceiptData(
|
||||||
|
bank = "MIB",
|
||||||
|
amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0),
|
||||||
|
currency = currency,
|
||||||
|
fromLabel = src.accountBriefName,
|
||||||
|
fromColorHex = "#FE860E",
|
||||||
|
fromProfileImageHash = src.profileImageHash,
|
||||||
|
toLabel = destDisplay.ifBlank { destName },
|
||||||
|
toAccount = destAccount,
|
||||||
|
toBank = toBank,
|
||||||
|
remarks = remarks,
|
||||||
|
mibReferenceNo = result.trxId,
|
||||||
|
mibTransactionDate = result.date,
|
||||||
|
mibFromProfileName = src.profileName,
|
||||||
|
mibTransactionType = transactionType
|
||||||
|
)
|
||||||
|
Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt)
|
||||||
|
} else {
|
||||||
|
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package sh.sar.basedbank.ui.home.transfer
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.text.InputType
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.math.RoundingMode
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A carrier service a phone number can be paid with (reload, Raastas, bill pay), whichever
|
||||||
|
* route pays it — the Fahipay wallet ([FahipayService]) or a verified BML card
|
||||||
|
* ([CardPayoutService]). Each route has its own limits, so each has its own constants.
|
||||||
|
*/
|
||||||
|
interface PayoutService {
|
||||||
|
/** Names it in the "Transfer Type" picker and the recipient card. */
|
||||||
|
val label: String
|
||||||
|
/** Names it in the confirm dialog's "To" block, e.g. "Ooredoo · Raastas". */
|
||||||
|
val destinationLabel: String
|
||||||
|
@get:DrawableRes val iconRes: Int
|
||||||
|
/** Smallest amount the service accepts, in MVR. */
|
||||||
|
val minAmount: Int
|
||||||
|
/** Largest amount the service accepts, in MVR, or null for no limit. */
|
||||||
|
val maxAmount: Int?
|
||||||
|
/** Whether the amount may have a fractional part (up to 2 decimal places). */
|
||||||
|
val decimalsAllowed: Boolean
|
||||||
|
/** GST the carrier charges, in percent, or null for none. [gstAdded] says how. */
|
||||||
|
val gstPercent: Int?
|
||||||
|
/**
|
||||||
|
* False (the usual): GST comes out of the amount, so the number is credited less
|
||||||
|
* ([creditedAfterGst]). True: the number is credited the whole amount and GST is charged on
|
||||||
|
* top of it ([chargedWithGst]).
|
||||||
|
*/
|
||||||
|
val gstAdded: Boolean get() = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the number is credited for a GST-inclusive [amount]: amount / (1 + rate), rounded
|
||||||
|
* down. Null when the service charges no GST, or adds it on top.
|
||||||
|
*/
|
||||||
|
fun creditedAfterGst(amount: BigDecimal): BigDecimal? {
|
||||||
|
val gst = gstPercent ?: return null
|
||||||
|
if (gstAdded) return null
|
||||||
|
return amount.divide(BigDecimal.ONE + BigDecimal(gst).movePointLeft(2), 2, RoundingMode.DOWN)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What is paid for [amount]: amount + round2(amount × rate) when GST is added on top,
|
||||||
|
* otherwise [amount] itself.
|
||||||
|
*/
|
||||||
|
fun chargedWithGst(amount: BigDecimal): BigDecimal {
|
||||||
|
val gst = gstPercent?.takeIf { gstAdded } ?: return amount
|
||||||
|
return amount + (amount * BigDecimal(gst).movePointLeft(2)).setScale(2, RoundingMode.HALF_UP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the Transfer screen's amount and reference fields in step with a picked
|
||||||
|
* [PayoutService]: the amount error, the GST note under the amount, the keypad, and the
|
||||||
|
* reference box disabled (none of the payouts take one). Shared by the Fahipay and card routes.
|
||||||
|
*/
|
||||||
|
class PayoutAmountField(
|
||||||
|
private val binding: FragmentTransferBinding,
|
||||||
|
private val context: () -> Context,
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Why the typed amount can't be sent with [svc], or null when it can (or the field is
|
||||||
|
* empty). Checks the service's minimum, maximum and whether it takes decimals.
|
||||||
|
*/
|
||||||
|
fun problem(svc: PayoutService): String? {
|
||||||
|
val ctx = context()
|
||||||
|
val text = binding.etAmount.text?.toString()?.trim().orEmpty()
|
||||||
|
if (text.isEmpty()) return null
|
||||||
|
val amount = text.toBigDecimalOrNull() ?: return ctx.getString(R.string.transfer_fahipay_amount_invalid)
|
||||||
|
val fraction = amount.stripTrailingZeros().scale()
|
||||||
|
return when {
|
||||||
|
!svc.decimalsAllowed && fraction > 0 -> ctx.getString(R.string.transfer_fahipay_amount_whole)
|
||||||
|
fraction > 2 -> ctx.getString(R.string.transfer_fahipay_amount_decimals)
|
||||||
|
amount < BigDecimal(svc.minAmount) -> ctx.getString(R.string.transfer_fahipay_amount_min, svc.minAmount)
|
||||||
|
svc.maxAmount != null && amount > BigDecimal(svc.maxAmount!!) ->
|
||||||
|
ctx.getString(R.string.transfer_fahipay_amount_max, "%,d".format(svc.maxAmount))
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Idempotent — the fragment calls it on every form change. */
|
||||||
|
fun sync(svc: PayoutService) {
|
||||||
|
// Whole-number services get a keypad without a decimal point. Only set on change:
|
||||||
|
// setting inputType restarts the keyboard, and this runs on every keystroke.
|
||||||
|
val inputType = if (svc.decimalsAllowed) DECIMAL_INPUT else InputType.TYPE_CLASS_NUMBER
|
||||||
|
if (binding.etAmount.inputType != inputType) binding.etAmount.inputType = inputType
|
||||||
|
binding.tilRemarks.isEnabled = false
|
||||||
|
binding.tilRemarks.alpha = 0.4f
|
||||||
|
val problem = problem(svc)
|
||||||
|
binding.tilAmount.error = problem
|
||||||
|
binding.tilAmount.helperText = if (problem == null) gstNote(svc) else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For services that charge GST: what the recipient is credited once it comes out, or what
|
||||||
|
* is paid once it's added on top.
|
||||||
|
*/
|
||||||
|
fun gstNote(svc: PayoutService): String? {
|
||||||
|
val gst = svc.gstPercent ?: return null
|
||||||
|
val ctx = context()
|
||||||
|
val amount = binding.etAmount.text?.toString()?.trim()?.toBigDecimalOrNull()
|
||||||
|
if (svc.gstAdded) {
|
||||||
|
if (amount == null || amount.signum() <= 0) return ctx.getString(R.string.transfer_gst_added_hint, gst)
|
||||||
|
return ctx.getString(R.string.transfer_gst_added_pay, "%,.2f".format(svc.chargedWithGst(amount)), gst)
|
||||||
|
}
|
||||||
|
if (amount == null || amount.signum() <= 0) return ctx.getString(R.string.transfer_fahipay_gst_hint, gst)
|
||||||
|
val credited = svc.creditedAfterGst(amount) ?: return null
|
||||||
|
return ctx.getString(R.string.transfer_fahipay_gst_receive, "%,.2f".format(credited), gst)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gives back the amount and reference fields once no service is picked. */
|
||||||
|
fun reset() {
|
||||||
|
binding.tilAmount.error = null
|
||||||
|
binding.tilAmount.helperText = null
|
||||||
|
binding.etAmount.inputType = DECIMAL_INPUT
|
||||||
|
binding.tilRemarks.isEnabled = true
|
||||||
|
binding.tilRemarks.alpha = 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
/** The amount field's input type from `fragment_transfer.xml` (`numberDecimal`). */
|
||||||
|
const val DECIMAL_INPUT = InputType.TYPE_CLASS_NUMBER or InputType.TYPE_NUMBER_FLAG_DECIMAL
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package sh.sar.basedbank.ui.home.transfer
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the Transfer screen has filled in or resolved so far: source, recipient, form
|
||||||
|
* text and any loaded merchant QR.
|
||||||
|
*
|
||||||
|
* Kept on [sh.sar.basedbank.ui.home.HomeViewModel] rather than on the fragment so it outlives
|
||||||
|
* both the view (switching tabs) and the fragment instance (a theme or language change
|
||||||
|
* recreates the activity) — the screen is repainted from here instead of re-running lookups.
|
||||||
|
* A new Transfer screen opened with its own arguments (a scanned QR, a contact) starts a fresh
|
||||||
|
* draft.
|
||||||
|
*/
|
||||||
|
class TransferDraft {
|
||||||
|
var selectedAccount: BankAccount? = null
|
||||||
|
|
||||||
|
// Resolved recipient — set after a successful lookup or prefill
|
||||||
|
var resolvedAccountNumber = ""
|
||||||
|
var resolvedRecipientName = ""
|
||||||
|
var resolvedBankName = ""
|
||||||
|
/** Last real profile/contact photo loaded into the "To" card (not an initials placeholder). */
|
||||||
|
var loadedToPhoto: Bitmap? = null
|
||||||
|
var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown
|
||||||
|
var resolvedToOwnAccount: BankAccount? = null
|
||||||
|
var toSubtitle = ""
|
||||||
|
var toColorHex = "#607D8B"
|
||||||
|
var toImageHash: String? = null
|
||||||
|
|
||||||
|
// Form text, captured when the view goes away
|
||||||
|
var amount = ""
|
||||||
|
var remarks = ""
|
||||||
|
var toText = ""
|
||||||
|
|
||||||
|
// BML card-only merchant payment (merchant without BML Pay, paid by verified card + 3-D Secure)
|
||||||
|
var bmlCardMerchant: sh.sar.basedbank.api.bml.BmlMerchantTxnClient.PayPage? = null
|
||||||
|
|
||||||
|
// BML merchant QR
|
||||||
|
var bmlQrInfo: BmlQrPayInfo? = null
|
||||||
|
/** True for pay.bml.com.mv and POS QRs, which need an extra pre-initiate step. */
|
||||||
|
var bmlGatewayQr = false
|
||||||
|
/** A BML QR whose lookup has not finished — no session yet, or the view went away mid-way. */
|
||||||
|
var pendingBmlQrTarget: String? = null
|
||||||
|
|
||||||
|
// M-Faisa
|
||||||
|
var mfaisaRecipient: MfaisaTransferClient.Recipient? = null
|
||||||
|
var mfaisaQrInfo: MfaisaQrPayClient.QrMerchant? = null
|
||||||
|
|
||||||
|
// Transfer type — options the "To" lookup offered for [transferTypeNumber], and the pick
|
||||||
|
var transferTypes: List<TransferType> = emptyList()
|
||||||
|
var transferTypeNumber = ""
|
||||||
|
var transferType: TransferType? = null
|
||||||
|
|
||||||
|
// Fahipay
|
||||||
|
var fahipayService: FahipayService? = null
|
||||||
|
|
||||||
|
// Carrier service paid by verified BML card
|
||||||
|
var cardPayoutService: CardPayoutService? = null
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package sh.sar.basedbank.ui.home.transfer
|
||||||
|
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
||||||
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One option in the Transfer screen's "Transfer Type" picker: a way of paying the number in
|
||||||
|
* the "To" field. Picking one also decides the source account — see [worksFrom].
|
||||||
|
*
|
||||||
|
* Add new kinds as subclasses; the exhaustive `when`s over this type point at every site that
|
||||||
|
* needs updating.
|
||||||
|
*/
|
||||||
|
sealed interface TransferType {
|
||||||
|
val label: String
|
||||||
|
/** Second line in the picker: who gets paid, and how. */
|
||||||
|
val subtitle: String
|
||||||
|
@get:DrawableRes val iconRes: Int
|
||||||
|
/** Small logo on the icon's bottom corner — the wallet that pays it — or null for none. */
|
||||||
|
@get:DrawableRes val badgeRes: Int? get() = null
|
||||||
|
|
||||||
|
/** Whether [account] can be the source for this transfer type. */
|
||||||
|
fun worksFrom(account: BankAccount): Boolean
|
||||||
|
|
||||||
|
/** A bank transfer to the account the Favara ID (phone number) resolved to. */
|
||||||
|
data class Favara(val info: MibIpsAccountInfo) : TransferType {
|
||||||
|
override val label get() = "Favara Transfer"
|
||||||
|
override val subtitle get() = info.accountName
|
||||||
|
override val iconRes get() = R.drawable.favara_logo
|
||||||
|
override fun worksFrom(account: BankAccount) = account.bank == "MIB" || account.bank == "BML"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Fahipay payout (reload, Raastas, bill pay) to the phone number. */
|
||||||
|
data class Fahipay(val service: FahipayService, val ownerName: String?) : TransferType {
|
||||||
|
override val label get() = service.label
|
||||||
|
override val subtitle get() = listOfNotNull(ownerName, "via Fahipay").joinToString(" · ")
|
||||||
|
override val iconRes get() = service.iconRes
|
||||||
|
override val badgeRes get() = R.drawable.fahipay_logo
|
||||||
|
override fun worksFrom(account: BankAccount) = account.bank == "FAHIPAY"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A carrier service (reload, …) paid by verified BML card through the carrier's own BML
|
||||||
|
* merchant gateway. [cards] are the account numbers of the cards that can pay it — verified,
|
||||||
|
* with an OTP seed for the 3-D Secure step — worked out when the lookup ran.
|
||||||
|
*/
|
||||||
|
data class Card(val service: CardPayoutService, val ownerName: String?, val cards: Set<String>) : TransferType {
|
||||||
|
override val label get() = service.label
|
||||||
|
override val subtitle get() = listOfNotNull(ownerName, "by BML card").joinToString(" · ")
|
||||||
|
override val iconRes get() = service.iconRes
|
||||||
|
override val badgeRes get() = R.drawable.bml_logo_vector
|
||||||
|
override fun worksFrom(account: BankAccount) = account.bank == "BML" && account.accountNumber in cards
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,10 @@ class BankSelectionFragment : Fragment() {
|
|||||||
val args = android.os.Bundle().apply { putString("bankType", "FAHIPAY") }
|
val args = android.os.Bundle().apply { putString("bankType", "FAHIPAY") }
|
||||||
findNavController().navigate(R.id.action_bankSelection_to_credentials_fahipay, args)
|
findNavController().navigate(R.id.action_bankSelection_to_credentials_fahipay, args)
|
||||||
}
|
}
|
||||||
|
binding.cardOoredoo.setOnClickListener {
|
||||||
|
val args = android.os.Bundle().apply { putString("bankType", "OOREDOO") }
|
||||||
|
findNavController().navigate(R.id.action_bankSelection_to_credentials_ooredoo, args)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package sh.sar.basedbank.ui.login
|
package sh.sar.basedbank.ui.login
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -13,11 +14,13 @@ import android.os.Looper
|
|||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
import android.text.TextWatcher
|
import android.text.TextWatcher
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
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.util.OtpauthParser
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
@@ -27,6 +30,12 @@ import sh.sar.basedbank.api.bml.BmlLoginFlow
|
|||||||
import sh.sar.basedbank.api.fahipay.FahipayAccountClient
|
import sh.sar.basedbank.api.fahipay.FahipayAccountClient
|
||||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaAccountClient
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaInvalidPinException
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaKycRequiredException
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaNotRegisteredException
|
||||||
|
import sh.sar.basedbank.api.mfaisa.MfaisaWalletNotReadyException
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
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.MibProfileClient
|
import sh.sar.basedbank.api.mib.MibProfileClient
|
||||||
@@ -34,6 +43,7 @@ import sh.sar.basedbank.util.AccountCache
|
|||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
|
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
|
||||||
import sh.sar.basedbank.ui.home.HomeActivity
|
import sh.sar.basedbank.ui.home.HomeActivity
|
||||||
|
import sh.sar.basedbank.ui.home.QrScannerActivity
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
|
||||||
class CredentialsFragment : Fragment() {
|
class CredentialsFragment : Fragment() {
|
||||||
@@ -60,6 +70,25 @@ class CredentialsFragment : Fragment() {
|
|||||||
private var bmlLoginId: String = ""
|
private var bmlLoginId: String = ""
|
||||||
private var bmlAccumulatedAccounts = mutableListOf<BankAccount>()
|
private var bmlAccumulatedAccounts = mutableListOf<BankAccount>()
|
||||||
|
|
||||||
|
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
|
val entries = OtpauthParser.parse(raw)
|
||||||
|
when {
|
||||||
|
entries.isEmpty() -> Toast.makeText(requireContext(), "No OTP data found in QR", Toast.LENGTH_SHORT).show()
|
||||||
|
entries.size == 1 -> binding.etOtpSeed.setText(entries[0].secret)
|
||||||
|
else -> {
|
||||||
|
val labels = entries.map { e ->
|
||||||
|
if (e.issuer.isNotBlank()) "${e.issuer} (${e.name})" else e.name.ifBlank { e.secret.take(8) + "…" }
|
||||||
|
}.toTypedArray()
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle("Choose account")
|
||||||
|
.setItems(labels) { _, i -> binding.etOtpSeed.setText(entries[i].secret) }
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
|
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
|
||||||
return binding.root
|
return binding.root
|
||||||
@@ -75,7 +104,20 @@ class CredentialsFragment : Fragment() {
|
|||||||
binding.ivBankLogo.setImageResource(R.drawable.fahipay_logo_long)
|
binding.ivBankLogo.setImageResource(R.drawable.fahipay_logo_long)
|
||||||
binding.tvSignInDesc.setText(R.string.fahipay_sign_in_desc)
|
binding.tvSignInDesc.setText(R.string.fahipay_sign_in_desc)
|
||||||
binding.tilUsername.hint = getString(R.string.fahipay_id_card)
|
binding.tilUsername.hint = getString(R.string.fahipay_id_card)
|
||||||
binding.tilOtpSeed.visibility = android.view.View.GONE
|
binding.rowOtpSeed.visibility = android.view.View.GONE
|
||||||
|
binding.etOtpSeed.isEnabled = false
|
||||||
|
binding.etOtpSeed.isFocusable = false
|
||||||
|
}
|
||||||
|
"OOREDOO" -> {
|
||||||
|
binding.ivBankLogo.setImageResource(R.drawable.ooredoo_logo_long)
|
||||||
|
binding.tvSignInDesc.setText(R.string.ooredoo_sign_in_desc)
|
||||||
|
binding.tilUsername.hint = getString(R.string.ooredoo_phone)
|
||||||
|
binding.etUsername.inputType = android.text.InputType.TYPE_CLASS_PHONE
|
||||||
|
binding.etPassword.inputType =
|
||||||
|
android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD
|
||||||
|
binding.etPassword.filters = arrayOf<android.text.InputFilter>(android.text.InputFilter.LengthFilter(4))
|
||||||
|
binding.tilPassword.hint = getString(R.string.ooredoo_pin)
|
||||||
|
binding.rowOtpSeed.visibility = android.view.View.GONE
|
||||||
binding.etOtpSeed.isEnabled = false
|
binding.etOtpSeed.isEnabled = false
|
||||||
binding.etOtpSeed.isFocusable = false
|
binding.etOtpSeed.isFocusable = false
|
||||||
}
|
}
|
||||||
@@ -83,9 +125,12 @@ class CredentialsFragment : Fragment() {
|
|||||||
|
|
||||||
binding.btnLogin.isEnabled = false
|
binding.btnLogin.isEnabled = false
|
||||||
binding.btnLogin.setOnClickListener { attemptLogin() }
|
binding.btnLogin.setOnClickListener { attemptLogin() }
|
||||||
|
binding.btnScanOtpSeed.setOnClickListener {
|
||||||
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
binding.cardOtp.setOnClickListener {
|
binding.cardOtp.root.setOnClickListener {
|
||||||
val code = binding.tvOtpCode.text.toString().replace(" ", "")
|
val code = binding.cardOtp.tvOtpCode.text.toString().replace(" ", "")
|
||||||
if (code.isNotEmpty()) {
|
if (code.isNotEmpty()) {
|
||||||
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
||||||
@@ -107,7 +152,7 @@ class CredentialsFragment : Fragment() {
|
|||||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (bankType != "FAHIPAY") {
|
if (bankType != "FAHIPAY" && bankType != "OOREDOO") {
|
||||||
binding.etOtpSeed.addTextChangedListener(object : TextWatcher {
|
binding.etOtpSeed.addTextChangedListener(object : TextWatcher {
|
||||||
override fun afterTextChanged(s: Editable?) {
|
override fun afterTextChanged(s: Editable?) {
|
||||||
updateOtpDisplay()
|
updateOtpDisplay()
|
||||||
@@ -121,7 +166,7 @@ class CredentialsFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
if (bankType != "FAHIPAY") otpHandler.post(otpRunnable)
|
if (bankType != "FAHIPAY" && bankType != "OOREDOO") otpHandler.post(otpRunnable)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
@@ -144,6 +189,7 @@ class CredentialsFragment : Fragment() {
|
|||||||
val otpSeed = resolveOtpSeed(otpSeedRaw)
|
val otpSeed = resolveOtpSeed(otpSeedRaw)
|
||||||
binding.btnLogin.isEnabled = when (bankType) {
|
binding.btnLogin.isEnabled = when (bankType) {
|
||||||
"FAHIPAY" -> username.isNotEmpty() && password.isNotEmpty()
|
"FAHIPAY" -> username.isNotEmpty() && password.isNotEmpty()
|
||||||
|
"OOREDOO" -> username.isNotEmpty() && password.length == 4
|
||||||
else -> username.isNotEmpty() && password.isNotEmpty() && otpSeed.isNotEmpty() && password != otpSeedRaw
|
else -> username.isNotEmpty() && password.isNotEmpty() && otpSeed.isNotEmpty() && password != otpSeedRaw
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -152,12 +198,12 @@ class CredentialsFragment : Fragment() {
|
|||||||
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
|
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
|
||||||
val seed = resolveOtpSeed(otpSeedRaw)
|
val seed = resolveOtpSeed(otpSeedRaw)
|
||||||
if (seed.isEmpty()) {
|
if (seed.isEmpty()) {
|
||||||
binding.cardOtp.visibility = View.INVISIBLE
|
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val password = binding.etPassword.text.toString()
|
val password = binding.etPassword.text.toString()
|
||||||
if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) {
|
if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) {
|
||||||
binding.cardOtp.visibility = View.INVISIBLE
|
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -165,12 +211,13 @@ class CredentialsFragment : Fragment() {
|
|||||||
val secondsInPeriod = (System.currentTimeMillis() / 1000L % 30).toInt()
|
val secondsInPeriod = (System.currentTimeMillis() / 1000L % 30).toInt()
|
||||||
val remaining = 30 - secondsInPeriod
|
val remaining = 30 - secondsInPeriod
|
||||||
|
|
||||||
binding.tvOtpCode.text = otp
|
binding.cardOtp.tvOtpCode.text = otp
|
||||||
binding.otpTimer.max = 30
|
binding.cardOtp.tvNextOtpCode.text = Totp.generate(seed, periodOffset = 1)
|
||||||
binding.otpTimer.progress = remaining
|
binding.cardOtp.otpTimer.max = 30
|
||||||
binding.cardOtp.visibility = View.VISIBLE
|
binding.cardOtp.otpTimer.progress = remaining
|
||||||
|
binding.cardOtp.root.visibility = View.VISIBLE
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
binding.cardOtp.visibility = View.INVISIBLE
|
binding.cardOtp.root.visibility = View.INVISIBLE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +225,7 @@ class CredentialsFragment : Fragment() {
|
|||||||
when (bankType) {
|
when (bankType) {
|
||||||
"BML" -> { attemptBmlLogin(); return }
|
"BML" -> { attemptBmlLogin(); return }
|
||||||
"FAHIPAY" -> { attemptFahipayLogin(); return }
|
"FAHIPAY" -> { attemptFahipayLogin(); return }
|
||||||
|
"OOREDOO" -> { attemptMfaisaLogin(); return }
|
||||||
}
|
}
|
||||||
|
|
||||||
val username = binding.etUsername.text.toString().trim()
|
val username = binding.etUsername.text.toString().trim()
|
||||||
@@ -385,6 +433,97 @@ class CredentialsFragment : Fragment() {
|
|||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun attemptMfaisaLogin() {
|
||||||
|
val msisdn = binding.etUsername.text.toString().trim()
|
||||||
|
val pin = binding.etPassword.text.toString()
|
||||||
|
|
||||||
|
if (msisdn.isEmpty() || pin.length != 4) {
|
||||||
|
binding.tvError.text = "Please enter your phone number and 4-digit mPIN"
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.tvError.visibility = View.GONE
|
||||||
|
binding.progressBar.visibility = View.VISIBLE
|
||||||
|
binding.btnLogin.isEnabled = false
|
||||||
|
binding.etUsername.isEnabled = false
|
||||||
|
binding.etPassword.isEnabled = false
|
||||||
|
|
||||||
|
val store = CredentialStore(requireContext())
|
||||||
|
val flow = MfaisaLoginFlow(requireContext())
|
||||||
|
val loginTag = "mfaisa_$msisdn"
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
withContext(Dispatchers.IO) { flow.fetchSubscriber(msisdn) }
|
||||||
|
val result = withContext(Dispatchers.IO) { flow.doMobileLogin(msisdn, pin) }
|
||||||
|
val accounts = MfaisaAccountClient.buildAccounts(result, loginTag)
|
||||||
|
|
||||||
|
store.saveMfaisaCredentials(msisdn, msisdn, pin)
|
||||||
|
store.saveMfaisaUserProfile(
|
||||||
|
msisdn,
|
||||||
|
CredentialStore.MfaisaUserProfile(
|
||||||
|
name = result.profile.name,
|
||||||
|
email = result.profile.email,
|
||||||
|
mdnId = result.profile.mdnId,
|
||||||
|
subscriberId = result.profile.subscriberId,
|
||||||
|
walletId = result.profile.walletId,
|
||||||
|
roleId = result.profile.roleId,
|
||||||
|
offerId = result.profile.offerId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AccountCache.saveMfaisa(requireContext(), msisdn, accounts)
|
||||||
|
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
app.mfaisaSessions[msisdn] = result.session
|
||||||
|
app.mfaisaAccounts = app.mfaisaAccounts.filter { it.loginTag != loginTag } + accounts
|
||||||
|
app.accounts = app.accounts.filter { it.loginTag != loginTag } + accounts
|
||||||
|
app.isUnlocked = true
|
||||||
|
|
||||||
|
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||||
|
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
startActivity(intent)
|
||||||
|
} catch (e: MfaisaNotRegisteredException) {
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle("User not registered")
|
||||||
|
.setMessage("Please use the Ooredoo SuperApp to register your M-Faisa wallet and complete KYC, then come back to Thijooree.")
|
||||||
|
.setPositiveButton("OK", null)
|
||||||
|
.show()
|
||||||
|
binding.tvError.visibility = View.GONE
|
||||||
|
} catch (e: MfaisaKycRequiredException) {
|
||||||
|
MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle("KYC incomplete")
|
||||||
|
.setMessage("Your M-Faisa wallet needs Full KYC. Please complete KYC in the Ooredoo SuperApp, then come back to Thijooree.")
|
||||||
|
.setPositiveButton("OK", null)
|
||||||
|
.show()
|
||||||
|
binding.tvError.visibility = View.GONE
|
||||||
|
} catch (e: MfaisaWalletNotReadyException) {
|
||||||
|
binding.tvError.text = e.message
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
} catch (e: MfaisaInvalidPinException) {
|
||||||
|
val message = if (e.lastAttempt)
|
||||||
|
"${e.message}\n\nOne more wrong mPIN will lock your account."
|
||||||
|
else
|
||||||
|
e.message
|
||||||
|
binding.tvError.text = message ?: "Incorrect mPIN"
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
// Re-enable PIN input only — leave phone number locked
|
||||||
|
binding.etPassword.isEnabled = true
|
||||||
|
binding.etPassword.setText("")
|
||||||
|
binding.etPassword.requestFocus()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
binding.tvError.text = e.message ?: "Login failed"
|
||||||
|
binding.tvError.visibility = View.VISIBLE
|
||||||
|
binding.etUsername.isEnabled = true
|
||||||
|
binding.etPassword.isEnabled = true
|
||||||
|
} finally {
|
||||||
|
binding.progressBar.visibility = View.GONE
|
||||||
|
binding.btnLogin.isEnabled = true
|
||||||
|
// After PIN error, keep phone disabled; for any other resolution above we already restored as needed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun attemptFahipayLogin() {
|
private fun attemptFahipayLogin() {
|
||||||
if (fahipayAwaitingTotp) {
|
if (fahipayAwaitingTotp) {
|
||||||
submitFahipayTotp()
|
submitFahipayTotp()
|
||||||
|
|||||||
@@ -31,9 +31,14 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
|||||||
ThemeHelper.applyAccent(this)
|
ThemeHelper.applyAccent(this)
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
// If security is already configured, onboarding is complete. Redirect to lock screen
|
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
// to prevent overwriting an existing PIN/pattern via direct activity launch.
|
|
||||||
if (CredentialStore(this).loadSecurityHash() != null) {
|
// Only redirect to the lock screen if onboarding is fully complete. Checking the
|
||||||
|
// security hash alone is not sufficient — the hash is written during the PIN/pattern
|
||||||
|
// setup step (page 1) which happens *before* the user clicks "Get Started", so a
|
||||||
|
// theme change or process restart mid-onboarding would otherwise trigger this guard
|
||||||
|
// and strand the user in the lock flow without finishing onboarding.
|
||||||
|
if (prefs.getBoolean("onboarding_done", false) && CredentialStore(this).loadSecurityHash() != null) {
|
||||||
startActivity(Intent(this, LockActivity::class.java))
|
startActivity(Intent(this, LockActivity::class.java))
|
||||||
finish()
|
finish()
|
||||||
return
|
return
|
||||||
@@ -50,7 +55,6 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
|||||||
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
|
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
|
||||||
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
|
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
|
||||||
ta.recycle()
|
ta.recycle()
|
||||||
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
|
||||||
val originalBottomPadding = binding.bottomBar.paddingBottom
|
val originalBottomPadding = binding.bottomBar.paddingBottom
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets ->
|
ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets ->
|
||||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.biometric.BiometricManager
|
|||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.databinding.FragmentOnboardingConfigureBinding
|
import sh.sar.basedbank.databinding.FragmentOnboardingConfigureBinding
|
||||||
|
import sh.sar.basedbank.ui.home.NavCustomization
|
||||||
|
|
||||||
class OnboardingConfigureFragment : Fragment() {
|
class OnboardingConfigureFragment : Fragment() {
|
||||||
|
|
||||||
@@ -24,12 +25,20 @@ class OnboardingConfigureFragment : Fragment() {
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
// Navigation — default Drawer
|
// Navigation
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
binding.navModeToggle.check(when (NavCustomization.getNavMode(prefs)) {
|
||||||
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer)
|
NavCustomization.NAV_MODE_BOTTOM -> R.id.btnNavBottom
|
||||||
|
NavCustomization.NAV_MODE_CIRCULAR -> R.id.btnNavCircular
|
||||||
|
else -> R.id.btnNavDrawer
|
||||||
|
})
|
||||||
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
if (!isChecked) return@addOnButtonCheckedListener
|
if (!isChecked) return@addOnButtonCheckedListener
|
||||||
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
|
val mode = when (checkedId) {
|
||||||
|
R.id.btnNavBottom -> NavCustomization.NAV_MODE_BOTTOM
|
||||||
|
R.id.btnNavCircular -> NavCustomization.NAV_MODE_CIRCULAR
|
||||||
|
else -> NavCustomization.NAV_MODE_DRAWER
|
||||||
|
}
|
||||||
|
NavCustomization.saveNavMode(prefs, mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Theme — default System
|
// Theme — default System
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class OnboardingFragment : Fragment() {
|
|||||||
|
|
||||||
private fun notifyScrolledToBottom() {
|
private fun notifyScrolledToBottom() {
|
||||||
if (scrolledToBottom) return
|
if (scrolledToBottom) return
|
||||||
|
if (!isAdded) return
|
||||||
scrolledToBottom = true
|
scrolledToBottom = true
|
||||||
parentFragmentManager.setFragmentResult(RESULT_SCROLLED_TO_BOTTOM, Bundle.EMPTY)
|
parentFragmentManager.setFragmentResult(RESULT_SCROLLED_TO_BOTTOM, Bundle.EMPTY)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding
|
import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding
|
||||||
@@ -102,8 +103,17 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
else
|
else
|
||||||
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
||||||
val btn = MaterialButton(requireContext(), null, style).apply {
|
val btn = MaterialButton(requireContext(), null, style).apply {
|
||||||
|
if (key == "⌫" || key == "✓") {
|
||||||
|
text = ""
|
||||||
|
icon = ContextCompat.getDrawable(requireContext(),
|
||||||
|
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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ object AccountCache {
|
|||||||
private const val KEY_MIB = "mib_accounts"
|
private const val KEY_MIB = "mib_accounts"
|
||||||
private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
|
private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
|
||||||
private fun fahipayKey(loginId: String) = "fahipay_accounts_$loginId"
|
private fun fahipayKey(loginId: String) = "fahipay_accounts_$loginId"
|
||||||
|
private fun mfaisaKey(loginId: String) = "mfaisa_accounts_$loginId"
|
||||||
|
|
||||||
fun save(context: Context, accounts: List<BankAccount>) {
|
fun save(context: Context, accounts: List<BankAccount>) {
|
||||||
val arr = JSONArray()
|
val arr = JSONArray()
|
||||||
@@ -150,6 +151,62 @@ object AccountCache {
|
|||||||
fun loadFahipay(context: Context, loginIds: List<String>): List<BankAccount> =
|
fun loadFahipay(context: Context, loginIds: List<String>): List<BankAccount> =
|
||||||
loginIds.flatMap { loadFahipay(context, it) }
|
loginIds.flatMap { loadFahipay(context, it) }
|
||||||
|
|
||||||
|
fun saveMfaisa(context: Context, loginId: String, accounts: List<BankAccount>) {
|
||||||
|
val arr = JSONArray()
|
||||||
|
for (acc in accounts) {
|
||||||
|
arr.put(JSONObject().apply {
|
||||||
|
put("profileName", acc.profileName)
|
||||||
|
put("profileType", acc.profileType)
|
||||||
|
put("accountNumber", acc.accountNumber)
|
||||||
|
put("accountBriefName", acc.accountBriefName)
|
||||||
|
put("currencyName", acc.currencyName)
|
||||||
|
put("accountTypeName", acc.accountTypeName)
|
||||||
|
put("availableBalance", acc.availableBalance)
|
||||||
|
put("currentBalance", acc.currentBalance)
|
||||||
|
put("blockedAmount", acc.blockedAmount)
|
||||||
|
put("mvrBalance", acc.mvrBalance)
|
||||||
|
put("statusDesc", acc.statusDesc)
|
||||||
|
put("loginTag", acc.loginTag)
|
||||||
|
put("profileId", acc.profileId)
|
||||||
|
put("internalId", acc.internalId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.edit().putString(mfaisaKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMfaisa(context: Context, loginId: String): List<BankAccount> {
|
||||||
|
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||||
|
.getString(mfaisaKey(loginId), null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||||
|
(0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
BankAccount(
|
||||||
|
bank = "MFAISA",
|
||||||
|
profileName = o.optString("profileName"),
|
||||||
|
profileType = o.optString("profileType"),
|
||||||
|
accountNumber = o.optString("accountNumber"),
|
||||||
|
accountBriefName = o.optString("accountBriefName"),
|
||||||
|
currencyName = o.optString("currencyName"),
|
||||||
|
accountTypeName = o.optString("accountTypeName"),
|
||||||
|
availableBalance = o.optString("availableBalance"),
|
||||||
|
currentBalance = o.optString("currentBalance"),
|
||||||
|
blockedAmount = o.optString("blockedAmount"),
|
||||||
|
mvrBalance = o.optString("mvrBalance"),
|
||||||
|
statusDesc = o.optString("statusDesc"),
|
||||||
|
profileImageHash = null,
|
||||||
|
loginTag = o.optString("loginTag"),
|
||||||
|
profileId = o.optString("profileId", ""),
|
||||||
|
internalId = o.optString("internalId", "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMfaisa(context: Context, loginIds: List<String>): List<BankAccount> =
|
||||||
|
loginIds.flatMap { loadMfaisa(context, it) }
|
||||||
|
|
||||||
fun clear(context: Context) {
|
fun clear(context: Context) {
|
||||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ package sh.sar.basedbank.util
|
|||||||
data class AccountHistoryDisplay(
|
data class AccountHistoryDisplay(
|
||||||
val name: String,
|
val name: String,
|
||||||
val number: String,
|
val number: String,
|
||||||
val bankPill: String?, // "BML", "FP", null for MIB (no pill)
|
val bankPill: String?, // "BML", "FP", "MIB" — bank label shown on the pill
|
||||||
val typeLabel: String, // e.g. "Savings", "Current", "Visa Platinum"
|
val typeLabel: String, // e.g. "Savings", "Current", "Visa Platinum"
|
||||||
val availableBalance: String, // formatted "CCY amount"
|
val availableBalance: String, // formatted "CCY amount"
|
||||||
val workingBalance: String, // ledger/working balance — formatted "CCY amount"
|
val workingBalance: String, // ledger/working balance — formatted "CCY amount"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package sh.sar.basedbank.util
|
|||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlHistoryParser
|
import sh.sar.basedbank.util.bmlapi.BmlHistoryParser
|
||||||
import sh.sar.basedbank.util.fahipayapi.FahipayHistoryParser
|
import sh.sar.basedbank.util.fahipayapi.FahipayHistoryParser
|
||||||
|
import sh.sar.basedbank.util.mfaisaapi.MfaisaHistoryParser
|
||||||
import sh.sar.basedbank.util.mibapi.MibHistoryParser
|
import sh.sar.basedbank.util.mibapi.MibHistoryParser
|
||||||
|
|
||||||
object AccountHistoryParser {
|
object AccountHistoryParser {
|
||||||
@@ -11,6 +12,7 @@ object AccountHistoryParser {
|
|||||||
"BML" -> BmlHistoryParser.displayData(account)
|
"BML" -> BmlHistoryParser.displayData(account)
|
||||||
"FAHIPAY" -> FahipayHistoryParser.displayData(account)
|
"FAHIPAY" -> FahipayHistoryParser.displayData(account)
|
||||||
"MIB" -> MibHistoryParser.displayData(account)
|
"MIB" -> MibHistoryParser.displayData(account)
|
||||||
|
"MFAISA" -> MfaisaHistoryParser.displayData(account)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package sh.sar.basedbank.util
|
|||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||||
import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser
|
import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser
|
||||||
|
import sh.sar.basedbank.util.mfaisaapi.MfaisaAccountParser
|
||||||
import sh.sar.basedbank.util.mibapi.MibAccountParser
|
import sh.sar.basedbank.util.mibapi.MibAccountParser
|
||||||
|
|
||||||
object AccountListParser {
|
object AccountListParser {
|
||||||
@@ -11,6 +12,7 @@ object AccountListParser {
|
|||||||
"BML" -> BmlDashboardParser.displayData(account)
|
"BML" -> BmlDashboardParser.displayData(account)
|
||||||
"FAHIPAY" -> FahipayAccountParser.displayData(account)
|
"FAHIPAY" -> FahipayAccountParser.displayData(account)
|
||||||
"MIB" -> MibAccountParser.displayData(account)
|
"MIB" -> MibAccountParser.displayData(account)
|
||||||
|
"MFAISA" -> MfaisaAccountParser.displayData(account)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user