Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8e7cf2a24
|
||
|
|
97fe63025d
|
||
|
|
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
|
||
|
|
83ca37eade
|
||
|
|
c7d3efa64e
|
||
|
|
2688118f52
|
||
|
|
e717360d00
|
||
|
|
2f26599931
|
||
|
|
f8f9d82440
|
||
|
|
da5cca79c6
|
||
|
|
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
|
||
|
|
c0b58061c2
|
||
|
|
978da26ff1
|
||
|
|
7fe2ba5788
|
||
|
|
26a0c7b81d
|
||
|
|
83fc340e2b
|
||
|
|
bfbb649b33
|
||
|
|
b780091bb8
|
||
|
|
e4468c4a8f
|
||
|
|
b4e1f57347
|
||
|
|
907757c893
|
||
|
|
1ea0355ce6
|
||
|
|
c9b8973b65
|
||
|
|
7a0e32f4d6
|
||
|
|
d68b8aaf0a
|
||
|
|
396f778ad4
|
||
|
|
dc0f1b96c1
|
||
|
|
640dd5de22
|
||
|
|
f0a0e7857c
|
||
|
|
836f4c493a
|
||
|
|
6325f4fd7a
|
||
|
|
69aa172eff
|
||
|
|
ed2054fb81
|
||
|
|
e9583f0580
|
||
|
|
a32841a319
|
||
|
|
7a66dd836c
|
||
|
|
68dd49b90c
|
||
|
|
76090525e1
|
||
|
|
f7fd06cdf3
|
||
|
|
8d09e760a8
|
||
|
|
62ccae602d
|
||
|
|
9011ef2f5a
|
||
|
|
dd620763ec
|
||
|
|
86063d600f
|
@@ -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: |
|
||||||
echo "No release notes" > release_notes.md
|
VERSION_CODE=$(grep 'versionCode = ' app/build.gradle.kts | sed 's/.*versionCode = \([0-9]*\).*/\1/')
|
||||||
# VERSION="${{ gitea.ref_name }}"
|
CHANGELOG="fastlane/metadata/android/en-US/changelogs/${VERSION_CODE}.txt"
|
||||||
# VERSION="${VERSION#v}"
|
|
||||||
#
|
if [ -f "$CHANGELOG" ]; then
|
||||||
# awk -v ver="$VERSION" '
|
cp "$CHANGELOG" release_notes.md
|
||||||
# BEGIN { found=0 }
|
else
|
||||||
# /^## \[/ {
|
echo "No changelog found at $CHANGELOG"
|
||||||
# if (found) exit
|
echo "No release notes" > release_notes.md
|
||||||
# if ($0 ~ "\\[" ver "\\]") { found=1; next }
|
fi
|
||||||
# }
|
|
||||||
# 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"
|
||||||
@@ -87,3 +86,24 @@ jobs:
|
|||||||
--data-binary "@${ASSET_PATH}"
|
--data-binary "@${ASSET_PATH}"
|
||||||
|
|
||||||
echo "Uploaded asset: $ASSET_NAME"
|
echo "Uploaded asset: $ASSET_NAME"
|
||||||
|
|
||||||
|
- name: Send APK to Telegram
|
||||||
|
env:
|
||||||
|
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
||||||
|
TG_CHAT_ID: ${{ vars.TG_CHAT_ID }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$TG_BOT_TOKEN" ] || [ -z "$TG_CHAT_ID" ]; then
|
||||||
|
echo "TG_BOT_TOKEN or TG_CHAT_ID not set, skipping Telegram upload."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_NAME="${{ gitea.repository }}"
|
||||||
|
APP_NAME="${APP_NAME//\//-}"
|
||||||
|
TAG="${{ gitea.ref_name }}"
|
||||||
|
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
|
||||||
|
CAPTION="$(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" \
|
||||||
|
-F "chat_id=${TG_CHAT_ID}" \
|
||||||
|
-F "document=@${ASSET_PATH}" \
|
||||||
|
--form-string "caption=${CAPTION}"
|
||||||
|
|||||||
@@ -16,5 +16,7 @@ local.properties
|
|||||||
docs/mibapi/tmp
|
docs/mibapi/tmp
|
||||||
docs/bmlapi/tmp
|
docs/bmlapi/tmp
|
||||||
docs/fahipayapi/tmp
|
docs/fahipayapi/tmp
|
||||||
|
docs/mfaisaapi/tmp
|
||||||
tmp
|
tmp
|
||||||
app/key.jks
|
app/key.jks
|
||||||
|
.kotlin/*
|
||||||
|
|||||||
@@ -4,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
|
||||||
|
|
||||||
|
|||||||
@@ -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 = 8
|
versionCode = 32
|
||||||
versionName = "1.0.9"
|
versionName = "1.0.31"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
buildConfigField("String", "ACCOUNT_MVR", "\"${localOrEnv("account.mvr", "ACCOUNT_MVR")}\"")
|
||||||
|
buildConfigField("String", "ACCOUNT_USD", "\"${localOrEnv("account.usd", "ACCOUNT_USD")}\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -27,6 +40,10 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
versionNameSuffix = "-debug"
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
@@ -45,6 +62,7 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding = true
|
viewBinding = true
|
||||||
|
buildConfig = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +105,9 @@ dependencies {
|
|||||||
// Biometric authentication
|
// Biometric authentication
|
||||||
implementation("androidx.biometric:biometric:1.1.0")
|
implementation("androidx.biometric:biometric:1.1.0")
|
||||||
|
|
||||||
|
// Encrypted SharedPreferences (HCE token store)
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#CC0000"
|
||||||
|
android:pathData="M0,0h108v108h-108z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_logo_background">#CC0000</color>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Thijooree Debug</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<shortcut
|
||||||
|
android:shortcutId="transfer"
|
||||||
|
android:enabled="true"
|
||||||
|
android:icon="@drawable/ic_shortcut_transfer"
|
||||||
|
android:shortcutShortLabel="@string/transfer"
|
||||||
|
android:shortcutLongLabel="@string/transfer">
|
||||||
|
<intent
|
||||||
|
android:action="sh.sar.basedbank.OPEN_TRANSFER"
|
||||||
|
android:targetPackage="sh.sar.basedbank.debug"
|
||||||
|
android:targetClass="sh.sar.basedbank.MainActivity" />
|
||||||
|
<categories android:name="android.shortcut.conversation" />
|
||||||
|
</shortcut>
|
||||||
|
|
||||||
|
<shortcut
|
||||||
|
android:shortcutId="scan_qr"
|
||||||
|
android:enabled="true"
|
||||||
|
android:icon="@drawable/ic_shortcut_scan_qr"
|
||||||
|
android:shortcutShortLabel="@string/transfer_scan_qr"
|
||||||
|
android:shortcutLongLabel="@string/transfer_scan_qr">
|
||||||
|
<intent
|
||||||
|
android:action="sh.sar.basedbank.OPEN_SCAN_QR"
|
||||||
|
android:targetPackage="sh.sar.basedbank.debug"
|
||||||
|
android:targetClass="sh.sar.basedbank.MainActivity" />
|
||||||
|
<categories android:name="android.shortcut.conversation" />
|
||||||
|
</shortcut>
|
||||||
|
|
||||||
|
<shortcut
|
||||||
|
android:shortcutId="tap_to_pay"
|
||||||
|
android:enabled="true"
|
||||||
|
android:icon="@drawable/ic_shortcut_pay_card"
|
||||||
|
android:shortcutShortLabel="@string/card_pay_nfc"
|
||||||
|
android:shortcutLongLabel="@string/card_pay_nfc">
|
||||||
|
<intent
|
||||||
|
android:action="sh.sar.basedbank.TAP_TO_PAY"
|
||||||
|
android:targetPackage="sh.sar.basedbank.debug"
|
||||||
|
android:targetClass="sh.sar.basedbank.MainActivity" />
|
||||||
|
<categories android:name="android.shortcut.conversation" />
|
||||||
|
</shortcut>
|
||||||
|
|
||||||
|
</shortcuts>
|
||||||
@@ -7,10 +7,18 @@
|
|||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.NFC" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
|
|
||||||
|
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".BasedBankApp"
|
android:name=".BasedBankApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
@@ -30,6 +38,9 @@
|
|||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.app.shortcuts"
|
||||||
|
android:resource="@xml/shortcuts" />
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
@@ -56,6 +67,44 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:screenOrientation="portrait" />
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".nfc.BmlTapToPayActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:theme="@style/Theme.BasedBank" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".service.NotificationPollingService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".nfc.BmlHostCardEmulatorService"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.BIND_NFC_SERVICE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.nfc.cardemulation.host_apdu_service"
|
||||||
|
android:resource="@xml/bml_aid_list" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- Share-sheet alias: "Scan to Pay" receives shared images and decodes their QR code -->
|
||||||
|
<activity-alias
|
||||||
|
android:name=".ScanToPayActivity"
|
||||||
|
android:targetActivity=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/transfer_scan_qr"
|
||||||
|
android:icon="@drawable/ic_qr_scan">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<data android:mimeType="image/*" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity-alias>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 191 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 390 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 295 KiB After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 196 KiB |
@@ -0,0 +1 @@
|
|||||||
|
visa_bingaa.png
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
visa_bingaa.png
|
||||||
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 208 KiB After Width: | Height: | Size: 192 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 36 KiB |
@@ -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
|
||||||
@@ -16,6 +17,13 @@ import sh.sar.basedbank.util.CredentialStore
|
|||||||
|
|
||||||
class BasedBankApp : Application() {
|
class BasedBankApp : Application() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set to true only after the user passes LockActivity or completes fresh login.
|
||||||
|
* Resets to false on every process restart so direct ADB/root activity launches
|
||||||
|
* cannot reach HomeActivity without re-authenticating.
|
||||||
|
*/
|
||||||
|
var isUnlocked = false
|
||||||
|
|
||||||
// Held in memory after successful login; cleared on logout
|
// Held in memory after successful login; cleared on logout
|
||||||
var accounts: List<BankAccount> = emptyList()
|
var accounts: List<BankAccount> = emptyList()
|
||||||
var fullName: String = ""
|
var fullName: String = ""
|
||||||
@@ -41,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). */
|
||||||
@@ -103,12 +115,36 @@ 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()
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
// Only apply wallpaper-based dynamic colors in system theme mode.
|
||||||
|
// Light/dark modes use content-based accent colors applied per-activity via ThemeHelper.
|
||||||
|
DynamicColors.applyToActivitiesIfAvailable(this) { _, _ ->
|
||||||
|
getSharedPreferences("prefs", MODE_PRIVATE).getString("theme", "system") == "system"
|
||||||
|
}
|
||||||
|
|
||||||
val theme = getSharedPreferences("prefs", MODE_PRIVATE).getString("theme", "system")
|
val theme = getSharedPreferences("prefs", MODE_PRIVATE).getString("theme", "system")
|
||||||
AppCompatDelegate.setDefaultNightMode(when (theme) {
|
AppCompatDelegate.setDefaultNightMode(when (theme) {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import kotlinx.coroutines.withContext
|
|||||||
import sh.sar.basedbank.databinding.ActivityLockBinding
|
import sh.sar.basedbank.databinding.ActivityLockBinding
|
||||||
import sh.sar.basedbank.ui.home.HomeActivity
|
import sh.sar.basedbank.ui.home.HomeActivity
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.ThemeHelper
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import javax.crypto.SecretKeyFactory
|
import javax.crypto.SecretKeyFactory
|
||||||
import javax.crypto.spec.PBEKeySpec
|
import javax.crypto.spec.PBEKeySpec
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@ class LockActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
ThemeHelper.applyAccent(this)
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
binding = ActivityLockBinding.inflate(layoutInflater)
|
binding = ActivityLockBinding.inflate(layoutInflater)
|
||||||
@@ -54,6 +57,9 @@ class LockActivity : AppCompatActivity() {
|
|||||||
isAppearanceLightStatusBars = isLight
|
isAppearanceLightStatusBars = isLight
|
||||||
isAppearanceLightNavigationBars = isLight
|
isAppearanceLightNavigationBars = isLight
|
||||||
}
|
}
|
||||||
|
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
|
||||||
|
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
|
||||||
|
ta.recycle()
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets ->
|
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets ->
|
||||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
|
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
|
||||||
@@ -118,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 {
|
||||||
text = key
|
if (key == "⌫" || key == "✓") {
|
||||||
textSize = 24f
|
text = ""
|
||||||
|
icon = ContextCompat.getDrawable(this@LockActivity,
|
||||||
|
if (key == "⌫") R.drawable.ic_backspace else R.drawable.ic_check)
|
||||||
|
iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START
|
||||||
|
iconPadding = 0
|
||||||
|
iconSize = (28 * dp).toInt()
|
||||||
|
} else {
|
||||||
|
text = key
|
||||||
|
textSize = 24f
|
||||||
|
}
|
||||||
insetTop = 0; insetBottom = 0
|
insetTop = 0; insetBottom = 0
|
||||||
minimumWidth = 0; minimumHeight = 0
|
minimumWidth = 0; minimumHeight = 0
|
||||||
cornerRadius = btnSize / 2
|
cornerRadius = btnSize / 2
|
||||||
@@ -259,10 +274,28 @@ class LockActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun proceed() {
|
private fun proceed() {
|
||||||
|
(application as BasedBankApp).isUnlocked = true
|
||||||
if (intent.getBooleanExtra(EXTRA_RESUME, false)) {
|
if (intent.getBooleanExtra(EXTRA_RESUME, false)) {
|
||||||
finish()
|
finish()
|
||||||
} else {
|
} else {
|
||||||
startActivity(Intent(this, HomeActivity::class.java))
|
val store = CredentialStore(this)
|
||||||
|
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() ||
|
||||||
|
store.hasFahipayCredentials() || store.hasMfaisaCredentials()
|
||||||
|
if (!hasCredentials) {
|
||||||
|
startActivity(Intent(this, sh.sar.basedbank.ui.login.LoginActivity::class.java))
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val navDest = intent.getIntExtra("nav_destination", -1)
|
||||||
|
val autoScan = intent.getBooleanExtra("auto_scan", false)
|
||||||
|
val autoTapMode = intent.getBooleanExtra("auto_tap_mode", false)
|
||||||
|
val shareQrText = intent.getStringExtra("share_qr_text")
|
||||||
|
startActivity(Intent(this, HomeActivity::class.java).apply {
|
||||||
|
if (navDest != -1) putExtra("nav_destination", navDest)
|
||||||
|
if (autoScan) putExtra("auto_scan", true)
|
||||||
|
if (autoTapMode) putExtra("auto_tap_mode", true)
|
||||||
|
if (shareQrText != null) putExtra("share_qr_text", shareQrText)
|
||||||
|
})
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,16 +7,63 @@ import sh.sar.basedbank.ui.home.HomeActivity
|
|||||||
import sh.sar.basedbank.ui.login.LoginActivity
|
import sh.sar.basedbank.ui.login.LoginActivity
|
||||||
import sh.sar.basedbank.ui.onboarding.OnboardingActivity
|
import sh.sar.basedbank.ui.onboarding.OnboardingActivity
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
|
||||||
class MainActivity : AppCompatActivity() {
|
class MainActivity : AppCompatActivity() {
|
||||||
|
|
||||||
|
private fun decodeQrFromSharedImage(uri: android.net.Uri): String? {
|
||||||
|
return try {
|
||||||
|
val bitmap = contentResolver.openInputStream(uri)?.use {
|
||||||
|
android.graphics.BitmapFactory.decodeStream(it)
|
||||||
|
} ?: return null
|
||||||
|
val opts = de.markusfisch.android.zxingcpp.ZxingCpp.ReaderOptions(
|
||||||
|
tryHarder = true, tryRotate = true, tryInvert = true,
|
||||||
|
tryDownscale = true, maxNumberOfSymbols = 1,
|
||||||
|
textMode = de.markusfisch.android.zxingcpp.ZxingCpp.TextMode.PLAIN
|
||||||
|
)
|
||||||
|
val result = (de.markusfisch.android.zxingcpp.ZxingCpp.readBitmap(
|
||||||
|
bitmap, 0, 0, bitmap.width, bitmap.height, 0,
|
||||||
|
opts.apply { binarizer = de.markusfisch.android.zxingcpp.ZxingCpp.Binarizer.LOCAL_AVERAGE }
|
||||||
|
) ?: de.markusfisch.android.zxingcpp.ZxingCpp.readBitmap(
|
||||||
|
bitmap, 0, 0, bitmap.width, bitmap.height, 0,
|
||||||
|
opts.apply { binarizer = de.markusfisch.android.zxingcpp.ZxingCpp.Binarizer.GLOBAL_HISTOGRAM }
|
||||||
|
))?.firstOrNull()?.text
|
||||||
|
bitmap.recycle()
|
||||||
|
result
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
val onboardingDone = prefs.getBoolean("onboarding_done", false)
|
val onboardingDone = prefs.getBoolean("onboarding_done", false)
|
||||||
val securitySet = prefs.getString("security_method", null) != null
|
val securitySet = prefs.getString("security_method", null) != null
|
||||||
val store = CredentialStore(this)
|
val store = CredentialStore(this)
|
||||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() ||
|
||||||
|
store.hasFahipayCredentials() || store.hasMfaisaCredentials()
|
||||||
|
|
||||||
|
// Image shared via "Scan to Pay" — decode QR here while we still hold the URI permission
|
||||||
|
val shareQrText: String? = if (intent?.action == Intent.ACTION_SEND &&
|
||||||
|
intent.type?.startsWith("image/") == true) {
|
||||||
|
val uri: android.net.Uri? =
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU)
|
||||||
|
intent.getParcelableExtra(Intent.EXTRA_STREAM, android.net.Uri::class.java)
|
||||||
|
else
|
||||||
|
@Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_STREAM)
|
||||||
|
if (uri != null) decodeQrFromSharedImage(uri) else null
|
||||||
|
} else null
|
||||||
|
|
||||||
|
val navDestination = when (intent?.action) {
|
||||||
|
"sh.sar.basedbank.OPEN_TRANSFER" -> R.id.nav_transfer
|
||||||
|
"sh.sar.basedbank.OPEN_SCAN_QR" -> R.id.nav_transfer
|
||||||
|
"sh.sar.basedbank.OPEN_PAY_WITH_CARD" -> R.id.nav_pay_with_card
|
||||||
|
"sh.sar.basedbank.TAP_TO_PAY" -> R.id.nav_pay_with_card
|
||||||
|
else -> -1
|
||||||
|
}
|
||||||
|
val autoScan = intent?.action == "sh.sar.basedbank.OPEN_SCAN_QR"
|
||||||
|
val autoTapMode = intent?.action == "sh.sar.basedbank.TAP_TO_PAY"
|
||||||
|
|
||||||
val target = when {
|
val target = when {
|
||||||
!onboardingDone -> OnboardingActivity::class.java
|
!onboardingDone -> OnboardingActivity::class.java
|
||||||
@@ -24,7 +71,17 @@ class MainActivity : AppCompatActivity() {
|
|||||||
securitySet -> LockActivity::class.java // proceed() → HomeActivity
|
securitySet -> LockActivity::class.java // proceed() → HomeActivity
|
||||||
else -> HomeActivity::class.java
|
else -> HomeActivity::class.java
|
||||||
}
|
}
|
||||||
startActivity(Intent(this, target))
|
// No lock screen configured — mark as unlocked so HomeActivity's guard passes
|
||||||
|
if (target == HomeActivity::class.java) {
|
||||||
|
(application as BasedBankApp).isUnlocked = true
|
||||||
|
}
|
||||||
|
|
||||||
|
startActivity(Intent(this, target).apply {
|
||||||
|
if (navDestination != -1) putExtra("nav_destination", navDestination)
|
||||||
|
if (autoScan) putExtra("auto_scan", true)
|
||||||
|
if (autoTapMode) putExtra("auto_tap_mode", true)
|
||||||
|
if (shareQrText != null) putExtra("share_qr_text", shareQrText)
|
||||||
|
})
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,9 +14,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(),
|
accountNumber = accountNumber,
|
||||||
amount = item.optDouble("BillingAmount", 0.0),
|
accountDisplayName = accountDisplayName,
|
||||||
currency = item.optString("BillingCcy", "MVR"),
|
source = "BML"
|
||||||
counterpartyName = null,
|
)
|
||||||
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
|
|
||||||
accountNumber = accountNumber,
|
|
||||||
accountDisplayName = accountDisplayName,
|
|
||||||
source = "BML_CARD"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val unbilled = payload.optJSONObject("unbilled")
|
|
||||||
?.optJSONArray("CardUnbillTxnDetails")
|
|
||||||
if (unbilled != null) {
|
|
||||||
for (i in 0 until unbilled.length()) {
|
|
||||||
val item = unbilled.getJSONObject(i)
|
|
||||||
result.add(BankTransaction(
|
|
||||||
id = "unbilled_${item.optString("TranApprCode")}_$i",
|
|
||||||
date = item.optString("DateTime"),
|
|
||||||
description = item.optString("TranDesc").trim(),
|
|
||||||
amount = item.optDouble("BillingAmount", 0.0),
|
|
||||||
currency = item.optString("BillingCcy", "MVR"),
|
|
||||||
counterpartyName = null,
|
|
||||||
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
|
|
||||||
accountNumber = accountNumber,
|
|
||||||
accountDisplayName = accountDisplayName,
|
|
||||||
source = "BML_CARD"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val statement = payload.optJSONArray("cardstatement")
|
|
||||||
if (statement != null) {
|
|
||||||
for (i in 0 until statement.length()) {
|
|
||||||
val item = statement.getJSONObject(i)
|
|
||||||
result.add(BankTransaction(
|
|
||||||
id = "stmt_${item.optString("TranRef", i.toString())}",
|
|
||||||
date = item.optString("TransDate", item.optString("TranDate", "")),
|
|
||||||
description = item.optString("TranDesc", item.optString("Description", "")).trim(),
|
|
||||||
amount = -item.optDouble("TranAmount", 0.0),
|
|
||||||
currency = item.optString("TranCcy", "MVR"),
|
|
||||||
counterpartyName = null,
|
|
||||||
reference = item.optString("TranRef").takeIf { it.isNotBlank() },
|
|
||||||
accountNumber = accountNumber,
|
|
||||||
accountDisplayName = accountDisplayName,
|
|
||||||
source = "BML_CARD"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result
|
|
||||||
} catch (_: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BML Merchant Services payment links (`https://transaction.merchants.bankofmaldives.com.mv/<id>`),
|
||||||
|
* e.g. the bill links Fenaka sends. The web page only shows a QR; this fetches the QR's text so it
|
||||||
|
* can go through the regular BML QR payment flow.
|
||||||
|
*/
|
||||||
|
class BmlMerchantTxnClient {
|
||||||
|
|
||||||
|
private val client = newBmlApiClient()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
private const val API_BASE = "https://api.merchants.bankofmaldives.com.mv"
|
||||||
|
private const val PAGE_ORIGIN = "https://transaction.merchants.bankofmaldives.com.mv"
|
||||||
|
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,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"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import org.json.JSONObject
|
|||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import java.util.concurrent.atomic.AtomicBoolean
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import kotlin.math.abs
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
class SessionExpiredException : Exception("MIB session expired")
|
class SessionExpiredException : Exception("MIB session expired")
|
||||||
@@ -49,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 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -168,7 +174,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
accountTypeName = a.optString("accountTypeName"),
|
accountTypeName = a.optString("accountTypeName"),
|
||||||
availableBalance = a.optString("availableBalance"),
|
availableBalance = a.optString("availableBalance"),
|
||||||
currentBalance = a.optString("currentBalance"),
|
currentBalance = a.optString("currentBalance"),
|
||||||
blockedAmount = a.optString("blockedAmount"),
|
blockedAmount = absBlockedAmount(a.optString("blockedAmount")),
|
||||||
mvrBalance = a.optString("mvrBalance"),
|
mvrBalance = a.optString("mvrBalance"),
|
||||||
statusDesc = a.optString("statusDesc"),
|
statusDesc = a.optString("statusDesc"),
|
||||||
profileImageHash = profile.customerImage,
|
profileImageHash = profile.customerImage,
|
||||||
@@ -188,6 +194,13 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** MIB returns blockedAmount as a signed decimal where negative = funds held.
|
||||||
|
* Normalize to a positive magnitude so downstream code can treat it uniformly. */
|
||||||
|
private fun absBlockedAmount(raw: String): String {
|
||||||
|
val v = raw.toDoubleOrNull() ?: return raw
|
||||||
|
return "%.2f".format(abs(v))
|
||||||
|
}
|
||||||
|
|
||||||
private fun initialKeyExchange(
|
private fun initialKeyExchange(
|
||||||
appId: String, encKey: String, sfunc: String, key2: String? = null
|
appId: String, encKey: String, sfunc: String, key2: String? = null
|
||||||
): Pair<MibSession, String> {
|
): Pair<MibSession, String> {
|
||||||
@@ -325,7 +338,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
|||||||
accountTypeName = a.optString("accountTypeName"),
|
accountTypeName = a.optString("accountTypeName"),
|
||||||
availableBalance = a.optString("availableBalance"),
|
availableBalance = a.optString("availableBalance"),
|
||||||
currentBalance = a.optString("currentBalance"),
|
currentBalance = a.optString("currentBalance"),
|
||||||
blockedAmount = a.optString("blockedAmount"),
|
blockedAmount = absBlockedAmount(a.optString("blockedAmount")),
|
||||||
mvrBalance = a.optString("mvrBalance"),
|
mvrBalance = a.optString("mvrBalance"),
|
||||||
statusDesc = a.optString("statusDesc"),
|
statusDesc = a.optString("statusDesc"),
|
||||||
profileImageHash = profile.customerImage,
|
profileImageHash = profile.customerImage,
|
||||||
|
|||||||
@@ -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,185 @@
|
|||||||
|
package sh.sar.basedbank.nfc
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.nfc.cardemulation.HostApduService
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import sh.sar.basedbank.api.bml.BmlWalletToken
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HCE service that emulates a BML contactless payment card.
|
||||||
|
*
|
||||||
|
* Implements the minimal EMV mag-stripe contactless flow:
|
||||||
|
* SELECT PPSE → SELECT AID → GET PROCESSING OPTIONS → READ RECORD
|
||||||
|
*
|
||||||
|
* Each BmlWalletToken is single-use and is set via [setToken] before tapping.
|
||||||
|
* After READ RECORD is sent the [onTransactionComplete] callback fires.
|
||||||
|
*/
|
||||||
|
class BmlHostCardEmulatorService : HostApduService() {
|
||||||
|
|
||||||
|
private var gpoSent = false
|
||||||
|
|
||||||
|
override fun processCommandApdu(commandApdu: ByteArray?, extras: Bundle?): ByteArray {
|
||||||
|
if (commandApdu == null) return SW_UNKNOWN_ERROR
|
||||||
|
val apdu = Apdu(commandApdu)
|
||||||
|
if (apdu.isError) return apdu.errorResponse()
|
||||||
|
|
||||||
|
return when (apdu.ins) {
|
||||||
|
INS_SELECT -> handleSelect(apdu)
|
||||||
|
INS_GPO -> handleGpo()
|
||||||
|
INS_READ -> handleReadRecord()
|
||||||
|
else -> SW_INS_NOT_SUPPORTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDeactivated(reason: Int) {
|
||||||
|
if (!gpoSent) onTransactionComplete?.invoke(false)
|
||||||
|
gpoSent = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── APDU handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun handleSelect(apdu: Apdu): ByteArray {
|
||||||
|
val data = apdu.data ?: return SW_UNKNOWN_ERROR
|
||||||
|
|
||||||
|
if (data.contentEquals(PPSE_BYTES)) {
|
||||||
|
val token = activeToken ?: run { launchPromptActivity(); return SW_UNKNOWN_ERROR }
|
||||||
|
return hexToBytes(buildSelectPpseResponse(token.appCode, applicationLabel(token.appCode), "01"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val token = activeToken ?: return SW_UNKNOWN_ERROR
|
||||||
|
return if (data.contentEquals(hexToBytes(token.appCode))) {
|
||||||
|
hexToBytes(buildSelectAidResponse(token.appCode, applicationLabel(token.appCode)))
|
||||||
|
} else {
|
||||||
|
SW_UNKNOWN_ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchPromptActivity() {
|
||||||
|
val intent = Intent(applicationContext, BmlTapToPayActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
}
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleGpo(): ByteArray {
|
||||||
|
gpoSent = true
|
||||||
|
// AIP=0080 (mag-stripe mode), AFL=08010100 (SFI=1, record 1-1, offline 0)
|
||||||
|
val miscData = "008008010100"
|
||||||
|
val body = tlv("80", miscData)
|
||||||
|
return hexToBytes(body + SW_OK_HEX)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleReadRecord(): ByteArray {
|
||||||
|
val token = activeToken ?: return SW_UNKNOWN_ERROR
|
||||||
|
val track2 = buildTrack2(token)
|
||||||
|
val body = tlv("70", tlv("57", track2))
|
||||||
|
val response = hexToBytes(body + SW_OK_HEX)
|
||||||
|
onTransactionComplete?.invoke(true)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TLV / APDU response builders ───────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun buildSelectPpseResponse(aid: String, label: String, priority: String): String {
|
||||||
|
val priorityTlv = tlv("87", priority) // tag 87
|
||||||
|
val aidTlv = tlv("4F", aid) // tag 4F (ADF Name)
|
||||||
|
val appEntry = tlv("61", aidTlv + priorityTlv) // tag 61
|
||||||
|
val ppseTlv = tlv("84", PPSE_HEX) // tag 84 (DF Name)
|
||||||
|
val inner = tlv("BF0C", appEntry) // tag BF0C
|
||||||
|
val propTemplate = tlv("A5", inner) // tag A5
|
||||||
|
val fci = tlv("6F", ppseTlv + propTemplate) // tag 6F
|
||||||
|
return fci + SW_OK_HEX
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSelectAidResponse(aid: String, label: String): String {
|
||||||
|
val aidTlv = tlv("84", aid) // tag 84
|
||||||
|
val labelTlv = tlv("50", asciiToHex(label)) // tag 50
|
||||||
|
val pdolTlv = tlv("9F38", "9F6602") // PDOL: TTQ 2 bytes
|
||||||
|
val propTemplate = tlv("A5", labelTlv + pdolTlv) // tag A5
|
||||||
|
val fci = tlv("6F", aidTlv + propTemplate) // tag 6F
|
||||||
|
return fci + SW_OK_HEX
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildTrack2(token: BmlWalletToken): String {
|
||||||
|
var t2 = "${token.token}D${token.expiry}${token.serviceCode}${token.data}"
|
||||||
|
if (t2.length % 2 != 0) t2 += "F"
|
||||||
|
return t2
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Build BER-TLV: tag (hex string, 1 or 2 bytes) + DER length + data (hex string). */
|
||||||
|
private fun tlv(tagHex: String, dataHex: String): String {
|
||||||
|
val lenBytes = dataHex.length / 2
|
||||||
|
val lenHex = when {
|
||||||
|
lenBytes <= 0x7F -> lenBytes.toHexByte()
|
||||||
|
lenBytes <= 0xFF -> "81" + lenBytes.toHexByte()
|
||||||
|
else -> "82" + (lenBytes shr 8).toHexByte() + (lenBytes and 0xFF).toHexByte()
|
||||||
|
}
|
||||||
|
return tagHex + lenHex + dataHex
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Int.toHexByte(): String = toString(16).padStart(2, '0').uppercase()
|
||||||
|
|
||||||
|
private fun asciiToHex(s: String): String =
|
||||||
|
s.toByteArray(Charsets.US_ASCII).joinToString("") { "%02X".format(it) }
|
||||||
|
|
||||||
|
private fun hexToBytes(hex: String): ByteArray {
|
||||||
|
val s = hex.uppercase()
|
||||||
|
return ByteArray(s.length / 2) { i ->
|
||||||
|
s.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── APDU parser ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private inner class Apdu(raw: ByteArray) {
|
||||||
|
val isError: Boolean
|
||||||
|
val ins: Int
|
||||||
|
val data: ByteArray?
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (raw.size < 5) {
|
||||||
|
isError = true; ins = -1; data = null
|
||||||
|
} else {
|
||||||
|
isError = false
|
||||||
|
ins = raw[1].toInt() and 0xFF
|
||||||
|
val lc = if (raw.size > 4) raw[4].toInt() and 0xFF else 0
|
||||||
|
data = if (lc > 0 && raw.size >= 5 + lc) raw.copyOfRange(5, 5 + lc) else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun errorResponse() = SW_UNKNOWN_ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BmlHCE"
|
||||||
|
|
||||||
|
private const val INS_SELECT = 0xA4
|
||||||
|
private const val INS_GPO = 0xA8
|
||||||
|
private const val INS_READ = 0xB2
|
||||||
|
|
||||||
|
private val PPSE_HEX = "325041592E5359532E4444463031" // "2PAY.SYS.DDF01"
|
||||||
|
private val PPSE_BYTES = byteArrayOf(
|
||||||
|
0x32,0x50,0x41,0x59,0x2E,0x53,0x59,0x53,0x2E,0x44,0x44,0x46,0x30,0x31
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val SW_OK_HEX = "9000"
|
||||||
|
private val SW_UNKNOWN_ERROR = byteArrayOf(0x6F.toByte(), 0x00.toByte())
|
||||||
|
private val SW_INS_NOT_SUPPORTED = byteArrayOf(0x6D.toByte(), 0x00.toByte())
|
||||||
|
|
||||||
|
@Volatile var activeToken: BmlWalletToken? = null
|
||||||
|
@Volatile var onTransactionComplete: ((success: Boolean) -> Unit)? = null
|
||||||
|
|
||||||
|
fun setToken(token: BmlWalletToken) { activeToken = token }
|
||||||
|
fun clearToken() { activeToken = null }
|
||||||
|
|
||||||
|
fun applicationLabel(aidHex: String): String = when {
|
||||||
|
aidHex.startsWith("A0000000031010", ignoreCase = true) -> "VISA"
|
||||||
|
aidHex.startsWith("A0000000041010", ignoreCase = true) -> "MASTERCARD"
|
||||||
|
aidHex.startsWith("A000000025", ignoreCase = true) -> "AMEX"
|
||||||
|
else -> "BML"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package sh.sar.basedbank.nfc
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import sh.sar.basedbank.MainActivity
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback entry point — redirects to MainActivity which routes to the in-app tap-to-pay screen.
|
||||||
|
*/
|
||||||
|
class BmlTapToPayActivity : AppCompatActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
startActivity(Intent(this, MainActivity::class.java).apply {
|
||||||
|
action = "sh.sar.basedbank.TAP_TO_PAY"
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
|
})
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package sh.sar.basedbank.service
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.IBinder
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.api.bml.BmlNotificationsClient
|
||||||
|
import sh.sar.basedbank.api.mib.MibActivityHistoryClient
|
||||||
|
import sh.sar.basedbank.ui.home.AppNotification
|
||||||
|
import sh.sar.basedbank.util.NotificationsCache
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
class NotificationPollingService : Service() {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
private val app get() = application as BasedBankApp
|
||||||
|
private val notifIdCounter = AtomicInteger(2000)
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
createChannels()
|
||||||
|
startForeground(SERVICE_NOTIF_ID, buildServiceNotification())
|
||||||
|
startPolling()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
scope.cancel()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startPolling() {
|
||||||
|
scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
runCatching { poll() }
|
||||||
|
delay(POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun poll() {
|
||||||
|
pollBml()
|
||||||
|
pollMib()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun pollBml() {
|
||||||
|
val sessions = app.bmlSessions.toMap()
|
||||||
|
if (sessions.isEmpty()) return
|
||||||
|
val client = BmlNotificationsClient()
|
||||||
|
sessions.forEach { (loginId, session) ->
|
||||||
|
val result = try { client.fetchNotifications(session, loginId, page = 1) }
|
||||||
|
catch (_: Exception) { return@forEach }
|
||||||
|
if (result.items.isEmpty()) return@forEach
|
||||||
|
|
||||||
|
val cached = NotificationsCache.loadBml(this@NotificationPollingService, loginId)
|
||||||
|
if (cached.isEmpty()) {
|
||||||
|
NotificationsCache.saveBml(this@NotificationPollingService, loginId, result.items)
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
val cachedIds = cached.map { it.id }.toSet()
|
||||||
|
val newItems = result.items.filter { it.id !in cachedIds }
|
||||||
|
if (newItems.isNotEmpty()) {
|
||||||
|
NotificationsCache.saveBml(this@NotificationPollingService, loginId, result.items)
|
||||||
|
val channelId = ensureLoginChannel("BML", loginId)
|
||||||
|
newItems.forEach { postBankNotification(it, channelId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun pollMib() {
|
||||||
|
val sessions = app.mibSessions.toMap()
|
||||||
|
if (sessions.isEmpty()) return
|
||||||
|
val client = MibActivityHistoryClient()
|
||||||
|
sessions.forEach { (loginId, session) ->
|
||||||
|
val result = try { client.fetchActivity(session, loginId, 1, 100) }
|
||||||
|
catch (_: Exception) { return@forEach }
|
||||||
|
|
||||||
|
val readIds = NotificationsCache.getMibReadIds(this@NotificationPollingService)
|
||||||
|
val cached = NotificationsCache.loadMib(this@NotificationPollingService, loginId, readIds)
|
||||||
|
if (cached.isEmpty()) {
|
||||||
|
NotificationsCache.saveMib(this@NotificationPollingService, loginId, result.items)
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
val cachedIds = cached.map { it.id }.toSet()
|
||||||
|
val newItems = result.items.filter { it.id !in cachedIds }
|
||||||
|
if (newItems.isNotEmpty()) {
|
||||||
|
val all = (cached + newItems).sortedByDescending { it.timestampMs }
|
||||||
|
NotificationsCache.saveMib(this@NotificationPollingService, loginId, all)
|
||||||
|
val channelId = ensureLoginChannel("MIB", loginId)
|
||||||
|
newItems.forEach { postBankNotification(it, channelId) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureLoginChannel(bank: String, loginId: String): String {
|
||||||
|
val channelId = "bank_${bank.lowercase()}_$loginId"
|
||||||
|
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
if (nm.getNotificationChannel(channelId) == null) {
|
||||||
|
val profileName = when (bank) {
|
||||||
|
"BML" -> app.bmlProfilesMap[loginId]?.firstOrNull()?.name
|
||||||
|
"MIB" -> app.mibProfilesMap[loginId]?.firstOrNull()?.name
|
||||||
|
else -> null
|
||||||
|
} ?: loginId
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(channelId, "$bank · $profileName", NotificationManager.IMPORTANCE_DEFAULT)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return channelId
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun postBankNotification(notif: AppNotification, channelId: String) {
|
||||||
|
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val pi = PendingIntent.getActivity(
|
||||||
|
this, 0,
|
||||||
|
packageManager.getLaunchIntentForPackage(packageName),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val n = Notification.Builder(this, channelId)
|
||||||
|
.setSmallIcon(R.drawable.ic_bell)
|
||||||
|
.setContentTitle(notif.title)
|
||||||
|
.setContentText(notif.message)
|
||||||
|
.setContentIntent(pi)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
nm.notify(notifIdCounter.getAndIncrement(), n)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildServiceNotification(): Notification {
|
||||||
|
val pi = PendingIntent.getActivity(
|
||||||
|
this, 0,
|
||||||
|
packageManager.getLaunchIntentForPackage(packageName),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
return Notification.Builder(this, CHANNEL_SERVICE)
|
||||||
|
.setSmallIcon(R.drawable.ic_bell)
|
||||||
|
.setContentTitle(getString(R.string.notif_service_title))
|
||||||
|
.setContentText(getString(R.string.notif_service_desc))
|
||||||
|
.setContentIntent(pi)
|
||||||
|
.setOngoing(true)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createChannels() {
|
||||||
|
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
nm.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_SERVICE,
|
||||||
|
getString(R.string.notif_channel_service),
|
||||||
|
NotificationManager.IMPORTANCE_MIN
|
||||||
|
).apply { setShowBadge(false) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val POLL_INTERVAL_MS = 30_000L
|
||||||
|
private const val SERVICE_NOTIF_ID = 1001
|
||||||
|
const val CHANNEL_SERVICE = "notif_polling_service"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -125,6 +127,14 @@ class AccountsAdapter(
|
|||||||
binding.tvAccountNumber.text = display.number
|
binding.tvAccountNumber.text = display.number
|
||||||
binding.tvAccountType.text = display.typeLabel
|
binding.tvAccountType.text = display.typeLabel
|
||||||
binding.tvBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
|
binding.tvBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
|
||||||
|
val blocked = display.blockedBalance
|
||||||
|
if (blocked != null) {
|
||||||
|
val shown = if (hideAmounts) maskAmount(blocked) else blocked
|
||||||
|
binding.tvBlocked.text = binding.root.context.getString(R.string.account_blocked_label, shown)
|
||||||
|
binding.tvBlocked.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
binding.tvBlocked.visibility = View.GONE
|
||||||
|
}
|
||||||
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||||
binding.root.setOnClickListener { onAccountClick(account) }
|
binding.root.setOnClickListener { onAccountClick(account) }
|
||||||
binding.root.setOnLongClickListener {
|
binding.root.setOnLongClickListener {
|
||||||
@@ -136,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)
|
||||||
|
|||||||
@@ -1,139 +1,3 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
import android.content.Context
|
// Merged into CardsFragment
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import android.widget.ImageView
|
|
||||||
import android.widget.TextView
|
|
||||||
import android.widget.Toast
|
|
||||||
import androidx.core.view.ViewCompat
|
|
||||||
import androidx.core.view.WindowInsetsCompat
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.fragment.app.activityViewModels
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import sh.sar.basedbank.R
|
|
||||||
import sh.sar.basedbank.databinding.FragmentCardSettingsBinding
|
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
|
||||||
|
|
||||||
class CardSettingsFragment : Fragment() {
|
|
||||||
|
|
||||||
private var _binding: FragmentCardSettingsBinding? = null
|
|
||||||
private val binding get() = _binding!!
|
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
|
||||||
_binding = FragmentCardSettingsBinding.inflate(inflater, container, false)
|
|
||||||
return binding.root
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
|
||||||
val adapter = CardSettingsAdapter(emptyList(), requireContext())
|
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
|
||||||
binding.recyclerView.adapter = adapter
|
|
||||||
|
|
||||||
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
|
||||||
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
|
||||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
|
||||||
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
|
||||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
|
||||||
insets
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.swipeRefresh.setOnRefreshListener {
|
|
||||||
(activity as? HomeActivity)?.triggerRefresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
val updateCardList = {
|
|
||||||
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
|
|
||||||
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
|
||||||
.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
|
|
||||||
.map { CardItem.Bml(it) }
|
|
||||||
val all = mibItems + bmlItems
|
|
||||||
adapter.update(all)
|
|
||||||
binding.loadingView.visibility = View.GONE
|
|
||||||
binding.swipeRefresh.isRefreshing = false
|
|
||||||
binding.emptyView.visibility = if (all.isEmpty()) View.VISIBLE else View.GONE
|
|
||||||
binding.recyclerView.visibility = if (all.isEmpty()) View.GONE else View.VISIBLE
|
|
||||||
}
|
|
||||||
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
|
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
|
|
||||||
|
|
||||||
if (viewModel.mibCards.value == null) {
|
|
||||||
binding.loadingView.visibility = View.VISIBLE
|
|
||||||
(activity as? HomeActivity)?.triggerRefreshCards()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResume() {
|
|
||||||
super.onResume()
|
|
||||||
requireActivity().title = getString(R.string.nav_card_settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
|
||||||
super.onDestroyView()
|
|
||||||
_binding = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class CardSettingsAdapter(
|
|
||||||
private var cards: List<CardItem>,
|
|
||||||
private val context: Context
|
|
||||||
) : RecyclerView.Adapter<CardSettingsAdapter.VH>() {
|
|
||||||
|
|
||||||
fun update(newCards: List<CardItem>) {
|
|
||||||
cards = newCards
|
|
||||||
notifyDataSetChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
|
|
||||||
VH(LayoutInflater.from(context).inflate(R.layout.item_card_settings_entry, parent, false))
|
|
||||||
|
|
||||||
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position])
|
|
||||||
override fun getItemCount() = cards.size
|
|
||||||
|
|
||||||
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
|
||||||
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
|
|
||||||
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
|
|
||||||
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
|
|
||||||
private val tvCardType: TextView = view.findViewById(R.id.tvCardType)
|
|
||||||
private val tvCardStatus: TextView = view.findViewById(R.id.tvCardStatus)
|
|
||||||
private val btnChangePin: View = view.findViewById(R.id.btnChangePin)
|
|
||||||
private val btnFreeze: View = view.findViewById(R.id.btnFreeze)
|
|
||||||
private val btnBlock: View = view.findViewById(R.id.btnBlock)
|
|
||||||
|
|
||||||
fun bind(item: CardItem) {
|
|
||||||
when (item) {
|
|
||||||
is CardItem.Mib -> {
|
|
||||||
tvCardOwner.text = item.card.cardHolderName
|
|
||||||
tvCardNumber.text = PayWithCardFragment.formatMasked(item.card.maskedCardNumber)
|
|
||||||
tvCardType.text = item.card.cardTypeDesc
|
|
||||||
val assetPath = PayWithCardFragment.cardImageAsset(item.card)
|
|
||||||
if (assetPath != null) PayWithCardFragment.loadCardImage(ivCardImage, assetPath)
|
|
||||||
else ivCardImage.setImageDrawable(null)
|
|
||||||
PayWithCardFragment.bindCardStatus(tvCardStatus, PayWithCardFragment.mibCardStatusLabel(item.card.cardStatus))
|
|
||||||
itemView.alpha = 1f
|
|
||||||
}
|
|
||||||
is CardItem.Bml -> {
|
|
||||||
tvCardOwner.text = item.account.accountBriefName
|
|
||||||
tvCardNumber.text = PayWithCardFragment.formatMasked(item.account.accountNumber)
|
|
||||||
tvCardType.text = item.account.accountTypeName
|
|
||||||
PayWithCardFragment.loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
|
|
||||||
val isActive = item.account.statusDesc.equals("Active", ignoreCase = true)
|
|
||||||
val bmlStatus = item.account.statusDesc.takeUnless { isActive }
|
|
||||||
PayWithCardFragment.bindCardStatus(tvCardStatus, bmlStatus)
|
|
||||||
itemView.alpha = if (isActive) 1f else 0.45f
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val wip = View.OnClickListener {
|
|
||||||
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
|
||||||
}
|
|
||||||
btnChangePin.setOnClickListener(wip)
|
|
||||||
btnFreeze.setOnClickListener(wip)
|
|
||||||
btnBlock.setOnClickListener(wip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,686 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.inputmethod.EditorInfo
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import android.widget.TextView
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
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.models.BankAccount
|
||||||
|
import sh.sar.basedbank.databinding.FragmentChatBinding
|
||||||
|
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||||
|
import sh.sar.basedbank.databinding.ViewChatHeaderBinding
|
||||||
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
|
import sh.sar.basedbank.util.ContactListParser
|
||||||
|
import sh.sar.basedbank.util.ReceiptStore
|
||||||
|
import sh.sar.basedbank.util.AccountListParser
|
||||||
|
import sh.sar.basedbank.util.ChatAccount
|
||||||
|
import sh.sar.basedbank.util.ChatStore
|
||||||
|
import sh.sar.basedbank.util.ChatThread
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One person's transfers as a chat, or one business's card / Scan to Pay payments.
|
||||||
|
*
|
||||||
|
* A person can have several accounts (e.g. MVR and USD); the "To" bar picks which one to send to.
|
||||||
|
* Sending opens [TransferFragment] in a sheet, prefilled with the recipient, source account,
|
||||||
|
* amount and note, so the usual confirm/OTP flow still applies. A monthly summary sits on top,
|
||||||
|
* and long-pressing a bubble offers Send again / receipt / copy.
|
||||||
|
*/
|
||||||
|
class ChatFragment : Fragment(), ContactSheetHost {
|
||||||
|
|
||||||
|
private var _binding: FragmentChatBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
private val app get() = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
|
private var peerKey = ""
|
||||||
|
private var header: ViewChatHeaderBinding? = null
|
||||||
|
private val adapter = ChatMessagesAdapter(
|
||||||
|
onReceiptClick = { openReceipt(it.receiptKey) },
|
||||||
|
onLongPress = { message, anchor -> showMessageMenu(message, anchor) }
|
||||||
|
)
|
||||||
|
private var thread: ChatThread? = null
|
||||||
|
private var fromAccounts: List<BankAccount> = emptyList()
|
||||||
|
private var selectedFrom: BankAccount? = null
|
||||||
|
private var selectedTo: ChatAccount? = null
|
||||||
|
private var merchantQr: String? = null
|
||||||
|
private var cardAccounts: List<BankAccount> = emptyList()
|
||||||
|
private var selectedCard: BankAccount? = null
|
||||||
|
/** When the last transfer / payment sheet was opened, to tell whether it left a new receipt. */
|
||||||
|
private var payStartedAt = 0L
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentChatBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
peerKey = savedInstanceState?.getString(ARG_PEER_KEY) ?: requireArguments().getString(ARG_PEER_KEY).orEmpty()
|
||||||
|
binding.rvMessages.layoutManager = LinearLayoutManager(requireContext()).apply { stackFromEnd = true }
|
||||||
|
binding.rvMessages.adapter = adapter
|
||||||
|
|
||||||
|
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
||||||
|
adapter.setHideAmounts(it)
|
||||||
|
thread?.let { t -> bindSummary(t) }
|
||||||
|
bindFromChip()
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
|
fromAccounts = accounts.filter { it.bank == "BML" && it.profileType !in CARD_OR_LOAN }
|
||||||
|
cardAccounts = accounts.filter {
|
||||||
|
it.bank == "BML" && it.profileType in CARD_TYPES && it.statusDesc.equals("Active", ignoreCase = true)
|
||||||
|
}
|
||||||
|
bindFromAccounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnFrom.setOnClickListener { if (thread?.isMerchant == true) showCardPicker() else showAccountPicker() }
|
||||||
|
binding.btnSaveContact.setOnClickListener { openContact() }
|
||||||
|
binding.toAccountBar.setOnClickListener { showRecipientPicker() }
|
||||||
|
binding.btnSend.setOnClickListener { send() }
|
||||||
|
binding.etNote.setOnEditorActionListener { _, actionId, _ ->
|
||||||
|
if (actionId == EditorInfo.IME_ACTION_SEND) { send(); true } else false
|
||||||
|
}
|
||||||
|
binding.btnPayAgain.setOnClickListener { payBusiness() }
|
||||||
|
|
||||||
|
// Transfers started from this chat come straight back here instead of opening the receipt.
|
||||||
|
parentFragmentManager.setFragmentResultListener(TransferFragment.RESULT_TRANSFER_DONE, viewLifecycleOwner) { _, _ ->
|
||||||
|
onTransferDone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// A newly saved contact adds an account (and maybe a nickname) to this chat; reload.
|
||||||
|
viewModel.contacts.observe(viewLifecycleOwner) { loadThread() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
outState.putString(ARG_PEER_KEY, peerKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadThread() {
|
||||||
|
val ctx = requireContext().applicationContext
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val threads = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) }
|
||||||
|
val knownIds = thread?.messages?.mapTo(HashSet()) { it.id }.orEmpty()
|
||||||
|
val t = threads.firstOrNull { it.peerKey == peerKey }
|
||||||
|
?: threads.firstOrNull { th -> th.messages.any { it.id in knownIds } }
|
||||||
|
if (_binding == null) return@launch
|
||||||
|
thread = t
|
||||||
|
if (t == null) return@launch
|
||||||
|
peerKey = t.peerKey
|
||||||
|
adapter.showDestination = t.accounts.size > 1
|
||||||
|
adapter.setMessages(t.messages, getString(R.string.chat_today), getString(R.string.chat_yesterday))
|
||||||
|
binding.rvMessages.scrollToPosition(adapter.itemCount - 1)
|
||||||
|
bindSummary(t)
|
||||||
|
|
||||||
|
bindFromAccounts()
|
||||||
|
val previous = selectedTo
|
||||||
|
val to = t.accounts.firstOrNull { it.account == previous?.account }
|
||||||
|
?: t.accounts.firstOrNull { it.account == t.peerAccount }
|
||||||
|
selectTo(to, matchFromCurrency = previous == null)
|
||||||
|
updateToolbar()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Picks which of the person's accounts to send to and updates everything that depends on it. */
|
||||||
|
private fun selectTo(to: ChatAccount?, matchFromCurrency: Boolean) {
|
||||||
|
val b = _binding ?: return
|
||||||
|
val t = thread ?: return
|
||||||
|
selectedTo = to
|
||||||
|
|
||||||
|
if (t.isMerchant) {
|
||||||
|
// Businesses paid by card / Scan to Pay: history only, nothing to send to or save.
|
||||||
|
b.toAccountBar.visibility = View.GONE
|
||||||
|
b.saveContactBar.visibility = View.GONE
|
||||||
|
b.composer.visibility = View.GONE
|
||||||
|
b.tvCannotSend.visibility = View.GONE
|
||||||
|
b.btnPayAgain.visibility = View.VISIBLE
|
||||||
|
bindMerchantQr(t)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b.btnPayAgain.visibility = View.GONE
|
||||||
|
// One account: it's shown in the header. Several: this bar picks between them.
|
||||||
|
b.toAccountBar.visibility = if (to == null || t.accounts.size < 2) View.GONE else View.VISIBLE
|
||||||
|
if (to != null) {
|
||||||
|
b.tvToAccount.text = to.account
|
||||||
|
b.tvToCurrency.text = to.currency
|
||||||
|
b.tvToCurrency.visibility = if (to.currency.isBlank()) View.GONE else View.VISIBLE
|
||||||
|
val nickname = to.contact?.benefNickName?.takeIf { it.isNotBlank() && !it.equals(t.peerName, ignoreCase = true) }
|
||||||
|
b.tvToNickname.text = nickname
|
||||||
|
b.tvToNickname.visibility = if (nickname == null) View.GONE else View.VISIBLE
|
||||||
|
b.ivToSwitch.visibility = if (t.accounts.size > 1) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offer to save the selected account (or the person, when no account is known yet).
|
||||||
|
b.saveContactBar.visibility = if (to?.contact == null) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
val canSend = to != null
|
||||||
|
b.composer.visibility = if (canSend) View.VISIBLE else View.GONE
|
||||||
|
b.tvCannotSend.visibility = if (canSend) View.GONE else View.VISIBLE
|
||||||
|
|
||||||
|
// Sending to a USD account: default to a USD source account, and likewise for MVR.
|
||||||
|
if (matchFromCurrency && to != null && to.currency.isNotBlank() &&
|
||||||
|
!selectedFrom?.currencyName.equals(to.currency, ignoreCase = true)) {
|
||||||
|
fromAccounts.firstOrNull { it.currencyName.equals(to.currency, ignoreCase = true) }?.let(::selectFrom)
|
||||||
|
}
|
||||||
|
updateCurrencyWarning()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showRecipientPicker() {
|
||||||
|
val t = thread ?: return
|
||||||
|
if (t.accounts.size < 2) return
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dialog = BottomSheetDialog(ctx)
|
||||||
|
val list = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val pad = (8 * resources.displayMetrics.density).toInt()
|
||||||
|
setPadding(0, pad, 0, pad * 3)
|
||||||
|
}
|
||||||
|
list.addView(sheetTitle(getString(R.string.chat_pick_recipient_account)))
|
||||||
|
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
|
||||||
|
for (account in t.accounts) {
|
||||||
|
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
|
||||||
|
row.tvDropdownAccountName.text = account.contact?.benefNickName?.takeIf { it.isNotBlank() }
|
||||||
|
?: getString(R.string.chat_not_in_contacts)
|
||||||
|
row.tvDropdownAccountNumber.text = account.account
|
||||||
|
row.tvDropdownBalance.text = account.currency
|
||||||
|
row.ivDropdownCardLogo.visibility = View.GONE
|
||||||
|
// A USD account can only be paid from a USD account.
|
||||||
|
val unavailable = account.currency.equals("USD", ignoreCase = true) && !hasUsdSource()
|
||||||
|
row.tvDropdownAccountType.text = getString(R.string.chat_needs_usd_source)
|
||||||
|
row.tvDropdownAccountType.visibility = if (unavailable) View.VISIBLE else View.GONE
|
||||||
|
row.root.alpha = if (unavailable) 0.4f else 1f
|
||||||
|
if (account.account == selectedTo?.account) row.root.setBackgroundColor(selectedBg)
|
||||||
|
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
|
||||||
|
if (!unavailable) row.root.setOnClickListener {
|
||||||
|
selectTo(account, matchFromCurrency = true)
|
||||||
|
updateToolbar()
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
list.addView(row.root)
|
||||||
|
}
|
||||||
|
dialog.setContentView(list)
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sheetTitle(text: String) = TextView(requireContext()).apply {
|
||||||
|
this.text = text
|
||||||
|
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_TitleMedium)
|
||||||
|
val pad = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
setPadding(pad, pad / 2, pad, pad / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindFromAccounts() {
|
||||||
|
val b = _binding ?: return
|
||||||
|
if (fromAccounts.isEmpty()) return
|
||||||
|
// Default to the account last used with this person.
|
||||||
|
val lastUsed = thread?.messages?.lastOrNull { it.isSent }?.accountNumber
|
||||||
|
val current = selectedFrom?.takeIf { s -> fromAccounts.any { it.accountNumber == s.accountNumber } }
|
||||||
|
selectedFrom = current ?: fromAccounts.firstOrNull { it.accountNumber == lastUsed } ?: fromAccounts.first()
|
||||||
|
|
||||||
|
selectFrom(selectedFrom!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onTransferDone() {
|
||||||
|
childFragmentManager.findFragmentByTag(TAG_HIDDEN_TRANSFER)?.let {
|
||||||
|
childFragmentManager.beginTransaction().remove(it).commitAllowingStateLoss()
|
||||||
|
}
|
||||||
|
binding.etAmount.text = null
|
||||||
|
binding.etNote.text = null
|
||||||
|
// The receipt shows the new bubble right away; history catches up a little later and
|
||||||
|
// replaces it with the bank's own entry.
|
||||||
|
sync()
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
delay(HISTORY_CATCH_UP_MS)
|
||||||
|
sync()
|
||||||
|
}
|
||||||
|
// Only offer the receipt this payment made; QR payments don't save one.
|
||||||
|
val receipt = ReceiptStore.loadAll(requireContext()).firstOrNull()?.takeIf { it.savedAt >= payStartedAt }
|
||||||
|
val merchant = thread?.isMerchant == true
|
||||||
|
Snackbar.make(binding.root, if (merchant) R.string.chat_payment_sent else R.string.chat_transfer_sent, Snackbar.LENGTH_LONG)
|
||||||
|
.setAnchorView(if (binding.composer.visibility == View.VISIBLE) binding.composer else binding.btnPayAgain)
|
||||||
|
.apply { if (receipt != null) setAction(R.string.chat_view_receipt) { openReceipt(receipt.savedAt.toString()) } }
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openReceipt(receiptKey: String) {
|
||||||
|
val entry = ReceiptStore.loadAll(requireContext()).firstOrNull { it.savedAt.toString() == receiptKey }
|
||||||
|
if (entry == null) {
|
||||||
|
Snackbar.make(binding.root, R.string.chat_receipt_missing, Snackbar.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(TransferReceiptFragment.newInstance(entry.data, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sync() {
|
||||||
|
val ctx = requireContext().applicationContext
|
||||||
|
val accounts = viewModel.accounts.value ?: emptyList()
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
withContext(Dispatchers.IO) { ChatStore.sync(ctx, app, accounts, contacts) }
|
||||||
|
if (_binding != null) loadThread()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectFrom(account: BankAccount) {
|
||||||
|
val b = _binding ?: return
|
||||||
|
selectedFrom = account
|
||||||
|
bindFromChip()
|
||||||
|
b.tilAmount.prefixText = account.currencyName
|
||||||
|
updateCurrencyWarning()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The chip shows the account (or, in a business chat, the card) paid from, with its balance. */
|
||||||
|
private fun bindFromChip() {
|
||||||
|
val b = _binding ?: return
|
||||||
|
val account = (if (thread?.isMerchant == true) selectedCard else selectedFrom) ?: return
|
||||||
|
val balance = AccountListParser.from(account)?.balance.orEmpty()
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
val money = when {
|
||||||
|
balance.isBlank() -> account.currencyName
|
||||||
|
hide -> AccountHistoryAdapter.maskAmount(balance)
|
||||||
|
else -> balance
|
||||||
|
}
|
||||||
|
b.btnFrom.text = getString(R.string.chat_from_chip, account.accountBriefName, account.accountNumber, money)
|
||||||
|
b.btnFrom.contentDescription = getString(R.string.chat_from_account) + ": " + b.btnFrom.text
|
||||||
|
|
||||||
|
// A card shows its network logo up front, in full colour; an account keeps the dropdown arrow.
|
||||||
|
val logo = if (thread?.isMerchant == true) sh.sar.basedbank.util.bmlapi.BmlCardParser.cardNetworkIcon(account) else null
|
||||||
|
val density = resources.displayMetrics.density
|
||||||
|
if (logo != null) {
|
||||||
|
b.btnFrom.setIconResource(logo)
|
||||||
|
b.btnFrom.iconTint = null
|
||||||
|
b.btnFrom.iconGravity = com.google.android.material.button.MaterialButton.ICON_GRAVITY_START
|
||||||
|
b.btnFrom.iconSize = (28 * density).toInt()
|
||||||
|
} else {
|
||||||
|
b.btnFrom.setIconResource(R.drawable.ic_arrow_right)
|
||||||
|
b.btnFrom.iconTint = android.content.res.ColorStateList.valueOf(
|
||||||
|
MaterialColors.getColor(b.btnFrom, com.google.android.material.R.attr.colorOnSecondaryContainer))
|
||||||
|
b.btnFrom.iconGravity = com.google.android.material.button.MaterialButton.ICON_GRAVITY_END
|
||||||
|
b.btnFrom.iconSize = (16 * density).toInt()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showCardPicker() {
|
||||||
|
if (cardAccounts.isEmpty()) return
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dialog = BottomSheetDialog(ctx)
|
||||||
|
val list = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val pad = (8 * resources.displayMetrics.density).toInt()
|
||||||
|
setPadding(0, pad, 0, pad * 3)
|
||||||
|
}
|
||||||
|
list.addView(sheetTitle(getString(R.string.chat_pick_card)))
|
||||||
|
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
|
||||||
|
for (card in cardAccounts) {
|
||||||
|
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
|
||||||
|
bindAccountRow(row, card)
|
||||||
|
sh.sar.basedbank.util.bmlapi.BmlCardParser.cardNetworkIcon(card)?.let {
|
||||||
|
row.ivDropdownCardLogo.setImageResource(it)
|
||||||
|
row.ivDropdownCardLogo.visibility = View.VISIBLE
|
||||||
|
}
|
||||||
|
if (card.accountNumber == selectedCard?.accountNumber) row.root.setBackgroundColor(selectedBg)
|
||||||
|
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
|
||||||
|
row.root.setOnClickListener {
|
||||||
|
selectedCard = card
|
||||||
|
bindFromChip()
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
list.addView(row.root)
|
||||||
|
}
|
||||||
|
dialog.setContentView(list)
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MVR can't be sent into a USD account: say so and disable Send. USD into an MVR account is
|
||||||
|
* converted by BML at its rate: allowed, with a warning.
|
||||||
|
*/
|
||||||
|
private fun updateCurrencyWarning() {
|
||||||
|
val b = _binding ?: return
|
||||||
|
val from = selectedFrom?.currencyName.orEmpty()
|
||||||
|
val to = selectedTo?.currency.orEmpty()
|
||||||
|
val blocked = isBlockedPair(from, to)
|
||||||
|
val converts = from.equals("USD", ignoreCase = true) && to.equals("MVR", ignoreCase = true)
|
||||||
|
b.tvCurrencyWarning.text = when {
|
||||||
|
blocked -> getString(R.string.chat_currency_blocked)
|
||||||
|
converts -> getString(R.string.chat_currency_warning)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
b.tvCurrencyWarning.visibility = if (blocked || converts) View.VISIBLE else View.GONE
|
||||||
|
b.btnSend.isEnabled = !blocked
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MVR → USD account: BML doesn't allow it. */
|
||||||
|
private fun isBlockedPair(fromCurrency: String, toCurrency: String) =
|
||||||
|
fromCurrency.equals("MVR", ignoreCase = true) && toCurrency.equals("USD", ignoreCase = true)
|
||||||
|
|
||||||
|
private fun hasUsdSource() = fromAccounts.any { it.currencyName.equals("USD", ignoreCase = true) }
|
||||||
|
|
||||||
|
/** Looks up a saved static QR for this business: with one, the button pays it directly. */
|
||||||
|
private fun bindMerchantQr(t: ChatThread) {
|
||||||
|
val ctx = requireContext().applicationContext
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val qr = withContext(Dispatchers.IO) { ChatStore.merchantQr(ctx, t.peerName) }
|
||||||
|
val b = _binding ?: return@launch
|
||||||
|
merchantQr = qr
|
||||||
|
b.btnPayAgain.text = if (qr != null) getString(R.string.chat_pay_business, t.peerName)
|
||||||
|
else getString(R.string.chat_pay_again)
|
||||||
|
// A saved QR is paid from a card picked here: show just the card chip above the button.
|
||||||
|
b.composer.visibility = if (qr != null && cardAccounts.isNotEmpty()) View.VISIBLE else View.GONE
|
||||||
|
b.amountRow.visibility = View.GONE
|
||||||
|
b.tvCurrencyWarning.visibility = View.GONE
|
||||||
|
if (qr != null && selectedCard == null) {
|
||||||
|
val lastCard = t.messages.lastOrNull { m -> cardAccounts.any { it.accountNumber == m.accountNumber } }?.accountNumber
|
||||||
|
val defaultCard = CredentialStore(requireContext()).getDefaultCardAccountNumber()
|
||||||
|
selectedCard = cardAccounts.firstOrNull { it.accountNumber == lastCard }
|
||||||
|
?: cardAccounts.firstOrNull { it.accountNumber == defaultCard }
|
||||||
|
?: cardAccounts.firstOrNull()
|
||||||
|
}
|
||||||
|
bindFromChip()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saved static QR: pay it in the sheet over the chat. Otherwise: open the scanner. */
|
||||||
|
private fun payBusiness() {
|
||||||
|
val qr = merchantQr
|
||||||
|
if (qr == null) {
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(TransferFragment.newInstanceWithAutoScan())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return
|
||||||
|
val transfer = TransferFragment.newInstanceFromBmlQr(qr, selectedCard?.accountNumber, returnOnSuccess = true)
|
||||||
|
payStartedAt = System.currentTimeMillis()
|
||||||
|
hideKeyboard()
|
||||||
|
TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** This month with this person (sent / received / net) or at this business (spent / payments). */
|
||||||
|
private fun bindSummary(t: ChatThread) {
|
||||||
|
val b = _binding ?: return
|
||||||
|
val monthStart = java.util.Calendar.getInstance().apply {
|
||||||
|
set(java.util.Calendar.DAY_OF_MONTH, 1)
|
||||||
|
set(java.util.Calendar.HOUR_OF_DAY, 0); set(java.util.Calendar.MINUTE, 0)
|
||||||
|
set(java.util.Calendar.SECOND, 0); set(java.util.Calendar.MILLISECOND, 0)
|
||||||
|
}.timeInMillis
|
||||||
|
val thisMonth = t.messages.filter { it.timeMillis >= monthStart }
|
||||||
|
if (thisMonth.isEmpty()) { b.summaryCard.visibility = View.GONE; return }
|
||||||
|
// One currency per card: the selected account's, else the one used most this month.
|
||||||
|
val currency = selectedTo?.currency?.takeIf { c -> thisMonth.any { it.currency == c } }
|
||||||
|
?: thisMonth.groupingBy { it.currency }.eachCount().maxByOrNull { it.value }!!.key
|
||||||
|
val ms = thisMonth.filter { it.currency == currency }
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
fun money(v: Double) = if (hide) "$currency ••••" else "$currency ${"%,.2f".format(v)}"
|
||||||
|
val month = java.text.SimpleDateFormat("MMMM", java.util.Locale.getDefault()).format(java.util.Date())
|
||||||
|
|
||||||
|
b.summaryCard.visibility = View.VISIBLE
|
||||||
|
val sent = ms.filter { it.isSent }.sumOf { -it.amount }
|
||||||
|
if (t.isMerchant) {
|
||||||
|
b.tvSumLabel1.text = getString(R.string.chat_sum_spent, month)
|
||||||
|
b.tvSumValue1.text = money(sent)
|
||||||
|
b.sumColumn2.visibility = View.GONE
|
||||||
|
b.tvSumLabel3.text = getString(R.string.chat_sum_payments)
|
||||||
|
b.tvSumValue3.text = ms.size.toString()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val received = ms.filter { !it.isSent }.sumOf { it.amount }
|
||||||
|
val night = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||||
|
android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||||
|
b.sumColumn2.visibility = View.VISIBLE
|
||||||
|
b.tvSumLabel1.text = getString(R.string.chat_sum_sent, month)
|
||||||
|
b.tvSumValue1.text = money(sent)
|
||||||
|
b.tvSumValue1.setTextColor(MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorError))
|
||||||
|
b.tvSumLabel2.text = getString(R.string.chat_sum_received)
|
||||||
|
b.tvSumValue2.text = money(received)
|
||||||
|
b.tvSumValue2.setTextColor(android.graphics.Color.parseColor(if (night) "#81C784" else "#2E7D32"))
|
||||||
|
b.tvSumLabel3.text = getString(R.string.chat_sum_net)
|
||||||
|
val net = received - sent
|
||||||
|
b.tvSumValue3.text = if (hide) "••••" else (if (net < 0) "−" else "+") + "%,.2f".format(kotlin.math.abs(net))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Long-press on a bubble: send the same again, open its receipt, or copy its reference. */
|
||||||
|
private fun showMessageMenu(m: sh.sar.basedbank.util.ChatMessage, anchor: View) {
|
||||||
|
val t = thread ?: return
|
||||||
|
val menu = android.widget.PopupMenu(requireContext(), anchor, android.view.Gravity.END)
|
||||||
|
val amount = "%.2f".format(kotlin.math.abs(m.amount))
|
||||||
|
val canSendAgain = m.isSent && !t.isMerchant && selectedTo != null
|
||||||
|
if (canSendAgain) menu.menu.add(0, 1, 0, getString(R.string.chat_send_again, "${m.currency} $amount"))
|
||||||
|
if (m.receiptKey.isNotBlank()) menu.menu.add(0, 2, 1, R.string.chat_view_receipt_full)
|
||||||
|
if (m.reference.isNotBlank()) menu.menu.add(0, 3, 2, R.string.chat_copy_reference)
|
||||||
|
if (menu.menu.size() == 0) return
|
||||||
|
menu.setOnMenuItemClickListener { item ->
|
||||||
|
when (item.itemId) {
|
||||||
|
1 -> {
|
||||||
|
// Same account it went to, when that account is still one of theirs.
|
||||||
|
t.accounts.firstOrNull { it.account == m.peerAccount }?.let { selectTo(it, matchFromCurrency = true) }
|
||||||
|
binding.etAmount.setText(amount)
|
||||||
|
binding.etNote.setText(m.note)
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
2 -> openReceipt(m.receiptKey)
|
||||||
|
3 -> {
|
||||||
|
val clipboard = requireContext().getSystemService(android.content.ClipboardManager::class.java)
|
||||||
|
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("reference", m.reference))
|
||||||
|
Snackbar.make(binding.root, R.string.chat_reference_copied, Snackbar.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
menu.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fills an account row: name, full account number, balance (masked when amounts are hidden). */
|
||||||
|
private fun bindAccountRow(row: ItemAccountDropdownBinding, account: BankAccount) {
|
||||||
|
val display = AccountListParser.from(account)
|
||||||
|
row.tvDropdownAccountName.text = account.accountBriefName
|
||||||
|
row.tvDropdownAccountNumber.text = account.accountNumber
|
||||||
|
val balance = display?.balance.orEmpty()
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
row.tvDropdownBalance.text = if (hide && balance.isNotBlank()) AccountHistoryAdapter.maskAmount(balance) else balance
|
||||||
|
val type = display?.typeLabel.orEmpty()
|
||||||
|
row.tvDropdownAccountType.text = type
|
||||||
|
row.tvDropdownAccountType.visibility = if (type.isBlank()) View.GONE else View.VISIBLE
|
||||||
|
row.ivDropdownCardLogo.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showAccountPicker() {
|
||||||
|
if (fromAccounts.isEmpty()) return
|
||||||
|
val ctx = requireContext()
|
||||||
|
val dialog = BottomSheetDialog(ctx)
|
||||||
|
val list = LinearLayout(ctx).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
val pad = (8 * resources.displayMetrics.density).toInt()
|
||||||
|
setPadding(0, pad, 0, pad * 3)
|
||||||
|
}
|
||||||
|
list.addView(sheetTitle(getString(R.string.chat_pick_account)))
|
||||||
|
val selectedBg = MaterialColors.getColor(list, com.google.android.material.R.attr.colorSecondaryContainer)
|
||||||
|
for (account in fromAccounts) {
|
||||||
|
val row = ItemAccountDropdownBinding.inflate(layoutInflater, list, false)
|
||||||
|
bindAccountRow(row, account)
|
||||||
|
// MVR can't go into a USD account: show those accounts, but not selectable.
|
||||||
|
val blocked = isBlockedPair(account.currencyName, selectedTo?.currency.orEmpty())
|
||||||
|
if (blocked) {
|
||||||
|
row.tvDropdownAccountType.text = getString(R.string.chat_cant_send_mvr_to_usd)
|
||||||
|
row.tvDropdownAccountType.visibility = View.VISIBLE
|
||||||
|
row.root.alpha = 0.4f
|
||||||
|
}
|
||||||
|
if (account.accountNumber == selectedFrom?.accountNumber) row.root.setBackgroundColor(selectedBg)
|
||||||
|
else row.root.setBackgroundResource(android.R.drawable.list_selector_background)
|
||||||
|
if (!blocked) row.root.setOnClickListener {
|
||||||
|
selectFrom(account)
|
||||||
|
dialog.dismiss()
|
||||||
|
}
|
||||||
|
list.addView(row.root)
|
||||||
|
}
|
||||||
|
dialog.setContentView(list)
|
||||||
|
dialog.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun send() {
|
||||||
|
val t = thread ?: return
|
||||||
|
val from = selectedFrom ?: return
|
||||||
|
val to = selectedTo ?: return
|
||||||
|
if (isBlockedPair(from.currencyName, to.currency)) return
|
||||||
|
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull()
|
||||||
|
if (amount == null || amount <= 0.0) {
|
||||||
|
binding.tilAmount.error = getString(R.string.chat_amount_invalid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.tilAmount.error = null
|
||||||
|
val transfer = TransferFragment.newInstanceFromQr(
|
||||||
|
accountNumber = to.account,
|
||||||
|
displayName = to.contact?.benefNickName?.takeIf { it.isNotBlank() } ?: t.peerName,
|
||||||
|
amount = "%.2f".format(amount),
|
||||||
|
remarks = binding.etNote.text?.toString()?.trim()?.takeIf { it.isNotBlank() },
|
||||||
|
fromAccountNumber = from.accountNumber,
|
||||||
|
returnOnSuccess = true,
|
||||||
|
autoConfirm = true
|
||||||
|
)
|
||||||
|
if (childFragmentManager.findFragmentByTag("transfer_sheet") != null) return
|
||||||
|
payStartedAt = System.currentTimeMillis()
|
||||||
|
hideKeyboard()
|
||||||
|
if (isBusinessProfile(from)) {
|
||||||
|
// Business profiles confirm with an OTP typed into the Transfer page: show it in a sheet.
|
||||||
|
TransferSheetFragment.newInstance(transfer.requireArguments()).show(childFragmentManager, "transfer_sheet")
|
||||||
|
} else {
|
||||||
|
// Personal profiles confirm automatically: run the Transfer page hidden, so only its
|
||||||
|
// confirm dialog appears over the chat. A new send replaces any earlier one.
|
||||||
|
childFragmentManager.beginTransaction()
|
||||||
|
.replace(R.id.hiddenTransferHost, transfer, TAG_HIDDEN_TRANSFER)
|
||||||
|
.commit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same rule as the Transfer page: the account's BML login profile is a business one. */
|
||||||
|
private fun isBusinessProfile(account: BankAccount): Boolean {
|
||||||
|
val profiles = app.bmlProfilesMap[account.loginTag.removePrefix("bml_")] ?: return false
|
||||||
|
return profiles.firstOrNull { it.profileId == account.profileId }?.profileType == "business"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closes the composer's keyboard so the transfer sheet doesn't open pushed up by it. */
|
||||||
|
private fun hideKeyboard() {
|
||||||
|
val b = _binding ?: return
|
||||||
|
val focused = b.root.findFocus() ?: return
|
||||||
|
requireContext().getSystemService(android.view.inputmethod.InputMethodManager::class.java)
|
||||||
|
?.hideSoftInputFromWindow(focused.windowToken, 0)
|
||||||
|
focused.clearFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shows the photo, contact name and real name in the toolbar; tapping it opens the contact. */
|
||||||
|
private fun updateToolbar() {
|
||||||
|
val t = thread ?: return
|
||||||
|
val bar = (activity as? AppCompatActivity)?.supportActionBar ?: return
|
||||||
|
val h = header ?: ViewChatHeaderBinding.inflate(layoutInflater).also { h ->
|
||||||
|
header = h
|
||||||
|
h.root.setOnClickListener { openContact() }
|
||||||
|
}
|
||||||
|
h.tvHeaderName.text = t.peerName
|
||||||
|
// Real name (when titled with a nickname), plus the account and currency when there's only one.
|
||||||
|
val subtitle = if (t.isMerchant) getString(R.string.chat_merchant_subtitle) else listOfNotNull(
|
||||||
|
t.realName.takeIf { it.isNotBlank() && !it.equals(t.peerName, ignoreCase = true) },
|
||||||
|
selectedTo?.takeIf { t.accounts.size == 1 }?.account,
|
||||||
|
selectedTo?.takeIf { t.accounts.size == 1 }?.currency?.takeIf { it.isNotBlank() }
|
||||||
|
).joinToString(" · ")
|
||||||
|
h.tvHeaderAccount.text = subtitle
|
||||||
|
h.tvHeaderAccount.visibility = if (subtitle.isBlank()) View.GONE else View.VISIBLE
|
||||||
|
val sizePx = (40 * resources.displayMetrics.density).toInt()
|
||||||
|
if (t.isMerchant) {
|
||||||
|
h.ivHeaderAvatar.shapeAppearanceModel = com.google.android.material.shape.ShapeAppearanceModel.builder()
|
||||||
|
.setAllCornerSizes(10 * resources.displayMetrics.density).build()
|
||||||
|
h.ivHeaderAvatar.setImageBitmap(ChatsAdapter.shopBitmap(h.root, ChatsAdapter.merchantColor(t.peerKey), sizePx))
|
||||||
|
} else {
|
||||||
|
val photo = t.contact?.customerImgHash?.let { ContactImageCache.load(requireContext(), it) }
|
||||||
|
h.ivHeaderAvatar.setImageBitmap(photo ?: contactInitialsBitmap(t.peerName, ChatsAdapter.avatarColor(t.peerKey), sizePx))
|
||||||
|
}
|
||||||
|
|
||||||
|
requireActivity().title = t.peerName
|
||||||
|
bar.setDisplayShowTitleEnabled(false)
|
||||||
|
bar.setDisplayShowCustomEnabled(true)
|
||||||
|
bar.customView = h.root
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openContact() {
|
||||||
|
val t = thread ?: return
|
||||||
|
if (t.isMerchant) return
|
||||||
|
val display = (selectedTo?.contact ?: t.contact)?.let { ContactListParser.from(it) }
|
||||||
|
if (display != null) {
|
||||||
|
if (childFragmentManager.findFragmentByTag("contact_details") != null) return
|
||||||
|
ContactDetailsSheetFragment.newInstance(display).show(childFragmentManager, "contact_details")
|
||||||
|
} else {
|
||||||
|
// Not a saved contact yet: offer to save them, prefilled with what we know.
|
||||||
|
if (childFragmentManager.findFragmentByTag("add_contact") != null) return
|
||||||
|
AddContactSheetFragment.newInstance(
|
||||||
|
bmlProfileId = selectedFrom?.profileId?.takeIf { it.isNotBlank() },
|
||||||
|
accountNumber = selectedTo?.account,
|
||||||
|
recipientName = t.realName.ifBlank { t.peerName },
|
||||||
|
currency = selectedTo?.currency?.takeIf { it.isNotBlank() } ?: selectedFrom?.currencyName
|
||||||
|
).show(childFragmentManager, "add_contact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openTransfer(contact: ContactDisplay) {
|
||||||
|
val fragment = TransferFragment.newInstance(
|
||||||
|
accountNumber = contact.accountNumber,
|
||||||
|
displayName = contact.name,
|
||||||
|
subtitle = contact.transferSubtitle,
|
||||||
|
colorHex = contact.bankColor,
|
||||||
|
imageHash = contact.imageHash
|
||||||
|
)
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deleting lives on the Contacts page, which also keeps its caches in sync. */
|
||||||
|
override fun confirmDelete(contact: ContactDisplay) {
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(ContactsFragment())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
updateToolbar()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
(activity as? AppCompatActivity)?.supportActionBar?.apply {
|
||||||
|
setDisplayShowCustomEnabled(false)
|
||||||
|
setDisplayShowTitleEnabled(true)
|
||||||
|
customView = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
super.onDestroyView()
|
||||||
|
header = null
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_PEER_KEY = "peer_key"
|
||||||
|
private const val HISTORY_CATCH_UP_MS = 10_000L
|
||||||
|
private const val TAG_HIDDEN_TRANSFER = "transfer_hidden"
|
||||||
|
private val CARD_OR_LOAN = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT", "BML_LOAN")
|
||||||
|
private val CARD_TYPES = setOf("BML_PREPAID", "BML_CREDIT", "BML_DEBIT")
|
||||||
|
|
||||||
|
fun newInstance(peerKey: String) = ChatFragment().apply {
|
||||||
|
arguments = Bundle().apply { putString(ARG_PEER_KEY, peerKey) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Typeface
|
||||||
|
import android.text.SpannableStringBuilder
|
||||||
|
import android.text.Spanned
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.text.style.StyleSpan
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import androidx.core.graphics.ColorUtils
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
import com.google.android.material.shape.ShapeAppearanceModel
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.ItemChatBubbleBinding
|
||||||
|
import sh.sar.basedbank.databinding.ItemChatDateBinding
|
||||||
|
import sh.sar.basedbank.util.ChatMessage
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One chat's transfers or payments as compact bubbles — sent / paid on the right, received on the
|
||||||
|
* left — with ✓ (seen in a BML alert) or ✓✓ (booked in history). Day separators carry the day's
|
||||||
|
* totals.
|
||||||
|
*/
|
||||||
|
class ChatMessagesAdapter(
|
||||||
|
private val onReceiptClick: (ChatMessage) -> Unit,
|
||||||
|
private val onLongPress: (ChatMessage, View) -> Unit
|
||||||
|
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||||
|
|
||||||
|
/** Rows of the list: a day separator or a bubble. */
|
||||||
|
sealed class Item {
|
||||||
|
data class DateChip(val day: String, val messages: List<ChatMessage>) : Item()
|
||||||
|
data class Bubble(val message: ChatMessage) : Item()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val items = mutableListOf<Item>()
|
||||||
|
private var hideAmounts = false
|
||||||
|
private var todayLabel = ""
|
||||||
|
private var yesterdayLabel = ""
|
||||||
|
|
||||||
|
/** When the person has several accounts, sent bubbles say which one the money went to. */
|
||||||
|
var showDestination = false
|
||||||
|
|
||||||
|
fun setHideAmounts(hide: Boolean) {
|
||||||
|
if (hideAmounts == hide) return
|
||||||
|
hideAmounts = hide
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [messages] must be oldest first. */
|
||||||
|
fun setMessages(messages: List<ChatMessage>, todayLabel: String, yesterdayLabel: String) {
|
||||||
|
this.todayLabel = todayLabel
|
||||||
|
this.yesterdayLabel = yesterdayLabel
|
||||||
|
items.clear()
|
||||||
|
messages.groupBy { it.date.take(10) }.forEach { (day, dayMessages) ->
|
||||||
|
items.add(Item.DateChip(day, dayMessages))
|
||||||
|
dayMessages.forEach { items.add(Item.Bubble(it)) }
|
||||||
|
}
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount() = items.size
|
||||||
|
|
||||||
|
override fun getItemViewType(position: Int) = when (items[position]) {
|
||||||
|
is Item.DateChip -> TYPE_DATE
|
||||||
|
is Item.Bubble -> TYPE_BUBBLE
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||||
|
val inflater = LayoutInflater.from(parent.context)
|
||||||
|
return if (viewType == TYPE_DATE) DateVH(ItemChatDateBinding.inflate(inflater, parent, false))
|
||||||
|
else BubbleVH(ItemChatBubbleBinding.inflate(inflater, parent, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
|
when (val item = items[position]) {
|
||||||
|
is Item.DateChip -> (holder as DateVH).bind(item)
|
||||||
|
is Item.Bubble -> (holder as BubbleVH).bind(item.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatAmount(m: ChatMessage) =
|
||||||
|
if (hideAmounts) "${m.currency} ••••••" else "${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}"
|
||||||
|
|
||||||
|
private fun isNight(view: View) =
|
||||||
|
(view.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
|
||||||
|
private fun ticksFor(m: ChatMessage) = if (m.isBooked) "✓✓" else "✓"
|
||||||
|
|
||||||
|
private fun tickColor(view: View, m: ChatMessage, fallback: Int) =
|
||||||
|
if (m.isBooked) Color.parseColor(if (isNight(view)) "#90CAF9" else "#1565C0") else fallback
|
||||||
|
|
||||||
|
inner class DateVH(private val b: ItemChatDateBinding) : RecyclerView.ViewHolder(b.root) {
|
||||||
|
fun bind(item: Item.DateChip) {
|
||||||
|
val first = item.messages.first()
|
||||||
|
val label = when {
|
||||||
|
DateUtils.isToday(first.timeMillis) -> todayLabel
|
||||||
|
DateUtils.isToday(first.timeMillis + DateUtils.DAY_IN_MILLIS) -> yesterdayLabel
|
||||||
|
else -> AccountHistoryAdapter.formatDateHeader(first.date)
|
||||||
|
}
|
||||||
|
val text = SpannableStringBuilder(label)
|
||||||
|
text.setSpan(StyleSpan(Typeface.BOLD), 0, label.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
|
if (!hideAmounts) {
|
||||||
|
// Day totals per currency: ↑ sent, ↓ received.
|
||||||
|
val parts = item.messages.groupBy { it.currency }.map { (ccy, ms) ->
|
||||||
|
val sent = ms.filter { it.isSent }.sumOf { -it.amount }
|
||||||
|
val received = ms.filter { !it.isSent }.sumOf { it.amount }
|
||||||
|
listOfNotNull(
|
||||||
|
"↑ %.2f".format(sent).takeIf { sent > 0 },
|
||||||
|
"↓ %.2f".format(received).takeIf { received > 0 }
|
||||||
|
).joinToString(" · ").let { if (item.messages.map { m -> m.currency }.toSet().size > 1) "$ccy $it" else it }
|
||||||
|
}.filter { it.isNotBlank() }
|
||||||
|
if (parts.isNotEmpty()) text.append(" ").append(parts.joinToString(" "))
|
||||||
|
}
|
||||||
|
b.tvDate.text = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class BubbleVH(private val b: ItemChatBubbleBinding) : RecyclerView.ViewHolder(b.root) {
|
||||||
|
fun bind(m: ChatMessage) {
|
||||||
|
val ctx = b.root.context
|
||||||
|
val sent = m.isSent
|
||||||
|
val night = isNight(b.root)
|
||||||
|
val density = ctx.resources.displayMetrics.density
|
||||||
|
|
||||||
|
b.bubbleRow.gravity = if (sent) Gravity.END else Gravity.START
|
||||||
|
(b.cardBubble.layoutParams as LinearLayout.LayoutParams).gravity = if (sent) Gravity.END else Gravity.START
|
||||||
|
|
||||||
|
// Rounded, with a small "tail" corner on the sender's side at the bottom.
|
||||||
|
val rtl = b.root.layoutDirection == View.LAYOUT_DIRECTION_RTL
|
||||||
|
val big = 18 * density
|
||||||
|
val tail = 4 * density
|
||||||
|
val tailOnRight = sent != rtl
|
||||||
|
b.cardBubble.shapeAppearanceModel = ShapeAppearanceModel.builder()
|
||||||
|
.setTopLeftCornerSize(big).setTopRightCornerSize(big)
|
||||||
|
.setBottomLeftCornerSize(if (tailOnRight) big else tail)
|
||||||
|
.setBottomRightCornerSize(if (tailOnRight) tail else big)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Sent: tinted. Received: neutral with an outline, so the two read apart on any theme.
|
||||||
|
val bg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorErrorContainer)
|
||||||
|
else Color.parseColor(if (night) "#2A2C2E" else "#FFFFFF")
|
||||||
|
val fg = if (sent) MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnErrorContainer)
|
||||||
|
else MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOnSurface)
|
||||||
|
val green = Color.parseColor(if (night) "#81C784" else "#2E7D32")
|
||||||
|
b.cardBubble.setCardBackgroundColor(bg)
|
||||||
|
b.cardBubble.strokeWidth = if (sent) 0 else density.toInt()
|
||||||
|
b.cardBubble.strokeColor = MaterialColors.getColor(b.root, com.google.android.material.R.attr.colorOutlineVariant)
|
||||||
|
|
||||||
|
b.tvAmount.text = if (sent) formatAmount(m) else "+ ${formatAmount(m)}"
|
||||||
|
b.tvAmount.setTextColor(if (sent) fg else green)
|
||||||
|
b.tvNote.text = m.note
|
||||||
|
b.tvNote.setTextColor(fg)
|
||||||
|
b.tvNote.visibility = if (m.note.isBlank()) View.GONE else View.VISIBLE
|
||||||
|
|
||||||
|
b.btnReceipt.visibility = if (m.receiptKey.isNotBlank()) View.VISIBLE else View.GONE
|
||||||
|
b.btnReceipt.setColorFilter(fg)
|
||||||
|
b.btnReceipt.setOnClickListener { onReceiptClick(m) }
|
||||||
|
|
||||||
|
b.tvAccount.text = when {
|
||||||
|
!sent -> ctx.getString(R.string.chat_into_short, m.accountDisplayName)
|
||||||
|
showDestination && m.peerAccount.isNotBlank() ->
|
||||||
|
ctx.getString(R.string.chat_account_to, m.accountDisplayName, m.peerAccount)
|
||||||
|
else -> m.accountDisplayName
|
||||||
|
}
|
||||||
|
b.tvAccount.setTextColor(fg)
|
||||||
|
b.tvTime.text = AccountHistoryAdapter.formatTime(m.date)
|
||||||
|
b.tvTime.setTextColor(fg)
|
||||||
|
b.tvTicks.text = ticksFor(m)
|
||||||
|
b.tvTicks.setTextColor(tickColor(b.root, m, ColorUtils.setAlphaComponent(fg, 0xB3)))
|
||||||
|
b.tvTicks.contentDescription = ctx.getString(if (m.isBooked) R.string.chat_tick_booked else R.string.chat_tick_seen)
|
||||||
|
|
||||||
|
b.cardBubble.setOnClickListener { showDetail(b.root, m) }
|
||||||
|
b.cardBubble.setOnLongClickListener { onLongPress(m, b.cardBubble); true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showDetail(anchor: View, m: ChatMessage) {
|
||||||
|
val ctx = anchor.context
|
||||||
|
val details = buildString {
|
||||||
|
val sign = if (m.isSent) "-" else "+"
|
||||||
|
append("Amount\n$sign ${m.currency} ${"%.2f".format(kotlin.math.abs(m.amount))}\n\n")
|
||||||
|
append("Date\n${AccountHistoryAdapter.formatFullDate(m.date)}\n\n")
|
||||||
|
if (m.note.isNotBlank()) append("Remarks\n${m.note}\n\n")
|
||||||
|
if (m.reference.isNotBlank()) append("Reference\n${m.reference}\n\n")
|
||||||
|
append("Account\n${m.accountDisplayName}\n\n")
|
||||||
|
append("Status\n${ctx.getString(if (m.isBooked) R.string.chat_tick_booked else R.string.chat_tick_seen)}")
|
||||||
|
}
|
||||||
|
MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(m.peerName)
|
||||||
|
.setMessage(details)
|
||||||
|
.setPositiveButton("OK", null)
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TYPE_DATE = 0
|
||||||
|
private const val TYPE_BUBBLE = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.text.format.DateUtils
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import com.google.android.material.color.MaterialColors
|
||||||
|
import com.google.android.material.shape.ShapeAppearanceModel
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.ItemChatBinding
|
||||||
|
import sh.sar.basedbank.util.ChatThread
|
||||||
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Telegram-style chat list: one row per person or business, latest transfer as the preview.
|
||||||
|
* Pinned chats come first; long-press selects a chat (for pinning).
|
||||||
|
*/
|
||||||
|
class ChatsAdapter(
|
||||||
|
private val onChatClick: (ChatThread) -> Unit,
|
||||||
|
private val onChatLongPress: (ChatThread) -> Unit
|
||||||
|
) : RecyclerView.Adapter<ChatsAdapter.ViewHolder>() {
|
||||||
|
|
||||||
|
private var allThreads: List<ChatThread> = emptyList()
|
||||||
|
private var displayed: List<ChatThread> = emptyList()
|
||||||
|
private var pinned: List<String> = emptyList()
|
||||||
|
private var searchQuery = ""
|
||||||
|
private var hideAmounts = false
|
||||||
|
|
||||||
|
/** Chat key shown as selected (long-pressed), or null. */
|
||||||
|
var selectedKey: String? = null
|
||||||
|
set(value) { field = value; notifyDataSetChanged() }
|
||||||
|
|
||||||
|
fun updateThreads(threads: List<ChatThread>, pinnedKeys: List<String>) {
|
||||||
|
allThreads = threads
|
||||||
|
pinned = pinnedKeys
|
||||||
|
applyFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSearch(query: String) {
|
||||||
|
searchQuery = query
|
||||||
|
applyFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setHideAmounts(hide: Boolean) {
|
||||||
|
if (hideAmounts == hide) return
|
||||||
|
hideAmounts = hide
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isPinned(key: String) = key in pinned
|
||||||
|
|
||||||
|
val isEmpty get() = displayed.isEmpty()
|
||||||
|
|
||||||
|
private fun applyFilter() {
|
||||||
|
val matching = if (searchQuery.isBlank()) allThreads else allThreads.filter {
|
||||||
|
it.peerName.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
it.realName.contains(searchQuery, ignoreCase = true) ||
|
||||||
|
it.peerAccount.contains(searchQuery) ||
|
||||||
|
it.messages.any { m -> m.note.contains(searchQuery, ignoreCase = true) }
|
||||||
|
}
|
||||||
|
// Pinned first, most recently pinned on top; the rest stay newest first.
|
||||||
|
val (pinnedThreads, others) = matching.partition { it.peerKey in pinned }
|
||||||
|
displayed = pinnedThreads.sortedBy { pinned.indexOf(it.peerKey) } + others
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||||
|
val holder = ViewHolder(ItemChatBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||||
|
holder.binding.root.setOnClickListener {
|
||||||
|
val pos = holder.bindingAdapterPosition
|
||||||
|
if (pos != RecyclerView.NO_POSITION) onChatClick(displayed[pos])
|
||||||
|
}
|
||||||
|
holder.binding.root.setOnLongClickListener {
|
||||||
|
val pos = holder.bindingAdapterPosition
|
||||||
|
if (pos != RecyclerView.NO_POSITION) onChatLongPress(displayed[pos])
|
||||||
|
pos != RecyclerView.NO_POSITION
|
||||||
|
}
|
||||||
|
return holder
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(displayed[position])
|
||||||
|
|
||||||
|
override fun getItemCount() = displayed.size
|
||||||
|
|
||||||
|
inner class ViewHolder(val binding: ItemChatBinding) : RecyclerView.ViewHolder(binding.root) {
|
||||||
|
fun bind(thread: ChatThread) {
|
||||||
|
val ctx = binding.root.context
|
||||||
|
val night = (ctx.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
val last = thread.last
|
||||||
|
binding.tvName.text = thread.peerName
|
||||||
|
|
||||||
|
val amount = if (hideAmounts) "${last.currency} ••••••"
|
||||||
|
else "${last.currency} ${"%.2f".format(kotlin.math.abs(last.amount))}"
|
||||||
|
val ticks = if (last.isBooked) " ✓✓" else " ✓"
|
||||||
|
binding.tvPreview.text = when {
|
||||||
|
last.merchant -> ctx.getString(R.string.chat_preview_paid, amount) + ticks
|
||||||
|
last.isSent -> {
|
||||||
|
val note = last.note.takeIf { it.isNotBlank() }?.let { " · $it" }.orEmpty()
|
||||||
|
ctx.getString(R.string.chat_preview_sent, amount) + note + ticks
|
||||||
|
}
|
||||||
|
else -> ctx.getString(R.string.chat_preview_received, amount)
|
||||||
|
}
|
||||||
|
binding.tvPreview.setTextColor(
|
||||||
|
if (!last.isSent) Color.parseColor(if (night) "#81C784" else "#2E7D32")
|
||||||
|
else MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorOnSurfaceVariant)
|
||||||
|
)
|
||||||
|
|
||||||
|
binding.tvTime.text = if (DateUtils.isToday(last.timeMillis)) AccountHistoryAdapter.formatTime(last.date)
|
||||||
|
else AccountHistoryAdapter.formatDateOnly(last.date)
|
||||||
|
|
||||||
|
val sizePx = (52 * ctx.resources.displayMetrics.density).toInt()
|
||||||
|
if (thread.isMerchant) {
|
||||||
|
// Businesses get a rounded-square shop icon; people keep round initials or a photo.
|
||||||
|
binding.ivAvatar.shapeAppearanceModel = ShapeAppearanceModel.builder()
|
||||||
|
.setAllCornerSizes(14 * ctx.resources.displayMetrics.density).build()
|
||||||
|
binding.ivAvatar.setImageBitmap(shopBitmap(binding.root, merchantColor(thread.peerKey), sizePx))
|
||||||
|
} else {
|
||||||
|
binding.ivAvatar.shapeAppearanceModel = ShapeAppearanceModel.builder().setAllCornerSizes(sizePx / 2f).build()
|
||||||
|
val photo = thread.contact?.customerImgHash?.let { ContactImageCache.load(ctx, it) }
|
||||||
|
binding.ivAvatar.setImageBitmap(photo ?: contactInitialsBitmap(thread.peerName, avatarColor(thread.peerKey), sizePx))
|
||||||
|
}
|
||||||
|
|
||||||
|
val isPinned = thread.peerKey in pinned
|
||||||
|
binding.ivPin.visibility = if (isPinned) View.VISIBLE else View.GONE
|
||||||
|
// Selected (long-pressed): tinted; pinned: a soft surface; otherwise the normal ripple.
|
||||||
|
when {
|
||||||
|
thread.peerKey == selectedKey -> binding.root.setBackgroundColor(
|
||||||
|
MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorErrorContainer))
|
||||||
|
isPinned -> binding.root.setBackgroundColor(
|
||||||
|
MaterialColors.getColor(binding.root, com.google.android.material.R.attr.colorSurfaceContainerHigh))
|
||||||
|
else -> binding.root.setBackgroundResource(selectableBackground(binding.root))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun selectableBackground(view: View): Int {
|
||||||
|
val out = android.util.TypedValue()
|
||||||
|
view.context.theme.resolveAttribute(android.R.attr.selectableItemBackground, out, true)
|
||||||
|
return out.resourceId
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val AVATAR_COLORS = listOf("#E8B04B", "#5C9CE6", "#E57373", "#66BB6A", "#9575CD", "#4DB6AC", "#F06292", "#FF8A65")
|
||||||
|
private val MERCHANT_COLORS = listOf("#6D4C41", "#546E7A", "#5D4037", "#455A64", "#8D6E63", "#37474F")
|
||||||
|
|
||||||
|
/** Stable per-person avatar colour, so a chat keeps its colour between visits. */
|
||||||
|
fun avatarColor(peerKey: String) = AVATAR_COLORS[(peerKey.hashCode() and 0x7fffffff) % AVATAR_COLORS.size]
|
||||||
|
|
||||||
|
fun merchantColor(peerKey: String) = MERCHANT_COLORS[(peerKey.hashCode() and 0x7fffffff) % MERCHANT_COLORS.size]
|
||||||
|
|
||||||
|
/** Shop icon on a coloured square; the image view's shape rounds the corners. */
|
||||||
|
fun shopBitmap(view: View, colorHex: String, sizePx: Int): Bitmap {
|
||||||
|
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bm)
|
||||||
|
canvas.drawRect(RectF(0f, 0f, sizePx.toFloat(), sizePx.toFloat()),
|
||||||
|
Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor(colorHex) })
|
||||||
|
val icon = ContextCompat.getDrawable(view.context, R.drawable.ic_store)?.mutate() ?: return bm
|
||||||
|
val inset = sizePx / 4
|
||||||
|
icon.setBounds(inset, inset, sizePx - inset, sizePx - inset)
|
||||||
|
icon.draw(canvas)
|
||||||
|
return bm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.Menu
|
||||||
|
import android.view.MenuInflater
|
||||||
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import androidx.appcompat.view.ActionMode
|
||||||
|
import androidx.core.view.MenuProvider
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.widget.addTextChangedListener
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.activityViewModels
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
|
import com.google.android.material.snackbar.Snackbar
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.BasedBankApp
|
||||||
|
import sh.sar.basedbank.R
|
||||||
|
import sh.sar.basedbank.databinding.FragmentChatsBinding
|
||||||
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
|
import sh.sar.basedbank.util.ChatStore
|
||||||
|
import sh.sar.basedbank.util.ChatThread
|
||||||
|
import java.text.DateFormat
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfers grouped by person or business, shown as a chat list. Contacts is reachable from the
|
||||||
|
* toolbar; long-press a chat to pin it to the top.
|
||||||
|
*/
|
||||||
|
class ChatsFragment : Fragment() {
|
||||||
|
|
||||||
|
private var _binding: FragmentChatsBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
private val app get() = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
|
private lateinit var adapter: ChatsAdapter
|
||||||
|
|
||||||
|
private val scanLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
|
onQrScanned(raw)
|
||||||
|
}
|
||||||
|
private var actionMode: ActionMode? = null
|
||||||
|
private var syncedThisView = false
|
||||||
|
private var syncedWithContacts = false
|
||||||
|
|
||||||
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
|
_binding = FragmentChatsBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
|
adapter = ChatsAdapter(
|
||||||
|
onChatClick = { thread ->
|
||||||
|
if (actionMode != null) actionMode?.finish()
|
||||||
|
else (requireActivity() as HomeActivity).showWithBackStack(ChatFragment.newInstance(thread.peerKey))
|
||||||
|
},
|
||||||
|
onChatLongPress = { thread -> startSelection(thread) }
|
||||||
|
)
|
||||||
|
binding.rvChats.layoutManager = LinearLayoutManager(requireContext())
|
||||||
|
binding.rvChats.adapter = adapter
|
||||||
|
|
||||||
|
binding.etSearch.addTextChangedListener { text ->
|
||||||
|
adapter.setSearch(text?.toString() ?: "")
|
||||||
|
updateEmptyView()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.swipeRefresh.setOnRefreshListener { sync() }
|
||||||
|
|
||||||
|
// Scan to Pay: opens the QR scanner; after paying it comes back here and refreshes.
|
||||||
|
binding.fabScanPay.setOnClickListener {
|
||||||
|
scanLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
|
}
|
||||||
|
parentFragmentManager.setFragmentResultListener(TransferFragment.RESULT_TRANSFER_DONE, viewLifecycleOwner) { _, _ ->
|
||||||
|
sync()
|
||||||
|
// BML's alert often lands a few seconds after the payment: check again shortly.
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
delay(RECHECK_AFTER_PAYMENT_MS)
|
||||||
|
if (_binding != null) sync()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep the button above the system navigation bar when there's no bottom bar.
|
||||||
|
val fabMargin = (16 * resources.displayMetrics.density).toInt()
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(binding.fabScanPay) { v, insets ->
|
||||||
|
val bottomNav = NavCustomization.getNavMode(
|
||||||
|
requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE)
|
||||||
|
) == NavCustomization.NAV_MODE_BOTTOM
|
||||||
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
|
||||||
|
(v.layoutParams as android.widget.FrameLayout.LayoutParams).bottomMargin =
|
||||||
|
fabMargin + if (bottomNav) 0 else navBar
|
||||||
|
v.requestLayout()
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
|
||||||
|
requireActivity().addMenuProvider(object : MenuProvider {
|
||||||
|
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
|
||||||
|
menu.add(Menu.NONE, R.id.action_open_contacts, 0, R.string.nav_contacts)
|
||||||
|
.setIcon(R.drawable.ic_contacts)
|
||||||
|
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||||
|
if (menuItem.itemId != R.id.action_open_contacts) return false
|
||||||
|
(requireActivity() as HomeActivity).showWithBackStack(ContactsFragment())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}, viewLifecycleOwner, Lifecycle.State.RESUMED)
|
||||||
|
|
||||||
|
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||||
|
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||||
|
|
||||||
|
// Contacts give chats a saved name and an account number to send to.
|
||||||
|
(activity as? HomeActivity)?.loadAllContacts()
|
||||||
|
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
|
||||||
|
loadThreads()
|
||||||
|
// Contacts usually load after the first sync; sync again so their real names get looked up.
|
||||||
|
if (syncedThisView && !syncedWithContacts && contacts.isNotEmpty()) sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accounts arrive after login; sync once they are available.
|
||||||
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
|
if (!syncedThisView && accounts.any { it.bank == "BML" }) sync()
|
||||||
|
}
|
||||||
|
|
||||||
|
loadThreads()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sync() {
|
||||||
|
syncedThisView = true
|
||||||
|
binding.swipeRefresh.isRefreshing = true
|
||||||
|
val accounts = viewModel.accounts.value ?: emptyList()
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
if (contacts.isNotEmpty()) syncedWithContacts = true
|
||||||
|
val ctx = requireContext().applicationContext
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
withContext(Dispatchers.IO) { ChatStore.sync(ctx, app, accounts, contacts) }
|
||||||
|
_binding?.swipeRefresh?.isRefreshing = false
|
||||||
|
loadThreads()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadThreads() {
|
||||||
|
val ctx = requireContext().applicationContext
|
||||||
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val (threads, pins) = withContext(Dispatchers.IO) { ChatStore.threads(ctx, contacts) to ChatStore.pins(ctx) }
|
||||||
|
if (_binding == null) return@launch
|
||||||
|
adapter.updateThreads(threads, pins)
|
||||||
|
updateEmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A scanned QR: BML Scan to Pay (static or card-machine) opens the payment, a PayMV QR opens a
|
||||||
|
* transfer. Either way, success comes back to this list, which then refreshes.
|
||||||
|
*/
|
||||||
|
private fun onQrScanned(raw: String) {
|
||||||
|
val activity = requireActivity() as HomeActivity
|
||||||
|
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||||
|
if (bmlTarget != null) {
|
||||||
|
activity.showWithBackStack(TransferFragment.newInstanceFromBmlQr(bmlTarget, null, returnOnSuccess = true))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val qr = PaymvQrParser.parse(raw)
|
||||||
|
val account = qr?.accountNumber
|
||||||
|
if (account == null) {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activity.showWithBackStack(TransferFragment.newInstanceFromQr(
|
||||||
|
accountNumber = account,
|
||||||
|
displayName = qr.merchantName ?: account,
|
||||||
|
amount = qr.amount,
|
||||||
|
remarks = qr.purpose,
|
||||||
|
returnOnSuccess = true
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Long-press: select the chat and offer Pin / Unpin in the top bar. */
|
||||||
|
private fun startSelection(thread: ChatThread) {
|
||||||
|
adapter.selectedKey = thread.peerKey
|
||||||
|
val pinned = adapter.isPinned(thread.peerKey)
|
||||||
|
val callback = object : ActionMode.Callback {
|
||||||
|
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
|
||||||
|
mode.title = getString(R.string.chat_selected_one)
|
||||||
|
menu.add(Menu.NONE, R.id.action_pin_chat, 0, if (pinned) R.string.chat_unpin else R.string.chat_pin)
|
||||||
|
.setIcon(R.drawable.ic_pin)
|
||||||
|
.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS or MenuItem.SHOW_AS_ACTION_WITH_TEXT)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
override fun onPrepareActionMode(mode: ActionMode, menu: Menu) = false
|
||||||
|
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
|
||||||
|
if (item.itemId != R.id.action_pin_chat) return false
|
||||||
|
setPinned(thread, !pinned, offerUndo = true)
|
||||||
|
mode.finish()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
override fun onDestroyActionMode(mode: ActionMode) {
|
||||||
|
actionMode = null
|
||||||
|
adapter.selectedKey = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actionMode?.finish()
|
||||||
|
actionMode = (requireActivity() as AppCompatActivity).startSupportActionMode(callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setPinned(thread: ChatThread, pin: Boolean, offerUndo: Boolean) {
|
||||||
|
val ctx = requireContext().applicationContext
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
withContext(Dispatchers.IO) { ChatStore.setPinned(ctx, thread.peerKey, pin) }
|
||||||
|
loadThreads()
|
||||||
|
val b = _binding ?: return@launch
|
||||||
|
if (!offerUndo) return@launch
|
||||||
|
Snackbar.make(b.root, getString(if (pin) R.string.chat_pinned_msg else R.string.chat_unpinned_msg, thread.peerName), Snackbar.LENGTH_LONG)
|
||||||
|
.setAction(R.string.chat_undo) { setPinned(thread, !pin, offerUndo = false) }
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateEmptyView() {
|
||||||
|
val b = _binding ?: return
|
||||||
|
b.emptyView.visibility = if (adapter.isEmpty) View.VISIBLE else View.GONE
|
||||||
|
if (!adapter.isEmpty) return
|
||||||
|
val since = ChatStore.sinceMillis(requireContext())
|
||||||
|
b.emptyView.text = when {
|
||||||
|
b.etSearch.text?.isNotBlank() == true -> getString(R.string.chats_no_results)
|
||||||
|
since > 0L -> getString(R.string.chats_empty_since,
|
||||||
|
DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(Date(since)))
|
||||||
|
else -> getString(R.string.chats_empty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
requireActivity().title = getString(R.string.nav_chats)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroyView() {
|
||||||
|
actionMode?.finish()
|
||||||
|
super.onDestroyView()
|
||||||
|
_binding = null
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val RECHECK_AFTER_PAYMENT_MS = 10_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 contact is tapped in [ContactsFragment] or a chat header. */
|
||||||
|
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? ContactSheetHost
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>) {
|
||||||
@@ -168,6 +168,14 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
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)
|
||||||
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 +199,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 +223,15 @@ 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
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
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 +262,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 +297,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 +328,7 @@ 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)
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
return items
|
return items
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import sh.sar.basedbank.util.ContactDisplay
|
||||||
|
|
||||||
|
/** A screen that can show [ContactDetailsSheetFragment] and handle its Transfer/Delete buttons. */
|
||||||
|
interface ContactSheetHost {
|
||||||
|
fun openTransfer(contact: ContactDisplay)
|
||||||
|
fun confirmDelete(contact: ContactDisplay)
|
||||||
|
}
|
||||||
@@ -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,37 +103,33 @@ 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 {
|
||||||
|
val sizePx = binding.ivContactPhoto.context.resources
|
||||||
|
.getDimensionPixelSize(android.R.dimen.app_icon_size)
|
||||||
|
.coerceAtLeast(96)
|
||||||
binding.ivContactPhoto.setImageBitmap(
|
binding.ivContactPhoto.setImageBitmap(
|
||||||
makeInitialsBitmap(contact.name, contact.bankColor)
|
contactInitialsBitmap(contact.name, contact.bankColor, sizePx)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
|
|
||||||
val sizePx = binding.ivContactPhoto.context.resources
|
|
||||||
.getDimensionPixelSize(android.R.dimen.app_icon_size)
|
|
||||||
.coerceAtLeast(96)
|
|
||||||
val bgColor = try { Color.parseColor(colorHex) } catch (e: Exception) { Color.GRAY }
|
|
||||||
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
|
||||||
val canvas = Canvas(bm)
|
|
||||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
|
||||||
paint.color = bgColor
|
|
||||||
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
|
||||||
paint.color = Color.WHITE
|
|
||||||
paint.textSize = sizePx * 0.42f
|
|
||||||
paint.textAlign = Paint.Align.CENTER
|
|
||||||
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
|
||||||
val metrics = paint.fontMetrics
|
|
||||||
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
|
||||||
return bm
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal fun contactInitialsBitmap(name: String, colorHex: String, sizePx: Int): Bitmap {
|
||||||
|
val bgColor = try { Color.parseColor(colorHex) } catch (e: Exception) { Color.GRAY }
|
||||||
|
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(bm)
|
||||||
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
paint.color = bgColor
|
||||||
|
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
||||||
|
paint.color = Color.WHITE
|
||||||
|
paint.textSize = sizePx * 0.42f
|
||||||
|
paint.textAlign = Paint.Align.CENTER
|
||||||
|
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||||
|
val metrics = paint.fontMetrics
|
||||||
|
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
||||||
|
return bm
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -33,7 +40,7 @@ import sh.sar.basedbank.util.ContactManager
|
|||||||
import sh.sar.basedbank.util.ContactsCache
|
import sh.sar.basedbank.util.ContactsCache
|
||||||
import sh.sar.basedbank.util.TransferNetwork
|
import sh.sar.basedbank.util.TransferNetwork
|
||||||
|
|
||||||
class ContactsFragment : Fragment() {
|
class ContactsFragment : Fragment(), ContactSheetHost {
|
||||||
|
|
||||||
private var _binding: FragmentContactsBinding? = null
|
private var _binding: FragmentContactsBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
@@ -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,7 +177,12 @@ 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun openTransfer(contact: ContactDisplay) {
|
||||||
val fragment = TransferFragment.newInstance(
|
val fragment = TransferFragment.newInstance(
|
||||||
accountNumber = contact.accountNumber,
|
accountNumber = contact.accountNumber,
|
||||||
displayName = contact.name,
|
displayName = contact.name,
|
||||||
@@ -178,13 +190,29 @@ class ContactsFragment : Fragment() {
|
|||||||
colorHex = contact.bankColor,
|
colorHex = contact.bankColor,
|
||||||
imageHash = contact.imageHash
|
imageHash = contact.imageHash
|
||||||
)
|
)
|
||||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
|
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun confirmDelete(contact: ContactDisplay) {
|
override 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
|
||||||
@@ -25,6 +25,9 @@ import sh.sar.basedbank.api.models.BankAccount
|
|||||||
import sh.sar.basedbank.api.mib.MibCard
|
import sh.sar.basedbank.api.mib.MibCard
|
||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||||
|
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
|
||||||
@@ -34,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 {
|
} else {
|
||||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
val qr = PaymvQrParser.parse(raw)
|
||||||
|
if (qr?.accountNumber != null) {
|
||||||
|
Toast.makeText(requireContext(), R.string.card_qr_paymv_unsupported, Toast.LENGTH_SHORT).show()
|
||||||
|
val defaultFrom = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||||
|
(requireActivity() as HomeActivity).navigateTo(
|
||||||
|
R.id.nav_transfer, TransferFragment.newInstanceFromQr(
|
||||||
|
accountNumber = qr.accountNumber,
|
||||||
|
displayName = qr.merchantName ?: qr.accountNumber,
|
||||||
|
amount = qr.amount,
|
||||||
|
remarks = qr.purpose,
|
||||||
|
fromAccountNumber = defaultFrom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pendingQrAccountNumber = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
@@ -57,14 +74,24 @@ class DashboardFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
|
viewModel.accounts.observe(viewLifecycleOwner) {
|
||||||
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances() }
|
updateBalances(it)
|
||||||
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) { updatePendingFinances() }
|
updateAttentionRow()
|
||||||
|
}
|
||||||
|
viewModel.financing.observe(viewLifecycleOwner) {
|
||||||
|
updatePendingFinances()
|
||||||
|
updateAttentionRow()
|
||||||
|
}
|
||||||
|
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) {
|
||||||
|
updatePendingFinances()
|
||||||
|
updateAttentionRow()
|
||||||
|
}
|
||||||
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
|
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
|
||||||
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
||||||
updateBalances(viewModel.accounts.value ?: emptyList())
|
updateBalances(viewModel.accounts.value ?: emptyList())
|
||||||
updatePendingFinances()
|
updatePendingFinances()
|
||||||
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
|
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
|
||||||
|
updateAttentionRow()
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.swipeRefresh.setOnRefreshListener {
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
@@ -73,7 +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 {
|
||||||
|
(activity as? HomeActivity)?.showWithBackStackAndNav(FinancingFragment(), R.id.nav_finances)
|
||||||
}
|
}
|
||||||
|
|
||||||
val cardAdapter = DashboardCardAdapter()
|
val cardAdapter = DashboardCardAdapter()
|
||||||
@@ -82,20 +113,30 @@ 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()
|
||||||
cardAdapter.update(all)
|
val all = (bmlItems + mibItems).sortedBy { rank(it.loginTag) }
|
||||||
binding.sectionCards.visibility = if (all.isNotEmpty()) View.VISIBLE else View.GONE
|
val defaultNum = credStore.getDefaultCardAccountNumber()
|
||||||
|
val ordered = if (defaultNum != null) {
|
||||||
|
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||||
|
if (def != null) listOf(def) + all.filter { it !== def } else all
|
||||||
|
} else all
|
||||||
|
cardAdapter.update(ordered)
|
||||||
|
binding.sectionCards.visibility = if (ordered.isNotEmpty()) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
|
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
|
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
|
||||||
|
|
||||||
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
|
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
|
||||||
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)
|
||||||
@@ -106,12 +147,37 @@ class DashboardFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
requireActivity().title = getString(R.string.nav_dashboard)
|
val isBottom = NavCustomization.getNavMode(requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)) == NavCustomization.NAV_MODE_BOTTOM
|
||||||
|
if (isBottom) {
|
||||||
|
requireActivity().title = getString(R.string.app_name)
|
||||||
|
val size = (28 * resources.displayMetrics.density).toInt()
|
||||||
|
val gap = (8 * resources.displayMetrics.density).toInt()
|
||||||
|
val icon = requireContext().packageManager.getApplicationIcon(requireContext().packageName)
|
||||||
|
val bmp = android.graphics.Bitmap.createBitmap(size + gap, size, android.graphics.Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = android.graphics.Canvas(bmp)
|
||||||
|
icon.setBounds(0, 0, size, size)
|
||||||
|
icon.draw(canvas)
|
||||||
|
requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar).logo =
|
||||||
|
android.graphics.drawable.BitmapDrawable(resources, bmp)
|
||||||
|
} else {
|
||||||
|
requireActivity().title = getString(R.string.nav_dashboard)
|
||||||
|
}
|
||||||
refreshQuickActions()
|
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 = NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM
|
||||||
|
if (isBottom) {
|
||||||
|
binding.buttonBar.visibility = View.GONE
|
||||||
|
return
|
||||||
|
}
|
||||||
|
binding.buttonBar.visibility = View.VISIBLE
|
||||||
val ids = NavCustomization.getQuickActions(prefs)
|
val ids = NavCustomization.getQuickActions(prefs)
|
||||||
listOf(binding.btnQuickAction1, binding.btnQuickAction2).forEachIndexed { i, btn ->
|
listOf(binding.btnQuickAction1, binding.btnQuickAction2).forEachIndexed { i, btn ->
|
||||||
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == ids[i] }
|
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == ids[i] }
|
||||||
@@ -244,6 +310,52 @@ class DashboardFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun updateAttentionRow() {
|
||||||
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
|
val accounts = viewModel.accounts.value ?: emptyList()
|
||||||
|
|
||||||
|
// Blocked: sum across CASA-style accounts (exclude cards and loans) per currency.
|
||||||
|
val blockedByCurrency = accounts
|
||||||
|
.filter { it.profileType != "BML_CREDIT" && it.profileType != "BML_PREPAID" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" }
|
||||||
|
.mapNotNull { acc ->
|
||||||
|
val v = acc.blockedAmount.replace(",", "").toDoubleOrNull() ?: 0.0
|
||||||
|
if (v > 0.0) acc.currencyName.uppercase() to v else null
|
||||||
|
}
|
||||||
|
.groupBy({ it.first }, { it.second })
|
||||||
|
.mapValues { (_, vs) -> vs.sum() }
|
||||||
|
|
||||||
|
val blockedMvr = blockedByCurrency["MVR"] ?: 0.0
|
||||||
|
val blockedUsd = blockedByCurrency["USD"] ?: 0.0
|
||||||
|
val blockedTotal = blockedByCurrency.values.sum()
|
||||||
|
|
||||||
|
if (blockedMvr > 0.0) {
|
||||||
|
binding.tvBlockedMvr.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(blockedMvr)
|
||||||
|
binding.cardBlockedMvr.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
binding.cardBlockedMvr.visibility = View.GONE
|
||||||
|
}
|
||||||
|
if (blockedUsd > 0.0) {
|
||||||
|
binding.tvBlockedUsd.text = if (hide) "USD ••••••" else "USD %,.2f".format(blockedUsd)
|
||||||
|
binding.cardBlockedUsd.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
binding.cardBlockedUsd.visibility = View.GONE
|
||||||
|
}
|
||||||
|
binding.rowBlocked.visibility = if (blockedTotal > 0.0) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
|
// Overdue: MIB finance deals + BML loan details (assumed MVR — matches existing Pending Finances).
|
||||||
|
val mibOverdue = (viewModel.financing.value ?: emptyList()).sumOf { it.overdueAmount }
|
||||||
|
val bmlOverdue = (viewModel.bmlLoanDetails.value ?: emptyMap()).values.sumOf { it.overdueAmount }
|
||||||
|
val overdueTotal = mibOverdue + bmlOverdue
|
||||||
|
if (overdueTotal > 0.0) {
|
||||||
|
binding.tvOverdueTotal.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(overdueTotal)
|
||||||
|
binding.cardOverdue.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
binding.cardOverdue.visibility = View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.rowAttention.visibility = if (overdueTotal > 0.0) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
private fun updatePendingFinances() {
|
private fun updatePendingFinances() {
|
||||||
val hide = viewModel.hideAmounts.value ?: false
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
val mibTotal = (viewModel.financing.value ?: emptyList()).sumOf { it.outstandingAmount }
|
val mibTotal = (viewModel.financing.value ?: emptyList()).sumOf { it.outstandingAmount }
|
||||||
@@ -287,17 +399,17 @@ class DashboardFragment : Fragment() {
|
|||||||
when (item) {
|
when (item) {
|
||||||
is CardItem.Mib -> {
|
is CardItem.Mib -> {
|
||||||
tvCardOwner.text = item.card.cardHolderName
|
tvCardOwner.text = item.card.cardHolderName
|
||||||
tvCardNumber.text = PayWithCardFragment.formatMasked(item.card.maskedCardNumber)
|
tvCardNumber.text = CardsFragment.formatMasked(item.card.maskedCardNumber)
|
||||||
val assetPath = PayWithCardFragment.cardImageAsset(item.card)
|
val assetPath = CardsFragment.cardImageAsset(item.card)
|
||||||
if (assetPath != null) PayWithCardFragment.loadCardImage(ivCardImage, assetPath)
|
if (assetPath != null) CardsFragment.loadCardImage(ivCardImage, assetPath)
|
||||||
else ivCardImage.setImageDrawable(null)
|
else ivCardImage.setImageDrawable(null)
|
||||||
PayWithCardFragment.bindCardStatus(tvCardStatus, PayWithCardFragment.mibCardStatusLabel(item.card.cardStatus))
|
CardsFragment.bindCardStatus(tvCardStatus, CardsFragment.mibCardStatusLabel(item.card.cardStatus))
|
||||||
}
|
}
|
||||||
is CardItem.Bml -> {
|
is CardItem.Bml -> {
|
||||||
tvCardOwner.text = item.account.accountBriefName
|
tvCardOwner.text = item.account.accountBriefName
|
||||||
tvCardNumber.text = PayWithCardFragment.formatMasked(item.account.accountNumber)
|
tvCardNumber.text = CardsFragment.formatMasked(item.account.accountNumber)
|
||||||
PayWithCardFragment.loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
|
CardsFragment.loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
|
||||||
PayWithCardFragment.bindCardStatus(tvCardStatus, null) // only Active BML cards reach dashboard
|
CardsFragment.bindCardStatus(tvCardStatus, null) // only Active BML cards reach dashboard
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val isMib = item is CardItem.Mib
|
val isMib = item is CardItem.Mib
|
||||||
@@ -305,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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -313,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
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import sh.sar.basedbank.util.CredentialStore
|
|||||||
import sh.sar.basedbank.util.CardsCache
|
import sh.sar.basedbank.util.CardsCache
|
||||||
import sh.sar.basedbank.util.FinancingCache
|
import sh.sar.basedbank.util.FinancingCache
|
||||||
import sh.sar.basedbank.util.ForeignLimitsCache
|
import sh.sar.basedbank.util.ForeignLimitsCache
|
||||||
|
import sh.sar.basedbank.util.ThemeHelper
|
||||||
|
|
||||||
class HomeActivity : AppCompatActivity() {
|
class HomeActivity : AppCompatActivity() {
|
||||||
|
|
||||||
@@ -75,6 +76,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())
|
||||||
@@ -88,6 +91,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
|
||||||
@@ -97,6 +104,21 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
if (securitySet) lock()
|
if (securitySet) lock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
@@ -106,6 +128,7 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
ThemeHelper.applyAccent(this)
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
binding = ActivityHomeBinding.inflate(layoutInflater)
|
binding = ActivityHomeBinding.inflate(layoutInflater)
|
||||||
@@ -118,6 +141,21 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
isAppearanceLightStatusBars = isLight
|
isAppearanceLightStatusBars = isLight
|
||||||
isAppearanceLightNavigationBars = isLight
|
isAppearanceLightNavigationBars = isLight
|
||||||
}
|
}
|
||||||
|
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
|
||||||
|
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
|
||||||
|
ta.recycle()
|
||||||
|
// Auth guard: HomeActivity must only be reachable after LockActivity or fresh login.
|
||||||
|
// Using loadSecurityHash() (EncryptedSharedPreferences) instead of plain prefs so
|
||||||
|
// a rooted device cannot bypass this by editing security_method in plain prefs.
|
||||||
|
val app = application as BasedBankApp
|
||||||
|
if (CredentialStore(this).loadSecurityHash() != null && !app.isUnlocked) {
|
||||||
|
startActivity(
|
||||||
|
android.content.Intent(this, sh.sar.basedbank.LockActivity::class.java)
|
||||||
|
)
|
||||||
|
finish()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setSupportActionBar(binding.toolbar)
|
setSupportActionBar(binding.toolbar)
|
||||||
|
|
||||||
toggle = ActionBarDrawerToggle(
|
toggle = ActionBarDrawerToggle(
|
||||||
@@ -138,8 +176,9 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
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_chats -> ChatsFragment()
|
||||||
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()
|
||||||
@@ -147,8 +186,7 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
R.id.nav_finances -> FinancingFragment()
|
R.id.nav_finances -> FinancingFragment()
|
||||||
R.id.nav_otp -> OtpFragment()
|
R.id.nav_otp -> OtpFragment()
|
||||||
R.id.nav_settings -> SettingsFragment()
|
R.id.nav_settings -> SettingsFragment()
|
||||||
R.id.nav_pay_with_card -> PayWithCardFragment()
|
R.id.nav_pay_with_card -> CardsFragment()
|
||||||
R.id.nav_card_settings -> CardSettingsFragment()
|
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
if (frag != null) show(frag)
|
if (frag != null) show(frag)
|
||||||
@@ -165,10 +203,9 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load data
|
// Load data
|
||||||
val app = application as BasedBankApp
|
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty() || app.mfaisaAccounts.isNotEmpty()) {
|
||||||
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
|
|
||||||
// Came from fresh manual login — accounts ready, rest fetched in background
|
// Came from fresh manual login — accounts ready, rest fetched in background
|
||||||
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts
|
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts + 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()) {
|
||||||
@@ -179,6 +216,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
|
||||||
@@ -202,10 +243,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)
|
||||||
@@ -218,10 +260,36 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
viewModel.hideAmounts.value = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_amounts", false)
|
viewModel.hideAmounts.value = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_amounts", false)
|
||||||
|
|
||||||
// Show dashboard on first create
|
// Show dashboard on first create, or navigate to shortcut destination
|
||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
show(DashboardFragment())
|
val navDest = intent.getIntExtra("nav_destination", -1)
|
||||||
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
val autoScan = intent.getBooleanExtra("auto_scan", false)
|
||||||
|
val autoTapMode = intent.getBooleanExtra("auto_tap_mode", false)
|
||||||
|
val shareQrText = intent.getStringExtra("share_qr_text")
|
||||||
|
when {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val initPrefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
|
if (NavCustomization.getNavMode(initPrefs) == NavCustomization.NAV_MODE_CIRCULAR) {
|
||||||
|
show(CircularNavFragment())
|
||||||
|
} else {
|
||||||
|
show(DashboardFragment())
|
||||||
|
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||||
@@ -231,15 +299,43 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
binding.drawerLayout.closeDrawers()
|
binding.drawerLayout.closeDrawers()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Let CardsFragment handle back if in manage mode
|
||||||
|
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
|
||||||
|
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) {
|
|
||||||
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
|
|
||||||
// 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())
|
||||||
@@ -295,21 +391,44 @@ 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 {
|
}
|
||||||
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
|
NavCustomization.NAV_MODE_CIRCULAR -> {
|
||||||
toggle.isDrawerIndicatorEnabled = true
|
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
||||||
toggle.syncState()
|
toggle.isDrawerIndicatorEnabled = false
|
||||||
binding.bottomNavigation.visibility = View.GONE
|
supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
||||||
|
binding.bottomNavigation.visibility = View.GONE
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
supportActionBar?.show()
|
||||||
|
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
|
||||||
|
toggle.isDrawerIndicatorEnabled = true
|
||||||
|
toggle.syncState()
|
||||||
|
binding.bottomNavigation.visibility = View.GONE
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,38 +467,33 @@ 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_chats -> ChatsFragment()
|
||||||
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()
|
||||||
R.id.nav_finances -> FinancingFragment()
|
R.id.nav_finances -> FinancingFragment()
|
||||||
R.id.nav_otp -> OtpFragment()
|
R.id.nav_otp -> OtpFragment()
|
||||||
R.id.nav_settings -> SettingsFragment()
|
R.id.nav_settings -> SettingsFragment()
|
||||||
R.id.nav_pay_with_card -> PayWithCardFragment()
|
R.id.nav_pay_with_card -> CardsFragment()
|
||||||
R.id.nav_card_settings -> CardSettingsFragment()
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -408,6 +522,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.
|
||||||
@@ -416,6 +561,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
|
||||||
@@ -494,14 +644,23 @@ fun applyNavLabelVisibility() {
|
|||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||||
menuInflater.inflate(R.menu.toolbar_menu, menu)
|
menuInflater.inflate(R.menu.toolbar_menu, menu)
|
||||||
val eyeEnabled = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("hide_sensitive_info", false)
|
|
||||||
val eyeItem = menu.findItem(R.id.action_hide_amounts)
|
val eyeItem = menu.findItem(R.id.action_hide_amounts)
|
||||||
eyeItem?.isVisible = eyeEnabled
|
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
|
||||||
@@ -514,6 +673,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
|
||||||
@@ -527,23 +690,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)
|
||||||
@@ -562,7 +738,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()
|
||||||
|
|
||||||
@@ -603,47 +780,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)
|
||||||
@@ -736,17 +879,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" }
|
||||||
@@ -773,15 +957,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_")
|
||||||
@@ -793,6 +979,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -805,10 +996,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) {
|
||||||
@@ -968,14 +1289,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_")
|
||||||
@@ -1042,7 +1363,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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1058,7 +1379,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) { }
|
||||||
@@ -1067,7 +1388,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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -18,6 +32,7 @@ object NavCustomization {
|
|||||||
/** All items that can occupy either a bottom nav slot or the "More" screen. */
|
/** All items that can occupy either a bottom nav slot or the "More" screen. */
|
||||||
val ALL_SWAPPABLE = listOf(
|
val ALL_SWAPPABLE = listOf(
|
||||||
NavItemDef(R.id.nav_accounts, "nav_accounts", R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts),
|
NavItemDef(R.id.nav_accounts, "nav_accounts", R.drawable.ic_nav_accounts, R.string.nav_accounts, R.string.nav_desc_accounts),
|
||||||
|
NavItemDef(R.id.nav_chats, "nav_chats", R.drawable.ic_chat, R.string.nav_chats, R.string.nav_desc_chats),
|
||||||
NavItemDef(R.id.nav_contacts, "nav_contacts", R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts),
|
NavItemDef(R.id.nav_contacts, "nav_contacts", R.drawable.ic_contacts, R.string.nav_contacts, R.string.nav_desc_contacts),
|
||||||
NavItemDef(R.id.nav_transfer, "nav_transfer", R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer),
|
NavItemDef(R.id.nav_transfer, "nav_transfer", R.drawable.ic_send, R.string.transfer, R.string.nav_desc_transfer),
|
||||||
NavItemDef(R.id.nav_pay_mv_qr, "nav_pay_mv_qr", R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr),
|
NavItemDef(R.id.nav_pay_mv_qr, "nav_pay_mv_qr", R.drawable.ic_qr_scan, R.string.pay_mv_qr, R.string.nav_desc_pay_mv_qr),
|
||||||
@@ -25,7 +40,6 @@ object NavCustomization {
|
|||||||
NavItemDef(R.id.nav_transfer_history, "nav_transfer_history", R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history, R.string.nav_desc_transfer_history),
|
NavItemDef(R.id.nav_transfer_history, "nav_transfer_history", R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history, R.string.nav_desc_transfer_history),
|
||||||
NavItemDef(R.id.nav_finances, "nav_finances", R.drawable.ic_nav_finances, R.string.nav_finances, R.string.nav_desc_finances),
|
NavItemDef(R.id.nav_finances, "nav_finances", R.drawable.ic_nav_finances, R.string.nav_finances, R.string.nav_desc_finances),
|
||||||
NavItemDef(R.id.nav_pay_with_card, "nav_pay_with_card", R.drawable.ic_nav_card, R.string.nav_pay_with_card, R.string.nav_desc_pay_with_card),
|
NavItemDef(R.id.nav_pay_with_card, "nav_pay_with_card", R.drawable.ic_nav_card, R.string.nav_pay_with_card, R.string.nav_desc_pay_with_card),
|
||||||
NavItemDef(R.id.nav_card_settings, "nav_card_settings", R.drawable.ic_nav_card, R.string.nav_card_settings, R.string.nav_desc_card_settings),
|
|
||||||
NavItemDef(R.id.nav_otp, "nav_otp", R.drawable.ic_nav_otp, R.string.nav_otp, R.string.nav_desc_otp),
|
NavItemDef(R.id.nav_otp, "nav_otp", R.drawable.ic_nav_otp, R.string.nav_otp, R.string.nav_desc_otp),
|
||||||
NavItemDef(R.id.nav_settings, "nav_settings", R.drawable.ic_nav_settings, R.string.nav_settings, R.string.nav_desc_settings),
|
NavItemDef(R.id.nav_settings, "nav_settings", R.drawable.ic_nav_settings, R.string.nav_settings, R.string.nav_desc_settings),
|
||||||
)
|
)
|
||||||
@@ -38,14 +52,14 @@ object NavCustomization {
|
|||||||
|
|
||||||
fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
|
fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
|
||||||
keyToId(prefs.getString("bottom_nav_slot_1_key", null), R.id.nav_accounts),
|
keyToId(prefs.getString("bottom_nav_slot_1_key", null), R.id.nav_accounts),
|
||||||
keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_contacts),
|
keyToId(prefs.getString("bottom_nav_slot_2_key", null), R.id.nav_chats),
|
||||||
keyToId(prefs.getString("bottom_nav_slot_3_key", null), R.id.nav_transfer),
|
keyToId(prefs.getString("bottom_nav_slot_3_key", null), R.id.nav_transfer),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
|
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
|
||||||
prefs.edit()
|
prefs.edit()
|
||||||
.putString("bottom_nav_slot_1_key", idToKey(slots[0]) ?: "nav_accounts")
|
.putString("bottom_nav_slot_1_key", idToKey(slots[0]) ?: "nav_accounts")
|
||||||
.putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_contacts")
|
.putString("bottom_nav_slot_2_key", idToKey(slots[1]) ?: "nav_chats")
|
||||||
.putString("bottom_nav_slot_3_key", idToKey(slots[2]) ?: "nav_transfer")
|
.putString("bottom_nav_slot_3_key", idToKey(slots[2]) ?: "nav_transfer")
|
||||||
.apply()
|
.apply()
|
||||||
}
|
}
|
||||||
@@ -63,8 +77,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_chats),
|
||||||
|
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_chats")
|
||||||
|
.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 }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||