Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
0efe833e40
|
|||
|
f5f52829c7
|
|||
|
3db077cf9a
|
|||
|
ee5ecdaa18
|
|||
|
2df162c09e
|
|||
|
0f77216d2d
|
|||
|
71e893faf8
|
|||
|
1cd254c134
|
|||
|
87536a339b
|
|||
|
32d23a43b3
|
|||
|
846ce22245
|
|||
|
ed5b456e3b
|
|||
|
9b284cc8d4
|
|||
|
c0b58061c2
|
|||
|
978da26ff1
|
|||
|
7fe2ba5788
|
|||
|
26a0c7b81d
|
|||
|
83fc340e2b
|
|||
|
bfbb649b33
|
|||
|
b780091bb8
|
|||
|
e4468c4a8f
|
|||
|
b4e1f57347
|
|||
|
907757c893
|
|||
|
1ea0355ce6
|
|||
|
c9b8973b65
|
|||
|
7a0e32f4d6
|
|||
|
d68b8aaf0a
|
|||
|
396f778ad4
|
|||
|
dc0f1b96c1
|
|||
|
640dd5de22
|
|||
|
f0a0e7857c
|
|||
|
836f4c493a
|
|||
|
6325f4fd7a
|
|||
|
69aa172eff
|
|||
|
ed2054fb81
|
|||
|
e9583f0580
|
|||
|
a32841a319
|
|||
|
7a66dd836c
|
|||
|
68dd49b90c
|
|||
|
76090525e1
|
|||
|
f7fd06cdf3
|
|||
|
8d09e760a8
|
|||
|
62ccae602d
|
|||
|
9011ef2f5a
|
|||
|
dd620763ec
|
|||
|
86063d600f
|
|||
|
da85a31bc6
|
|||
|
d292e73fd9
|
|||
|
3d632606a0
|
|||
|
6daeb5f72e
|
|||
|
c4d3c1efd4
|
|||
|
0560c53ae3
|
|||
|
a37454de00
|
|||
|
daf9b0475a
|
|||
|
c4ad35e6b9
|
|||
|
3e8ea90701
|
|||
|
ef919aa179
|
|||
|
c98a3e3e89
|
|||
|
0654c711d6
|
|||
|
b67368c94a
|
|||
|
a6e7e61b58
|
|||
|
e974a95708
|
|||
|
de11fbe0d3
|
|||
|
5d8ab76477
|
|||
|
d637877167
|
|||
|
ea227bf3b9
|
|||
|
6b3131069e
|
|||
|
8037ce3f02
|
|||
|
cecf0bedfc
|
|||
|
256f216da4
|
|||
|
0a27de4a34
|
|||
|
a3f8852163
|
|||
|
8e345746ed
|
|||
|
473e051282
|
|||
|
f9c182fe9a
|
|||
|
339dae8a37
|
|||
|
a6a1f28144
|
|||
|
523d1248bd
|
|||
|
ee9f98b720
|
|||
|
219ca9bf00
|
|||
|
e9f0cec698
|
|||
|
268f3dada0
|
|||
|
e0a554c769
|
|||
|
94b280a177
|
|||
|
88c9f153e5
|
|||
|
eb7da01b2e
|
|||
|
27270f1b7a
|
|||
|
fd7fcb41a6
|
|||
|
c9ae614fc7
|
|||
|
b784085605
|
|||
|
01e5c17284
|
|||
|
6d3c7036b5
|
|||
|
804712d22d
|
|||
|
f208ee6ad1
|
|||
|
51dbed94d4
|
|||
|
0b5a452046
|
|||
|
00297da71e
|
|||
|
1602d061c1
|
|||
|
ddd64e8624
|
|||
|
77f367844d
|
|||
|
e2729b1d1a
|
@@ -1,11 +1,9 @@
|
||||
services:
|
||||
release:
|
||||
# image: git.shihaam.dev/dockerfiles/android-builder
|
||||
image: git.shihaam.dev/dockerfiles/runners/gradle
|
||||
hostname: isodroid
|
||||
network_mode: host
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./release:/release
|
||||
- ../../:/source
|
||||
# - /root/.cache/cache-runners/gradle:/root/.gradle
|
||||
- /root/.cache/cache-runners/gradle:/root/.gradle
|
||||
|
||||
@@ -87,3 +87,24 @@ jobs:
|
||||
--data-binary "@${ASSET_PATH}"
|
||||
|
||||
echo "Uploaded asset: $ASSET_NAME"
|
||||
|
||||
- name: Send APK to Telegram
|
||||
env:
|
||||
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
||||
TG_CHAT_ID: ${{ vars.TG_CHAT_ID }}
|
||||
run: |
|
||||
if [ -z "$TG_BOT_TOKEN" ] || [ -z "$TG_CHAT_ID" ]; then
|
||||
echo "TG_BOT_TOKEN or TG_CHAT_ID not set, skipping Telegram upload."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
APP_NAME="${{ gitea.repository }}"
|
||||
APP_NAME="${APP_NAME##*/}"
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
|
||||
CAPTION="${APP_NAME} ${TAG}"
|
||||
|
||||
curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument" \
|
||||
-F "chat_id=${TG_CHAT_ID}" \
|
||||
-F "document=@${ASSET_PATH}" \
|
||||
-F "caption=${CAPTION}"
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-05-18T20:24:18.550107339Z">
|
||||
<DropdownSelection timestamp="2026-05-28T18:41:19.777722821Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=ce61d84c" />
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=4254e2f" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
@@ -15,7 +15,7 @@
|
||||
<targets>
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=ce61d84c" />
|
||||
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=67d022c2" />
|
||||
</handle>
|
||||
</Target>
|
||||
<Target type="DEFAULT_BOOT">
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="MarkdownSettings">
|
||||
<option name="previewPanelProviderInfo">
|
||||
<ProviderInfo name="Compose (experimental)" className="com.intellij.markdown.compose.preview.ComposePanelProvider" />
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
@@ -1,6 +1,6 @@
|
||||
# BasedBank
|
||||
# Thijooree
|
||||
|
||||
A unified Android banking app for Maldivians that combines MIB (Faisanet), BML (Bank of Maldives), and Fahipay into a single interface — with no analytics, no tracking, and no phone-home behaviour outside the banks themselves.
|
||||
A native Android client for Maldivian banking services. It is a pure client: requests go directly from your device to the banks' own servers using the same protocols as their official apps. No proxy, no backend, no middleman.
|
||||
|
||||
[](https://sladge.net)
|
||||
[](LICENSE)
|
||||
@@ -8,60 +8,15 @@ A unified Android banking app for Maldivians that combines MIB (Faisanet), BML (
|
||||

|
||||

|
||||
|
||||
## What it does
|
||||
|
||||
- **Multi-bank dashboard** — view balances across all your MIB, BML, and Fahipay accounts in one place, with a combined MVR and USD total
|
||||
- **Transaction history** — paginated, searchable transaction history per account for MIB CASA, BML CASA, BML prepaid cards, and Fahipay wallet
|
||||
- **Transfers** — send money between accounts and to saved contacts; supports MIB-to-MIB, BML-to-BML, and cross-bank (MIB↔BML via FAVARA)
|
||||
- **Contacts** — manage saved beneficiaries across all banks; validates Dhiraagu and Ooredoo numbers and shows the account owner name before you add
|
||||
- **Fahipay** — full wallet support including balance, history with merchant icons, and Fahipay favourites (Raastas, Reload, Ooredoo Bill, Dhiraagu Bill)
|
||||
- **QR payments** — scan PayMV QR codes to pre-fill transfers
|
||||
- **BML foreign limits** — view your foreign currency spending allowances and breakdowns by ATM / POS / ECOM
|
||||
- **MIB financing** — view active financing deals
|
||||
|
||||
## Authentication
|
||||
|
||||
The app requires your existing credentials for each bank — the same username/password/OTP seed you use with the official apps. It stores them encrypted using AES-256-GCM backed by the Android Keystore (hardware secure enclave).
|
||||
|
||||
Each bank's 2FA uses TOTP, so you need to have your OTP seed (the same secret used by your authenticator app).
|
||||
|
||||
## Security
|
||||
|
||||
- All credentials encrypted at rest with **AES-256-GCM** (Android Keystore)
|
||||
- Lock screen protected by **PBKDF2-HMAC-SHA256** (100,000 iterations) with optional biometric unlock
|
||||
- **FLAG_SECURE** on by default — content hidden in app switcher and screenshots blocked
|
||||
- All sensitive data excluded from Android cloud backup
|
||||
- Zero analytics, crash reporters, or third-party SDKs — network traffic goes only to MIB, BML, Fahipay, and the Maldivian telecoms for number validation
|
||||
|
||||
See [`docs/AI_SECURITY_CHECK.md`](docs/AI_SECURITY_CHECK.md) for the full security audit.
|
||||
|
||||
## Supported banks
|
||||
|
||||
| Bank | Login | Accounts | History | Transfers | Contacts |
|
||||
|---|---|---|---|---|---|
|
||||
| MIB (Faisanet) | username + password + TOTP | ✓ | ✓ | ✓ | ✓ |
|
||||
| BML (Bank of Maldives) | username + password + TOTP | ✓ | ✓ | ✓ | ✓ |
|
||||
| Fahipay | national ID + password + TOTP | ✓ | ✓ | — | ✓ (favourites) |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Android 8.0+ (API 26)
|
||||
- Existing accounts with MIB, BML, or Fahipay
|
||||
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
|
||||
|
||||
## Building
|
||||
|
||||
Open in Android Studio and run. No API keys or secrets required — all protocol constants are derived from the official apps and are included in the source.
|
||||
|
||||
The release signing config reads from environment variables (`KEYSTORE_PASSWORD`, `KEY_ALIAS`, `KEY_PASSWORD`).
|
||||
|
||||
## How it works
|
||||
|
||||
BasedBank talks directly to each bank's existing mobile API using the same protocol as their official apps, reverse-engineered from the APKs. It does not use any intermediary server — requests go straight from your device to the bank.
|
||||
|
||||
- **MIB**: Blowfish/ECB encrypted JSON over HTTPS with a Diffie-Hellman session key exchange
|
||||
- **BML**: PKCE OAuth 2.0 flow via the BML web login, exchanged for a Bearer token used on the mobile API
|
||||
- **Fahipay**: multipart form login with TOTP, session maintained via `__Secure-sess` cookie and `authid` header
|
||||
## Download APK
|
||||
[Gitea Releases](https://git.shihaam.dev/shihaam/thijooree/releases)
|
||||
[Telegram Channel](https://t.me/s/thijooreeapks)
|
||||
|
||||
## Privacy
|
||||
|
||||
@@ -70,3 +25,8 @@ No data ever leaves your device except the API calls to the banking services the
|
||||
## Disclaimer
|
||||
|
||||
This is an unofficial third-party app. It is not affiliated with, endorsed by, or supported by MIB, BML, or Fahipay. Use at your own risk. Review the source code before entering your banking credentials.
|
||||
|
||||
|
||||
## License
|
||||
|
||||
GNU General Public License v3.0 - See [LICENSE](LICENSE) file for details
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "sh.sar.basedbank"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 5
|
||||
versionName = "1.0.6"
|
||||
versionCode = 11
|
||||
versionName = "1.0.12"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -27,6 +27,10 @@ android {
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".debug"
|
||||
versionNameSuffix = "-debug"
|
||||
}
|
||||
release {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
isMinifyEnabled = false
|
||||
@@ -87,6 +91,9 @@ dependencies {
|
||||
// Biometric authentication
|
||||
implementation("androidx.biometric:biometric:1.1.0")
|
||||
|
||||
// Encrypted SharedPreferences (HCE token store)
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#CC0000"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<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,13 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
|
||||
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".BasedBankApp"
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -30,6 +33,9 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.app.shortcuts"
|
||||
android:resource="@xml/shortcuts" />
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
@@ -56,6 +62,25 @@
|
||||
android:exported="false"
|
||||
android:screenOrientation="portrait" />
|
||||
|
||||
<activity
|
||||
android:name=".nfc.BmlTapToPayActivity"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/Theme.BasedBank" />
|
||||
|
||||
<service
|
||||
android:name=".nfc.BmlHostCardEmulatorService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_NFC_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.nfc.cardemulation.host_apdu_service"
|
||||
android:resource="@xml/bml_aid_list" />
|
||||
</service>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 296 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 260 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 230 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 196 KiB |
@@ -0,0 +1 @@
|
||||
visa_bingaa.png
|
||||
@@ -0,0 +1 @@
|
||||
visa_bingaa.png
|
||||
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 36 KiB |
@@ -16,6 +16,13 @@ import sh.sar.basedbank.util.CredentialStore
|
||||
|
||||
class BasedBankApp : Application() {
|
||||
|
||||
/**
|
||||
* Set to true only after the user passes LockActivity or completes fresh login.
|
||||
* Resets to false on every process restart so direct ADB/root activity launches
|
||||
* cannot reach HomeActivity without re-authenticating.
|
||||
*/
|
||||
var isUnlocked = false
|
||||
|
||||
// Held in memory after successful login; cleared on logout
|
||||
var accounts: List<BankAccount> = emptyList()
|
||||
var fullName: String = ""
|
||||
@@ -108,7 +115,11 @@ class BasedBankApp : Application() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||
// Only apply wallpaper-based dynamic colors in system theme mode.
|
||||
// Light/dark modes use content-based accent colors applied per-activity via ThemeHelper.
|
||||
DynamicColors.applyToActivitiesIfAvailable(this) { _, _ ->
|
||||
getSharedPreferences("prefs", MODE_PRIVATE).getString("theme", "system") == "system"
|
||||
}
|
||||
|
||||
val theme = getSharedPreferences("prefs", MODE_PRIVATE).getString("theme", "system")
|
||||
AppCompatDelegate.setDefaultNightMode(when (theme) {
|
||||
|
||||
@@ -21,6 +21,8 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.databinding.ActivityLockBinding
|
||||
import sh.sar.basedbank.ui.home.HomeActivity
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.ThemeHelper
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
|
||||
@@ -32,6 +34,8 @@ class LockActivity : AppCompatActivity() {
|
||||
private lateinit var salt: String
|
||||
private lateinit var storedHash: String
|
||||
private var biometricsEnabled = false
|
||||
private var autoUnlockPin = false
|
||||
private var pinLength = 4
|
||||
private var isVerifying = false
|
||||
|
||||
private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE)
|
||||
@@ -43,6 +47,7 @@ class LockActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
ThemeHelper.applyAccent(this)
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
binding = ActivityLockBinding.inflate(layoutInflater)
|
||||
@@ -52,6 +57,9 @@ class LockActivity : AppCompatActivity() {
|
||||
isAppearanceLightStatusBars = isLight
|
||||
isAppearanceLightNavigationBars = isLight
|
||||
}
|
||||
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
|
||||
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
|
||||
ta.recycle()
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets ->
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
|
||||
@@ -61,6 +69,8 @@ class LockActivity : AppCompatActivity() {
|
||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
method = prefs.getString("security_method", "pin") ?: "pin"
|
||||
biometricsEnabled = prefs.getBoolean("biometrics_enabled", false)
|
||||
autoUnlockPin = prefs.getBoolean("auto_unlock_pin", false)
|
||||
pinLength = prefs.getInt("pin_length", 4)
|
||||
|
||||
val stored = CredentialStore(this).loadSecurityHash() ?: run { finish(); return }
|
||||
salt = stored.first
|
||||
@@ -134,13 +144,18 @@ class LockActivity : AppCompatActivity() {
|
||||
when (key) {
|
||||
"⌫" -> if (pinDigits.isNotEmpty()) { pinDigits.removeLast(); updateDots() }
|
||||
"✓" -> if (pinDigits.size >= 4) verifyPin()
|
||||
else -> if (pinDigits.size < 8) { pinDigits.add(key.toInt()); updateDots() }
|
||||
else -> if (pinDigits.size < 8) {
|
||||
pinDigits.add(key.toInt())
|
||||
updateDots()
|
||||
if (autoUnlockPin && pinDigits.size == pinLength) verifyPin()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateDots() {
|
||||
val n = pinDigits.size
|
||||
binding.tvLockPinDots.text = "●".repeat(n) + "○".repeat(maxOf(4 - n, 0))
|
||||
val total = if (autoUnlockPin) pinLength else maxOf(n, 4)
|
||||
binding.tvLockPinDots.text = "●".repeat(n) + "○".repeat(maxOf(total - n, 0))
|
||||
}
|
||||
|
||||
private fun verifyPin() {
|
||||
@@ -194,15 +209,15 @@ class LockActivity : AppCompatActivity() {
|
||||
if (remaining <= 0) return false
|
||||
val secs = ((remaining + 999L) / 1000L).toInt()
|
||||
val msg = getString(R.string.unlock_locked_out, secs)
|
||||
binding.tvLockPinDots.text = msg
|
||||
binding.root.postDelayed({ updateDots() }, remaining)
|
||||
binding.tvPinHint.text = msg
|
||||
binding.root.postDelayed({ binding.tvPinHint.text = ""; updateDots() }, remaining)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun showFailure() {
|
||||
val msg = failureMessage()
|
||||
binding.tvLockPinDots.text = msg
|
||||
binding.root.postDelayed({ updateDots() }, 1200)
|
||||
binding.tvPinHint.text = msg
|
||||
binding.root.postDelayed({ binding.tvPinHint.text = ""; updateDots() }, 1200)
|
||||
}
|
||||
|
||||
private fun failureMessage(): String {
|
||||
@@ -250,10 +265,25 @@ class LockActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun proceed() {
|
||||
(application as BasedBankApp).isUnlocked = true
|
||||
if (intent.getBooleanExtra(EXTRA_RESUME, false)) {
|
||||
finish()
|
||||
} else {
|
||||
startActivity(Intent(this, HomeActivity::class.java))
|
||||
val store = CredentialStore(this)
|
||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
||||
if (!hasCredentials) {
|
||||
startActivity(Intent(this, sh.sar.basedbank.ui.login.LoginActivity::class.java))
|
||||
finish()
|
||||
return
|
||||
}
|
||||
val navDest = intent.getIntExtra("nav_destination", -1)
|
||||
val autoScan = intent.getBooleanExtra("auto_scan", false)
|
||||
val autoTapMode = intent.getBooleanExtra("auto_tap_mode", false)
|
||||
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)
|
||||
})
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,24 +7,46 @@ import sh.sar.basedbank.ui.home.HomeActivity
|
||||
import sh.sar.basedbank.ui.login.LoginActivity
|
||||
import sh.sar.basedbank.ui.onboarding.OnboardingActivity
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
val onboardingDone = prefs.getBoolean("onboarding_done", false)
|
||||
val securitySet = prefs.getString("security_method", null) != null
|
||||
val store = CredentialStore(this)
|
||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
||||
|
||||
val navDestination = when (intent?.action) {
|
||||
"sh.sar.basedbank.OPEN_TRANSFER" -> R.id.nav_transfer
|
||||
"sh.sar.basedbank.OPEN_SCAN_QR" -> R.id.nav_transfer
|
||||
"sh.sar.basedbank.OPEN_PAY_WITH_CARD" -> R.id.nav_pay_with_card
|
||||
"sh.sar.basedbank.TAP_TO_PAY" -> R.id.nav_pay_with_card
|
||||
else -> -1
|
||||
}
|
||||
val autoScan = intent?.action == "sh.sar.basedbank.OPEN_SCAN_QR"
|
||||
val autoTapMode = intent?.action == "sh.sar.basedbank.TAP_TO_PAY"
|
||||
|
||||
val target = when {
|
||||
!onboardingDone -> OnboardingActivity::class.java
|
||||
!hasCredentials -> LoginActivity::class.java
|
||||
securitySet -> LockActivity::class.java // proceed() → HomeActivity
|
||||
else -> HomeActivity::class.java
|
||||
}
|
||||
startActivity(Intent(this, target))
|
||||
// No lock screen configured — mark as unlocked so HomeActivity's guard passes
|
||||
if (target == HomeActivity::class.java) {
|
||||
(application as BasedBankApp).isUnlocked = true
|
||||
}
|
||||
|
||||
startActivity(Intent(this, target).apply {
|
||||
if (navDestination != -1) putExtra("nav_destination", navDestination)
|
||||
if (autoScan) putExtra("auto_scan", true)
|
||||
if (autoTapMode) putExtra("auto_tap_mode", true)
|
||||
})
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package sh.sar.basedbank.api.bml
|
||||
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
|
||||
data class BmlUserInfo(
|
||||
val fullName: String,
|
||||
@@ -27,9 +28,19 @@ class BmlAccountClient {
|
||||
val json = resp.body?.string()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
if (code in 500..599) throw BankServerException("BML")
|
||||
return parseDashboard(json ?: return emptyList(), loginTag, profileName, profileId)
|
||||
}
|
||||
|
||||
/** Lightweight call to verify the session is alive. Throws [AuthExpiredException] on 401/419. */
|
||||
fun checkProfile(session: BmlSession) {
|
||||
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/profile")).execute()
|
||||
val code = resp.code
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
if (code in 500..599) throw BankServerException("BML")
|
||||
}
|
||||
|
||||
fun fetchUserInfo(session: BmlSession): BmlUserInfo? {
|
||||
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/userinfo")).execute()
|
||||
val json = resp.body?.string() ?: return null
|
||||
@@ -73,6 +84,27 @@ class BmlAccountClient {
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun fetchTransferChannels(session: BmlSession): List<BmlOtpChannel> {
|
||||
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/transfer")).execute()
|
||||
val json = resp.body?.string() ?: run { resp.close(); return emptyList() }
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val arr = root.optJSONObject("payload")
|
||||
?.optJSONObject("transfer")
|
||||
?.optJSONArray("otpChannel") ?: return emptyList()
|
||||
(0 until arr.length()).map { i ->
|
||||
val ch = arr.getJSONObject(i)
|
||||
BmlOtpChannel(
|
||||
channel = ch.optString("channel"),
|
||||
description = ch.optString("description"),
|
||||
masked = ch.optString("masked")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
private fun parseDashboard(
|
||||
json: String,
|
||||
loginTag: String,
|
||||
@@ -137,16 +169,22 @@ class BmlAccountClient {
|
||||
internalId = internalId
|
||||
))
|
||||
} else if (accountType == "Card") {
|
||||
val isVisible = item.optBoolean("account_visible", false)
|
||||
if (!isVisible) continue
|
||||
val isPrepaid = item.optBoolean("prepaid_card", false)
|
||||
val productCode = item.optString("product_code", "")
|
||||
val cardBalance = item.optJSONObject("cardBalance")
|
||||
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
|
||||
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
|
||||
val isVisible = item.optBoolean("account_visible", false)
|
||||
val cardProfileType = when {
|
||||
isPrepaid -> "BML_PREPAID"
|
||||
isVisible -> "BML_CREDIT" // non-prepaid, visible = credit card
|
||||
else -> "BML_DEBIT" // non-prepaid, not visible = debit card
|
||||
}
|
||||
prepaidCards.add(BankAccount(
|
||||
bank = "BML",
|
||||
profileName = profileName,
|
||||
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
|
||||
profileType = cardProfileType,
|
||||
productCode = productCode,
|
||||
accountNumber = accountNumber,
|
||||
accountBriefName = item.optString("alias").ifBlank { product },
|
||||
currencyName = currency,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import android.os.Build
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
||||
internal const val BML_USER_AGENT = "bml-mobile-banking/348 (POCO; Android 14; 22101320I)"
|
||||
internal val BML_USER_AGENT = "bml-mobile-banking/348 (${Build.MANUFACTURER}; Android ${Build.VERSION.RELEASE}; ${Build.MODEL})"
|
||||
internal const val BML_APP_VERSION = "2.1.44.348"
|
||||
|
||||
internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
||||
|
||||
@@ -4,6 +4,7 @@ import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
@@ -30,6 +31,7 @@ class BmlHistoryClient {
|
||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
if (code in 500..599) throw BankServerException("BML")
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return Pair(emptyList(), 0)
|
||||
@@ -82,6 +84,7 @@ class BmlHistoryClient {
|
||||
val json = resp.body?.string() ?: return emptyList()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
if (code in 500..599) throw BankServerException("BML")
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
|
||||
@@ -25,9 +25,9 @@ class BmlLoginFlow {
|
||||
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
||||
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
|
||||
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
|
||||
private val APP_USER_AGENT = "bml-mobile-banking/348 (POCO; Android 14; 22101320I)"
|
||||
private val APP_USER_AGENT = "bml-mobile-banking/348 (${android.os.Build.MANUFACTURER}; Android ${android.os.Build.VERSION.RELEASE}; ${android.os.Build.MODEL})"
|
||||
private val APP_VERSION = "2.1.44.348"
|
||||
private val WEB_USER_AGENT = "Mozilla/5.0 (Android 14; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
|
||||
private val WEB_USER_AGENT = "Mozilla/5.0 (Android ${android.os.Build.VERSION.RELEASE}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
|
||||
|
||||
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
||||
private val cookieJar = object : CookieJar {
|
||||
@@ -310,13 +310,47 @@ class BmlLoginFlow {
|
||||
val tokenJson = tokenResp.body?.string() ?: throw Exception("Empty token response")
|
||||
tokenResp.close()
|
||||
|
||||
val accessToken = JSONObject(tokenJson).optString("access_token")
|
||||
val tokenObj = JSONObject(tokenJson)
|
||||
val accessToken = tokenObj.optString("access_token")
|
||||
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
|
||||
val refreshToken = tokenObj.optString("refresh_token", "")
|
||||
val expiresIn = tokenObj.optLong("expires_in", 0L)
|
||||
val expiresAt = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000L else 0L
|
||||
|
||||
val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
|
||||
val session = BmlSession(accessToken = accessToken, deviceId = deviceId, refreshToken = refreshToken, expiresAt = expiresAt)
|
||||
val accounts = BmlAccountClient().fetchAccounts(session, loginTag, profileName, profileId)
|
||||
return Pair(session, accounts)
|
||||
}
|
||||
// ─── Token refresh ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Uses the saved refresh token to obtain a new access token without re-login.
|
||||
* Returns a new [BmlSession] with updated tokens.
|
||||
*/
|
||||
fun refreshSession(session: BmlSession): BmlSession {
|
||||
val body = FormBody.Builder()
|
||||
.add("grant_type", "refresh_token")
|
||||
.add("refresh_token", session.refreshToken)
|
||||
.add("client_id", CLIENT_ID)
|
||||
.add("Device-ID", session.deviceId)
|
||||
.add("User-Agent", APP_USER_AGENT)
|
||||
.add("x-app-version", APP_VERSION)
|
||||
.build()
|
||||
val resp = newBmlApiClient().newCall(
|
||||
Request.Builder().url("$BASE_URL/oauth/token").post(body)
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: throw Exception("Empty refresh response")
|
||||
resp.close()
|
||||
val obj = JSONObject(json)
|
||||
val newAccess = obj.optString("access_token").takeIf { it.isNotBlank() }
|
||||
?: throw Exception("Token refresh failed")
|
||||
val newRefresh = obj.optString("refresh_token", "").ifBlank { session.refreshToken }
|
||||
val expiresIn = obj.optLong("expires_in", 0L)
|
||||
val expiresAt = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000L else 0L
|
||||
return BmlSession(accessToken = newAccess, deviceId = session.deviceId, refreshToken = newRefresh, expiresAt = expiresAt)
|
||||
}
|
||||
|
||||
// ─── Parsing ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,8 +4,12 @@ import sh.sar.basedbank.api.models.BankAccount
|
||||
|
||||
data class BmlSession(
|
||||
val accessToken: String,
|
||||
val deviceId: String
|
||||
)
|
||||
val deviceId: String,
|
||||
val refreshToken: String = "",
|
||||
val expiresAt: Long = 0L // Unix millis; 0 = unknown
|
||||
) {
|
||||
fun isExpired() = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
|
||||
}
|
||||
|
||||
data class BmlProfile(
|
||||
val profileId: String,
|
||||
@@ -62,6 +66,31 @@ data class BmlLoanDetail(
|
||||
val overdueAmount: Double
|
||||
)
|
||||
|
||||
data class BmlQrPayInfo(
|
||||
val requestId: String, // base64-encoded full QR URL (trxn_hash)
|
||||
val merchantName: String, // narrative1
|
||||
val merchantAddress: String, // narrative2 + narrative3
|
||||
val amount: Double, // 0.0 for static QR
|
||||
val currency: String
|
||||
)
|
||||
|
||||
data class BmlQrPayResult(
|
||||
val success: Boolean,
|
||||
val merchant: String = "",
|
||||
val amount: String = "",
|
||||
val currency: String = "",
|
||||
val errorMessage: String = ""
|
||||
)
|
||||
|
||||
data class BmlWalletToken(
|
||||
val token: String,
|
||||
val expiry: String,
|
||||
val appCode: String, // AID hex, e.g. "A0000000031010"
|
||||
val serviceCode: String,
|
||||
val data: String,
|
||||
val validUntil: String // "YYYY-MM-DD HH:mm:ss.SSS"
|
||||
)
|
||||
|
||||
data class BmlForeignLimit(
|
||||
val type: String,
|
||||
val used: Double,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
|
||||
class BmlQrPayClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
/**
|
||||
* Resolves a BML QR URL to merchant details.
|
||||
* [base64Url] is the full QR URL Base64-encoded (standard, with padding).
|
||||
*/
|
||||
fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo {
|
||||
val request = bmlApiRequest(session,
|
||||
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url")
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string() ?: throw Exception("No response")
|
||||
val json = JSONObject(body)
|
||||
if (!json.optBoolean("success"))
|
||||
throw Exception(json.optString("message").ifBlank { "Lookup failed" })
|
||||
val payload = json.getJSONObject("payload")
|
||||
val addr2 = payload.optString("narrative2").trim()
|
||||
val addr3 = payload.optString("narrative3").trim()
|
||||
val address = listOf(addr2, addr3).filter { it.isNotBlank() }.joinToString(", ")
|
||||
BmlQrPayInfo(
|
||||
requestId = payload.optString("trxn_hash"),
|
||||
merchantName = payload.optString("narrative1").trim(),
|
||||
merchantAddress = address,
|
||||
amount = payload.optString("amount").toDoubleOrNull() ?: 0.0,
|
||||
currency = payload.optString("currency").ifBlank { "MVR" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-initiate step required for gateway QR (pay.bml.com.mv).
|
||||
* POST without channel — expects code 99 (OTP channel selection required).
|
||||
*/
|
||||
fun preInitiatePayment(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
requestId: String,
|
||||
amount: Double,
|
||||
currency: String
|
||||
): Boolean {
|
||||
val jo = JSONObject().apply {
|
||||
put("action", "approve")
|
||||
put("debitAccount", debitAccount)
|
||||
put("requestId", requestId)
|
||||
put("amount", amount)
|
||||
put("currency", currency)
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
|
||||
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string() ?: return@use false
|
||||
val json = try { JSONObject(body) } catch (_: Exception) { return@use false }
|
||||
json.optBoolean("success") && json.optInt("code") == 99
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1 — initiate: POST with channel but no OTP.
|
||||
* Returns true when server responds with code 22 (OTP generated).
|
||||
*/
|
||||
fun initiatePayment(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
requestId: String,
|
||||
amount: Double,
|
||||
currency: String
|
||||
): Boolean {
|
||||
val jo = JSONObject().apply {
|
||||
put("action", "approve")
|
||||
put("debitAccount", debitAccount)
|
||||
put("requestId", requestId)
|
||||
put("amount", amount)
|
||||
put("currency", currency)
|
||||
put("channel", "token")
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
|
||||
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string() ?: return@use false
|
||||
val json = try { JSONObject(body) } catch (_: Exception) { return@use false }
|
||||
json.optBoolean("success") && json.optInt("code") == 22
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2 — confirm: POST with channel + OTP.
|
||||
* Returns [BmlQrPayResult] with success/error.
|
||||
*/
|
||||
fun confirmPayment(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
requestId: String,
|
||||
amount: Double,
|
||||
currency: String,
|
||||
otp: String
|
||||
): BmlQrPayResult {
|
||||
val jo = JSONObject().apply {
|
||||
put("action", "approve")
|
||||
put("debitAccount", debitAccount)
|
||||
put("requestId", requestId)
|
||||
put("amount", amount)
|
||||
put("currency", currency)
|
||||
put("channel", "token")
|
||||
put("otp", otp)
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$BML_BASE_URL/api/mobile/walletpayments/pay")
|
||||
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string()
|
||||
?: return@use BmlQrPayResult(false, errorMessage = "No response")
|
||||
val json = try { JSONObject(body) } catch (_: Exception) {
|
||||
return@use BmlQrPayResult(false, errorMessage = "Parse error")
|
||||
}
|
||||
if (!json.optBoolean("success")) {
|
||||
BmlQrPayResult(false, errorMessage = json.optString("message").ifBlank { "Payment failed" })
|
||||
} else {
|
||||
val payload = json.optJSONObject("payload")
|
||||
BmlQrPayResult(
|
||||
success = true,
|
||||
merchant = payload?.optString("merchant") ?: "",
|
||||
amount = payload?.optString("amount") ?: "",
|
||||
currency = payload?.optString("currency") ?: currency
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class BmlTapToPayClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
/**
|
||||
* Fetches up to [quantity] single-use payment tokens for [cardId].
|
||||
* [otp] is a TOTP code generated from the stored BML OTP seed.
|
||||
*
|
||||
* Flow:
|
||||
* 1. POST → code 99 (OTP required) or 0 (direct, unlikely)
|
||||
* 2. POST with channel=token → code 22 (OTP generated on BML side, but we use TOTP)
|
||||
* 3. POST with otp=TOTP → code 0, payload = token list
|
||||
*/
|
||||
fun fetchTokens(
|
||||
session: BmlSession,
|
||||
cardId: String,
|
||||
otp: String,
|
||||
quantity: Int = 3
|
||||
): List<BmlWalletToken> {
|
||||
val url = "$BML_BASE_URL/api/mobile/walletpayments/gettoken"
|
||||
|
||||
// Step 1: initiate
|
||||
val base = JSONObject().apply {
|
||||
put("type", "track2")
|
||||
put("cardid", cardId)
|
||||
put("quantity", quantity)
|
||||
}
|
||||
val step1 = post(session, url, base)
|
||||
if (step1.optInt("code") == 0) return parseTokens(step1.optJSONArray("payload"))
|
||||
if (step1.optInt("code") != 99) throw Exception(step1.optString("message", "Token request failed"))
|
||||
|
||||
// Step 2: request OTP channel (triggers BML to validate we can use TOTP)
|
||||
val body2 = JSONObject(base.toString()).apply { put("channel", "token") }
|
||||
val step2 = post(session, url, body2)
|
||||
if (step2.optInt("code") != 22) throw Exception(step2.optString("message", "OTP channel request failed"))
|
||||
|
||||
// Step 3: submit TOTP
|
||||
val body3 = JSONObject(body2.toString()).apply { put("otp", otp) }
|
||||
val step3 = post(session, url, body3)
|
||||
if (step3.optInt("code") != 0) throw Exception(step3.optString("message", "Token fetch failed"))
|
||||
|
||||
return parseTokens(step3.optJSONArray("payload"))
|
||||
}
|
||||
|
||||
private fun post(session: BmlSession, url: String, body: JSONObject): JSONObject {
|
||||
val req = okhttp3.Request.Builder()
|
||||
.url(url)
|
||||
.post(body.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.build()
|
||||
return client.newCall(req).execute().use { resp ->
|
||||
JSONObject(resp.body?.string() ?: throw Exception("Empty response"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTokens(arr: JSONArray?): List<BmlWalletToken> {
|
||||
arr ?: return emptyList()
|
||||
return (0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
BmlWalletToken(
|
||||
token = o.getString("token"),
|
||||
expiry = o.getString("expiry"),
|
||||
appCode = o.getString("app_code"),
|
||||
serviceCode = o.getString("service_code"),
|
||||
data = o.optString("data", ""),
|
||||
validUntil = o.optString("valid_until", "")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,8 @@ class BmlTransferClient {
|
||||
amount: Double,
|
||||
transferType: String,
|
||||
currency: String,
|
||||
bank: String? = null
|
||||
bank: String? = null,
|
||||
channel: String = "token"
|
||||
): Boolean {
|
||||
val jo = JSONObject().apply {
|
||||
put("debitAccount", debitAccount)
|
||||
@@ -25,7 +26,7 @@ class BmlTransferClient {
|
||||
put("debitAmount", amount)
|
||||
put("transfertype", transferType)
|
||||
put("currency", currency)
|
||||
put("channel", "token")
|
||||
put("channel", channel)
|
||||
if (bank != null) put("bank", bank)
|
||||
}
|
||||
val request = Request.Builder()
|
||||
@@ -55,7 +56,8 @@ class BmlTransferClient {
|
||||
currency: String,
|
||||
otp: String,
|
||||
remarks: String = "",
|
||||
bank: String? = null
|
||||
bank: String? = null,
|
||||
channel: String = "token"
|
||||
): BmlTransferResult {
|
||||
val jo = JSONObject().apply {
|
||||
put("debitAccount", debitAccount)
|
||||
@@ -63,7 +65,7 @@ class BmlTransferClient {
|
||||
put("debitAmount", amount)
|
||||
put("transfertype", transferType)
|
||||
put("currency", currency)
|
||||
put("channel", "token")
|
||||
put("channel", channel)
|
||||
put("otp", otp)
|
||||
if (remarks.isNotBlank()) put("remarks", remarks)
|
||||
if (bank != null) put("bank", bank)
|
||||
|
||||
@@ -4,6 +4,7 @@ import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FahipayAccountClient {
|
||||
@@ -27,8 +28,10 @@ class FahipayAccountClient {
|
||||
Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en")
|
||||
.auth(session).build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: throw Exception("Empty profile response")
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Fahipay")
|
||||
val obj = JSONObject(json)
|
||||
val props = obj.optJSONObject("props") ?: JSONObject()
|
||||
return FahipayUserProfile(
|
||||
@@ -47,8 +50,10 @@ class FahipayAccountClient {
|
||||
Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en")
|
||||
.auth(session).build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return 0.0
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Fahipay")
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0)
|
||||
|
||||
@@ -3,6 +3,7 @@ package sh.sar.basedbank.api.fahipay
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@@ -32,8 +33,10 @@ class FahipayHistoryClient {
|
||||
.header("User-Agent", UA)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Fahipay")
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
val total = obj.optInt("total", 0)
|
||||
|
||||
@@ -16,7 +16,7 @@ import java.util.concurrent.TimeUnit
|
||||
class FahipayLoginFlow {
|
||||
|
||||
private val BASE_URL = "https://fahipay.mv"
|
||||
private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android 14; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
|
||||
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
||||
private val cookieJar = object : CookieJar {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import android.os.Build
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MibCardsClient {
|
||||
|
||||
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private fun cookieHeader(session: MibSession) =
|
||||
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
|
||||
"mbnonce=${session.nonceGenerator}; time-tracker=597"
|
||||
|
||||
fun fetchCards(session: MibSession, loginTag: String): List<MibCard> {
|
||||
val body = FormBody.Builder()
|
||||
.add("name", "")
|
||||
.add("start", "1")
|
||||
.add("end", "50")
|
||||
.add("includeCount", "1")
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("$BASE_WV_URL/ajaxDebitCard/fetchCardInfos")
|
||||
.post(body)
|
||||
.header("Cookie", cookieHeader(session))
|
||||
.header("User-Agent", "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36")
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("Accept", "*/*")
|
||||
.header("Origin", BASE_WV_URL)
|
||||
.header("Referer", "$BASE_WV_URL//debitCards?dashurl=1")
|
||||
.build()
|
||||
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val bodyStr = response.body?.string() ?: return emptyList()
|
||||
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return emptyList() }
|
||||
if (!json.optBoolean("success")) return emptyList()
|
||||
val data = json.optJSONArray("data") ?: return emptyList()
|
||||
(0 until data.length()).map { i ->
|
||||
val item = data.getJSONObject(i)
|
||||
MibCard(
|
||||
cardId = item.optString("cardId"),
|
||||
maskedCardNumber = item.optString("maskedCardNumber"),
|
||||
cardStatus = item.optString("cardStatus"),
|
||||
cardType = item.optString("cardType"),
|
||||
cardTypeDesc = item.optString("cardTypeDesc"),
|
||||
customerId = item.optString("customerId"),
|
||||
phoneNumber = item.optString("phoneNumber"),
|
||||
cardHolderName = item.optString("cardHolderName"),
|
||||
loginTag = loginTag
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import android.os.Build
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@@ -24,7 +25,7 @@ class MibContactsClient {
|
||||
.header("Cookie", cookieHeader(session))
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
)
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("Accept", "*/*")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import android.os.Build
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -27,7 +28,7 @@ class MibFinancingClient {
|
||||
.header("Cookie", cookieHeader)
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
)
|
||||
.header("X-Requested-With", "mv.com.mib.faisamobilex")
|
||||
.get()
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import android.os.Build
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MibHistoryClient {
|
||||
@@ -50,7 +52,7 @@ class MibHistoryClient {
|
||||
.header("Cookie", cookieHeader(session))
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
)
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("Accept", "*/*")
|
||||
@@ -59,6 +61,7 @@ class MibHistoryClient {
|
||||
.build()
|
||||
|
||||
return client.newCall(request).execute().use { response ->
|
||||
if (response.code in 500..599) throw BankServerException("MIB")
|
||||
val bodyStr = response.body?.string() ?: return Pair(emptyList(), 0)
|
||||
val json = try { JSONObject(bodyStr) } catch (_: Exception) { return Pair(emptyList(), 0) }
|
||||
if (!json.optBoolean("success")) return Pair(emptyList(), 0)
|
||||
|
||||
@@ -9,6 +9,7 @@ import org.json.JSONObject
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.math.abs
|
||||
import kotlin.random.Random
|
||||
|
||||
class SessionExpiredException : Exception("MIB session expired")
|
||||
@@ -39,7 +40,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.writeTimeout(30, TimeUnit.SECONDS)
|
||||
.addInterceptor { chain ->
|
||||
.addNetworkInterceptor { chain ->
|
||||
val req = chain.request().newBuilder()
|
||||
.header("User-Agent", "android/1.0")
|
||||
.header("Accept", "application/json")
|
||||
@@ -161,14 +162,14 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
bank = "MIB",
|
||||
profileName = profile.name,
|
||||
profileType = profile.profileType,
|
||||
cifType = profile.cifType,
|
||||
productCode = profile.cifType,
|
||||
accountNumber = a.optString("accountNumber"),
|
||||
accountBriefName = a.optString("accountBriefName"),
|
||||
currencyName = a.optString("currencyName"),
|
||||
accountTypeName = a.optString("accountTypeName"),
|
||||
availableBalance = a.optString("availableBalance"),
|
||||
currentBalance = a.optString("currentBalance"),
|
||||
blockedAmount = a.optString("blockedAmount"),
|
||||
blockedAmount = absBlockedAmount(a.optString("blockedAmount")),
|
||||
mvrBalance = a.optString("mvrBalance"),
|
||||
statusDesc = a.optString("statusDesc"),
|
||||
profileImageHash = profile.customerImage,
|
||||
@@ -188,6 +189,13 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** MIB returns blockedAmount as a signed decimal where negative = funds held.
|
||||
* Normalize to a positive magnitude so downstream code can treat it uniformly. */
|
||||
private fun absBlockedAmount(raw: String): String {
|
||||
val v = raw.toDoubleOrNull() ?: return raw
|
||||
return "%.2f".format(abs(v))
|
||||
}
|
||||
|
||||
private fun initialKeyExchange(
|
||||
appId: String, encKey: String, sfunc: String, key2: String? = null
|
||||
): Pair<MibSession, String> {
|
||||
@@ -318,14 +326,14 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
bank = "MIB",
|
||||
profileName = profile.name,
|
||||
profileType = profile.profileType,
|
||||
cifType = profile.cifType,
|
||||
productCode = profile.cifType,
|
||||
accountNumber = a.optString("accountNumber"),
|
||||
accountBriefName = a.optString("accountBriefName"),
|
||||
currencyName = a.optString("currencyName"),
|
||||
accountTypeName = a.optString("accountTypeName"),
|
||||
availableBalance = a.optString("availableBalance"),
|
||||
currentBalance = a.optString("currentBalance"),
|
||||
blockedAmount = a.optString("blockedAmount"),
|
||||
blockedAmount = absBlockedAmount(a.optString("blockedAmount")),
|
||||
mvrBalance = a.optString("mvrBalance"),
|
||||
statusDesc = a.optString("statusDesc"),
|
||||
profileImageHash = profile.customerImage,
|
||||
@@ -366,6 +374,34 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
return resp.optString("profileImage").takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a profile image via P40.
|
||||
* [imageBase64] is a base64-encoded JPEG string.
|
||||
* Returns the new imageHash on success, or null on failure.
|
||||
*/
|
||||
fun uploadProfileImage(session: MibSession, profile: MibProfile, imageBase64: String): String? {
|
||||
val payload = baseData(session, "P40").apply {
|
||||
put("profileId", profile.profileId)
|
||||
put("profileImage", imageBase64)
|
||||
}
|
||||
val resp = doRequest(session, payload, "n")
|
||||
if (!resp.optBoolean("success", false)) return null
|
||||
return resp.optString("imageHash").takeIf { it.isNotBlank() }
|
||||
?: resp.optString("customerImage").takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the profile image via P42.
|
||||
* Returns true on success.
|
||||
*/
|
||||
fun deleteProfileImage(session: MibSession, profile: MibProfile): Boolean {
|
||||
val payload = baseData(session, "P42").apply {
|
||||
put("profileId", profile.profileId)
|
||||
}
|
||||
val resp = doRequest(session, payload, "n")
|
||||
return resp.optBoolean("success", false)
|
||||
}
|
||||
|
||||
private fun post(body: FormBody): String {
|
||||
val request = Request.Builder()
|
||||
.url(BASE_URL)
|
||||
@@ -373,6 +409,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
.build()
|
||||
val response = client.newCall(request).execute()
|
||||
if (response.code == 419) throw SessionExpiredException()
|
||||
if (response.code in 500..599) throw sh.sar.basedbank.api.models.BankServerException("MIB")
|
||||
return response.body?.string() ?: throw IllegalStateException("Empty response body")
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,18 @@ data class MibIpsAccountInfo(
|
||||
)
|
||||
|
||||
|
||||
data class MibCard(
|
||||
val cardId: String,
|
||||
val maskedCardNumber: String,
|
||||
val cardStatus: String,
|
||||
val cardType: String,
|
||||
val cardTypeDesc: String,
|
||||
val customerId: String,
|
||||
val phoneNumber: String,
|
||||
val cardHolderName: String,
|
||||
val loginTag: String
|
||||
)
|
||||
|
||||
data class MibFinanceDeal(
|
||||
val dealNo: String,
|
||||
val productDesc: String,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import android.os.Build
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@@ -26,7 +27,7 @@ class MibTransferClient {
|
||||
.header("Cookie", cookieHeader(session))
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
)
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("Accept", "*/*")
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package sh.sar.basedbank.api.models
|
||||
|
||||
/** Thrown by a bank API client when the server returns an HTTP 5xx response. */
|
||||
class BankServerException(val bankName: String) : Exception("Server error from $bankName")
|
||||
|
||||
/**
|
||||
* Unified account model used across all banks (MIB, BML, Fahipay, ...).
|
||||
* The [bank] field identifies which bank owns this account.
|
||||
@@ -8,7 +11,7 @@ data class BankAccount(
|
||||
val bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow
|
||||
val profileName: String,
|
||||
val profileType: String,
|
||||
val cifType: String = "", // MIB: human-readable profile category (e.g. "Individual", "Sole Propr"); empty for other banks
|
||||
val productCode: String = "", // bank-specific product/subtype code: MIB: CIF type label ("Individual", "Sole Propr"); BML: card product code ("C8201", "C1007")
|
||||
val accountNumber: String,
|
||||
val accountBriefName: String,
|
||||
val currencyName: String,
|
||||
|
||||
@@ -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_UNKNOWN_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
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("sh.sar.basedbank.TAP_TO_PAY").apply {
|
||||
setPackage(applicationContext.packageName)
|
||||
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 < 4) {
|
||||
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())
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ 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.api.models.BankServerException
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import sh.sar.basedbank.api.mib.TransactionCache
|
||||
@@ -149,8 +150,15 @@ class AccountHistoryFragment : Fragment() {
|
||||
pendingIconUrls.clear()
|
||||
firstPageDone = false
|
||||
fetcher = HistoryFetcher(account)
|
||||
adapter.setTransactions(emptyList())
|
||||
binding.emptyView.visibility = View.GONE
|
||||
// Restore cache immediately so data stays visible while refreshing
|
||||
val cached = TransactionCache.load(requireContext(), account.accountNumber)
|
||||
if (cached.isNotEmpty()) {
|
||||
allTransactions.addAll(cached)
|
||||
filterAndDisplay()
|
||||
} else {
|
||||
adapter.setTransactions(emptyList())
|
||||
binding.emptyView.visibility = View.GONE
|
||||
}
|
||||
loadNextPage()
|
||||
}
|
||||
|
||||
@@ -165,9 +173,20 @@ class AccountHistoryFragment : Fragment() {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
|
||||
lifecycleScope.launch {
|
||||
val transactions = fetcher.fetchNextPage(app, pageSize)
|
||||
val transactions = try {
|
||||
fetcher.fetchNextPage(app, pageSize)
|
||||
} catch (e: java.io.IOException) {
|
||||
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
|
||||
null
|
||||
} catch (e: BankServerException) {
|
||||
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_server_error, e.bankName))
|
||||
null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
if (_binding == null) return@launch
|
||||
|
||||
if (!firstPageDone) {
|
||||
firstPageDone = true
|
||||
@@ -175,6 +194,13 @@ class AccountHistoryFragment : Fragment() {
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
|
||||
if (transactions == null) {
|
||||
adapter.showLoadingFooter = false
|
||||
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
|
||||
return@launch
|
||||
}
|
||||
(activity as? HomeActivity)?.hideConnectivityBanner()
|
||||
|
||||
if (transactions.isNotEmpty()) {
|
||||
val existingIds = allTransactions.map { it.id }.toHashSet()
|
||||
val newOnes = transactions.filter { it.id !in existingIds }
|
||||
|
||||
@@ -3,11 +3,13 @@ package sh.sar.basedbank.ui.home
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.databinding.ItemAccountBinding
|
||||
import sh.sar.basedbank.databinding.ItemCardBinding
|
||||
@@ -17,7 +19,11 @@ import sh.sar.basedbank.util.AccountListParser
|
||||
|
||||
class AccountsAdapter(
|
||||
accounts: List<BankAccount>,
|
||||
private val onAccountClick: (BankAccount) -> Unit = {}
|
||||
private val onAccountClick: (BankAccount) -> Unit = {},
|
||||
/** Optional loader for MIB per-profile images: (hash, onLoaded) */
|
||||
private val profileImageLoader: ((String, (Bitmap) -> Unit) -> Unit)? = null,
|
||||
/** Optional loader for local (BML/Fahipay) profile images: (loginTag, profileId, onLoaded) */
|
||||
private val localProfileImageLoader: ((String, String, (Bitmap) -> Unit) -> Unit)? = null
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
var onTransferClick: ((BankAccount) -> Unit)? = null
|
||||
@@ -72,7 +78,7 @@ class AccountsAdapter(
|
||||
else -> account.bank
|
||||
}
|
||||
val profileLabel = when (account.bank) {
|
||||
"MIB" -> account.cifType.ifBlank { account.profileName }
|
||||
"MIB" -> account.productCode.ifBlank { account.profileName }
|
||||
else -> account.profileName
|
||||
}
|
||||
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
|
||||
@@ -112,17 +118,51 @@ class AccountsAdapter(
|
||||
|
||||
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
private var boundHash: String? = null
|
||||
|
||||
fun bind(account: BankAccount, display: AccountListDisplay) {
|
||||
binding.tvAccountName.text = display.name
|
||||
binding.tvAccountNumber.text = display.number
|
||||
binding.tvAccountType.text = display.typeLabel
|
||||
binding.tvBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
|
||||
val blocked = display.blockedBalance
|
||||
if (blocked != null) {
|
||||
val shown = if (hideAmounts) maskAmount(blocked) else blocked
|
||||
binding.tvBlocked.text = binding.root.context.getString(R.string.account_blocked_label, shown)
|
||||
binding.tvBlocked.visibility = View.VISIBLE
|
||||
} else {
|
||||
binding.tvBlocked.visibility = View.GONE
|
||||
}
|
||||
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||
binding.root.setOnClickListener { onAccountClick(account) }
|
||||
binding.root.setOnLongClickListener {
|
||||
copyToClipboard(it.context, display.number)
|
||||
true
|
||||
}
|
||||
|
||||
val staticLogo = when (account.bank) {
|
||||
"BML" -> R.drawable.bml_logo_vector
|
||||
"FAHIPAY" -> R.drawable.fahipay_logo
|
||||
"MIB" -> R.drawable.mib_logo
|
||||
else -> null
|
||||
}
|
||||
if (staticLogo != null) binding.ivBankLogo.setImageResource(staticLogo)
|
||||
else binding.ivBankLogo.setImageDrawable(null)
|
||||
|
||||
val hash = account.profileImageHash
|
||||
boundHash = hash
|
||||
when {
|
||||
account.bank == "MIB" && hash != null && profileImageLoader != null -> {
|
||||
profileImageLoader.invoke(hash) { bitmap ->
|
||||
if (boundHash == hash) binding.ivBankLogo.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
(account.bank == "BML" || account.bank == "FAHIPAY") && localProfileImageLoader != null -> {
|
||||
localProfileImageLoader.invoke(account.loginTag, account.profileId) { bitmap ->
|
||||
if (boundHash == hash) binding.ivBankLogo.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -8,9 +11,15 @@ import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentAccountsBinding
|
||||
import sh.sar.basedbank.util.ProfileImageStore
|
||||
|
||||
class AccountsFragment : Fragment() {
|
||||
|
||||
@@ -18,6 +27,7 @@ class AccountsFragment : Fragment() {
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
private lateinit var adapter: AccountsAdapter
|
||||
private val profileImageCache = mutableMapOf<String, Bitmap>()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentAccountsBinding.inflate(inflater, container, false)
|
||||
@@ -25,9 +35,49 @@ class AccountsFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
adapter = AccountsAdapter(emptyList()) { account ->
|
||||
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
|
||||
}
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
adapter = AccountsAdapter(
|
||||
accounts = emptyList(),
|
||||
onAccountClick = { account ->
|
||||
(activity as? HomeActivity)?.showWithBackStack(AccountHistoryFragment.newInstance(account))
|
||||
},
|
||||
profileImageLoader = { hash, onLoaded ->
|
||||
profileImageCache[hash]?.let { onLoaded(it); return@AccountsAdapter }
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val session = app.anyMibSession() ?: return@withContext null
|
||||
val b64 = app.anyMibFlow()?.fetchProfileImage(session, hash) ?: return@withContext null
|
||||
val bytes = Base64.decode(b64, Base64.DEFAULT)
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
if (bitmap != null) {
|
||||
profileImageCache[hash] = bitmap
|
||||
onLoaded(bitmap)
|
||||
}
|
||||
}
|
||||
},
|
||||
localProfileImageLoader = { loginTag, profileId, onLoaded ->
|
||||
val cacheKey = "$loginTag|$profileId"
|
||||
profileImageCache[cacheKey]?.let { onLoaded(it); return@AccountsAdapter }
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
val ctx = requireContext()
|
||||
if (loginTag.startsWith("bml_") && profileId.isNotBlank()) {
|
||||
ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(profileId))
|
||||
} else if (loginTag.startsWith("fahipay_")) {
|
||||
val loginId = ProfileImageStore.loginIdFromTag(loginTag)
|
||||
ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
|
||||
} else null
|
||||
}
|
||||
if (bitmap != null) {
|
||||
profileImageCache[cacheKey] = bitmap
|
||||
onLoaded(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
adapter.onTransferClick = { account ->
|
||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(account))
|
||||
}
|
||||
@@ -43,7 +93,10 @@ class AccountsFragment : Fragment() {
|
||||
insets
|
||||
}
|
||||
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
|
||||
viewModel.accounts.observe(viewLifecycleOwner) {
|
||||
adapter.updateAccounts(it)
|
||||
binding.emptyView.visibility = if (it.isEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.BaseAdapter
|
||||
import android.widget.Filter
|
||||
import android.widget.Filterable
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlQrPayClient
|
||||
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.databinding.FragmentBmlQrPayBinding
|
||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.Totp
|
||||
|
||||
class BmlQrPayFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentBmlQrPayBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
private var merchantInfo: BmlQrPayInfo? = null
|
||||
private var selectedAccount: BankAccount? = null
|
||||
|
||||
companion object {
|
||||
private const val ARG_QR_URL = "qr_url"
|
||||
private const val ARG_FROM_ACCOUNT = "from_account"
|
||||
|
||||
fun newInstance(qrUrl: String, fromAccountNumber: String? = null) = BmlQrPayFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putString(ARG_QR_URL, qrUrl)
|
||||
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentBmlQrPayBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
setupFromDropdown()
|
||||
|
||||
binding.etAmount.addTextChangedListener { updatePayButton() }
|
||||
|
||||
binding.btnClearFromInfo.setOnClickListener {
|
||||
selectedAccount = null
|
||||
binding.cardFromInfo.visibility = View.GONE
|
||||
binding.tilFrom.visibility = View.VISIBLE
|
||||
binding.actvFrom.setText("", false)
|
||||
updatePayButton()
|
||||
}
|
||||
|
||||
binding.btnPay.setOnClickListener { initiatePay() }
|
||||
|
||||
val qrUrl = arguments?.getString(ARG_QR_URL) ?: run {
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed(); return
|
||||
}
|
||||
lookupMerchant(qrUrl)
|
||||
}
|
||||
|
||||
private fun setupFromDropdown() {
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||
val bmlAccounts = accounts.filter {
|
||||
it.bank == "BML" &&
|
||||
it.profileType != "BML_LOAN" &&
|
||||
it.profileType != "BML_CREDIT"
|
||||
}
|
||||
val adapter = BmlAccountAdapter(bmlAccounts)
|
||||
binding.actvFrom.setAdapter(adapter)
|
||||
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
|
||||
val picked = adapter.getItem(position) as? BankAccount ?: return@setOnItemClickListener
|
||||
selectedAccount = picked
|
||||
showFromCard(picked)
|
||||
updatePayButton()
|
||||
}
|
||||
|
||||
// Pre-select card passed in from the card wallet/dashboard
|
||||
val preselect = arguments?.getString(ARG_FROM_ACCOUNT)
|
||||
if (preselect != null && selectedAccount == null) {
|
||||
bmlAccounts.firstOrNull { it.accountNumber == preselect }?.let {
|
||||
selectedAccount = it
|
||||
showFromCard(it)
|
||||
updatePayButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFromCard(account: BankAccount) {
|
||||
binding.tvFromAccountName.text = account.accountBriefName
|
||||
binding.tvFromAccountNumber.text = account.accountNumber
|
||||
val currency = account.currencyName.ifBlank { "MVR" }
|
||||
binding.tvFromBalance.text = "$currency ${account.availableBalance}"
|
||||
binding.ivFromPhoto.setImageBitmap(makeInitialsBitmap(account.accountBriefName, "#0066A1"))
|
||||
binding.tilFrom.visibility = View.GONE
|
||||
binding.cardFromInfo.visibility = View.VISIBLE
|
||||
|
||||
// Update amount prefix to match account currency
|
||||
binding.tilAmount.prefixText = if (account.currencyName == "USD") "USD " else "MVR "
|
||||
}
|
||||
|
||||
private fun lookupMerchant(qrUrl: String) {
|
||||
val base64Url = Base64.encodeToString(qrUrl.toByteArray(Charsets.UTF_8), Base64.NO_WRAP)
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val session = app.anyBmlSession() ?: run {
|
||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
return
|
||||
}
|
||||
|
||||
binding.tvLookingUp.visibility = View.VISIBLE
|
||||
binding.cardMerchant.visibility = View.GONE
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val info = withContext(Dispatchers.IO) {
|
||||
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
|
||||
catch (_: Exception) { null }
|
||||
}
|
||||
if (_binding == null) return@launch
|
||||
binding.tvLookingUp.visibility = View.GONE
|
||||
if (info == null) {
|
||||
Toast.makeText(requireContext(), R.string.bml_qr_lookup_failed, Toast.LENGTH_LONG).show()
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
return@launch
|
||||
}
|
||||
merchantInfo = info
|
||||
populateMerchant(info)
|
||||
}
|
||||
}
|
||||
|
||||
private fun populateMerchant(info: BmlQrPayInfo) {
|
||||
binding.tvMerchantName.text = info.merchantName
|
||||
binding.tvMerchantAddress.text = info.merchantAddress
|
||||
binding.ivMerchantIcon.setImageBitmap(makeInitialsBitmap(info.merchantName, "#0066A1"))
|
||||
binding.cardMerchant.visibility = View.VISIBLE
|
||||
|
||||
// Dynamic QR: pre-fill amount and lock the field
|
||||
if (info.amount > 0.0) {
|
||||
binding.etAmount.setText("%.2f".format(info.amount))
|
||||
binding.tilAmount.isEnabled = false
|
||||
}
|
||||
|
||||
updatePayButton()
|
||||
}
|
||||
|
||||
private fun updatePayButton() {
|
||||
val merchant = merchantInfo ?: run { binding.btnPay.isEnabled = false; return }
|
||||
val account = selectedAccount ?: run { binding.btnPay.isEnabled = false; return }
|
||||
val amount = binding.etAmount.text?.toString()?.trim()?.toDoubleOrNull() ?: 0.0
|
||||
binding.btnPay.isEnabled = amount > 0.0
|
||||
}
|
||||
|
||||
private fun initiatePay() {
|
||||
val info = merchantInfo ?: return
|
||||
val account = selectedAccount ?: run {
|
||||
Toast.makeText(requireContext(), R.string.bml_qr_select_account, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
|
||||
val amount = amountStr.toDoubleOrNull()
|
||||
if (amount == null || amount <= 0) {
|
||||
binding.tilAmount.error = "Enter a valid amount"
|
||||
return
|
||||
}
|
||||
binding.tilAmount.error = null
|
||||
|
||||
val debitAccount = account.internalId.ifBlank {
|
||||
Toast.makeText(requireContext(), R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val currency = info.currency
|
||||
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.transfer)
|
||||
.setMessage("Pay $currency ${"%.2f".format(amount)} to ${info.merchantName}?\n\nFrom: ${account.accountBriefName} · ${account.accountNumber}")
|
||||
.setPositiveButton(R.string.transfer_confirm) { _, _ ->
|
||||
executePay(account, debitAccount, info.requestId, amount, currency, info.merchantName)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun executePay(
|
||||
account: BankAccount,
|
||||
debitAccount: String,
|
||||
requestId: String,
|
||||
amount: Double,
|
||||
currency: String,
|
||||
merchantName: String
|
||||
) {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val loginId = account.loginTag.removePrefix("bml_")
|
||||
val session = app.bmlSessionFor(account) ?: run {
|
||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) }
|
||||
?: run {
|
||||
Toast.makeText(requireContext(), "OTP unavailable", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
binding.btnPay.isEnabled = false
|
||||
(activity as? HomeActivity)?.setRefreshing(true)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val initiated = BmlQrPayClient().initiatePayment(session, debitAccount, requestId, amount, currency)
|
||||
if (!initiated) return@withContext null
|
||||
val confirmOtp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) } ?: otp
|
||||
BmlQrPayClient().confirmPayment(session, debitAccount, requestId, amount, currency, confirmOtp)
|
||||
} catch (e: Exception) {
|
||||
sh.sar.basedbank.api.bml.BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
|
||||
}
|
||||
}
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
|
||||
if (_binding == null) return@launch
|
||||
|
||||
if (result == null) {
|
||||
binding.btnPay.isEnabled = true
|
||||
Toast.makeText(requireContext(), "Failed to initiate payment", Toast.LENGTH_LONG).show()
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
showSuccessDialog(
|
||||
merchant = result.merchant.ifBlank { merchantName },
|
||||
amount = result.amount.ifBlank { "%.2f".format(amount) },
|
||||
currency = result.currency.ifBlank { currency }
|
||||
)
|
||||
} else {
|
||||
binding.btnPay.isEnabled = true
|
||||
Toast.makeText(requireContext(), result.errorMessage, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSuccessDialog(merchant: String, amount: String, currency: String) {
|
||||
val ctx = requireContext()
|
||||
val dp = resources.displayMetrics.density
|
||||
|
||||
val container = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
|
||||
}
|
||||
|
||||
// Green checkmark icon
|
||||
container.addView(ImageView(ctx).apply {
|
||||
setImageResource(R.drawable.ic_check_circle)
|
||||
setColorFilter(Color.parseColor("#4CAF50"))
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
(64 * dp).toInt(), (64 * dp).toInt()
|
||||
).apply { gravity = Gravity.CENTER_HORIZONTAL; bottomMargin = (16 * dp).toInt() }
|
||||
})
|
||||
|
||||
// Amount
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = "$currency $amount"
|
||||
textSize = 28f
|
||||
setTypeface(null, android.graphics.Typeface.BOLD)
|
||||
setTextColor(com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorOnSurface, Color.BLACK))
|
||||
gravity = Gravity.CENTER
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply { gravity = Gravity.CENTER_HORIZONTAL; bottomMargin = (8 * dp).toInt() }
|
||||
})
|
||||
|
||||
// Merchant name
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = merchant
|
||||
textSize = 14f
|
||||
setTextColor(com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY))
|
||||
gravity = Gravity.CENTER
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply { gravity = Gravity.CENTER_HORIZONTAL }
|
||||
})
|
||||
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(R.string.bml_qr_payment_success)
|
||||
.setView(container)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun makeInitialsBitmap(name: String, colorHex: String): Bitmap {
|
||||
val sizePx = (resources.displayMetrics.density * 40).toInt()
|
||||
val bgColor = try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY }
|
||||
val bm = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bm)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
paint.color = bgColor
|
||||
canvas.drawCircle(sizePx / 2f, sizePx / 2f, sizePx / 2f, paint)
|
||||
paint.color = Color.WHITE
|
||||
paint.textSize = sizePx * 0.42f
|
||||
paint.textAlign = Paint.Align.CENTER
|
||||
val letter = name.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
val metrics = paint.fontMetrics
|
||||
canvas.drawText(letter, sizePx / 2f, sizePx / 2f - (metrics.ascent + metrics.descent) / 2f, paint)
|
||||
return bm
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = "BML QR Pay"
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private inner class BmlAccountAdapter(private val accounts: List<BankAccount>) :
|
||||
BaseAdapter(), Filterable {
|
||||
|
||||
override fun getCount() = accounts.size
|
||||
override fun getItem(position: Int) = accounts[position]
|
||||
override fun getItemId(position: Int) = position.toLong()
|
||||
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup) =
|
||||
getDropDownView(position, convertView, parent)
|
||||
|
||||
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val acc = accounts[position]
|
||||
val b = if (convertView?.tag is ItemAccountDropdownBinding) {
|
||||
convertView.tag as ItemAccountDropdownBinding
|
||||
} else {
|
||||
ItemAccountDropdownBinding.inflate(LayoutInflater.from(context), parent, false)
|
||||
.also { it.root.tag = it }
|
||||
}
|
||||
val ownerPrefix = if (acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
||||
b.tvDropdownAccountNumber.text = acc.accountNumber
|
||||
b.tvDropdownBalance.text = "${acc.currencyName} ${acc.availableBalance}"
|
||||
b.root.alpha = 1f
|
||||
return b.root
|
||||
}
|
||||
|
||||
override fun getFilter() = object : Filter() {
|
||||
override fun performFiltering(c: CharSequence?) =
|
||||
FilterResults().apply { values = accounts; count = accounts.size }
|
||||
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
|
||||
override fun convertResultToString(r: Any?) =
|
||||
(r as? BankAccount)?.let {
|
||||
val prefix = if (it.profileName.isNotBlank()) "${it.profileName} · " else ""
|
||||
"$prefix${it.accountBriefName}"
|
||||
} ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
// Merged into CardsFragment
|
||||
@@ -31,7 +31,9 @@ class ContactPickerAdapter(
|
||||
val isSameAsFrom: Boolean = false,
|
||||
val isManualEntry: Boolean = false,
|
||||
val imageHash: String? = null,
|
||||
val inactiveReason: String? = null
|
||||
val inactiveReason: String? = null,
|
||||
val balance: String? = null,
|
||||
val bankLogoRes: Int? = null
|
||||
) : PickerItem()
|
||||
}
|
||||
|
||||
@@ -89,14 +91,31 @@ class ContactPickerAdapter(
|
||||
binding.tvPrimary.text = item.displayName
|
||||
binding.tvSecondary.text = item.subtitle
|
||||
|
||||
val cached = item.imageHash?.let { imageCache[it] }
|
||||
if (cached != null) {
|
||||
binding.ivIcon.setImageBitmap(cached)
|
||||
if (item.balance != null) {
|
||||
binding.tvBalance.text = item.balance
|
||||
binding.tvBalance.visibility = android.view.View.VISIBLE
|
||||
} else {
|
||||
val iconChar = if (item.isManualEntry) "→" else item.displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
val iconColor = if (item.isManualEntry) "#546E7A" else item.colorHex
|
||||
binding.ivIcon.setImageBitmap(makeInitialsBitmap(iconChar, iconColor, binding.ivIcon.context))
|
||||
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
|
||||
binding.tvBalance.visibility = android.view.View.GONE
|
||||
}
|
||||
|
||||
val cached = item.imageHash?.let { imageCache[it] }
|
||||
when {
|
||||
cached != null -> {
|
||||
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
binding.ivIcon.setImageBitmap(cached)
|
||||
}
|
||||
item.bankLogoRes != null -> {
|
||||
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivIcon.setImageResource(item.bankLogoRes)
|
||||
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
|
||||
}
|
||||
else -> {
|
||||
binding.ivIcon.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
val iconChar = if (item.isManualEntry) "→" else item.displayName.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
val iconColor = if (item.isManualEntry) "#546E7A" else item.colorHex
|
||||
binding.ivIcon.setImageBitmap(makeInitialsBitmap(iconChar, iconColor, binding.ivIcon.context))
|
||||
if (item.imageHash != null) onImageNeeded?.invoke(item.imageHash)
|
||||
}
|
||||
}
|
||||
|
||||
binding.root.alpha = if (item.isSameAsFrom || item.inactiveReason != null) 0.4f else 1.0f
|
||||
|
||||
@@ -24,7 +24,11 @@ import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.databinding.SheetContactPickerBinding
|
||||
import sh.sar.basedbank.util.AccountListParser
|
||||
import sh.sar.basedbank.util.ProfileImageStore
|
||||
import sh.sar.basedbank.util.RecentsCache
|
||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||
|
||||
class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
@@ -145,8 +149,9 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
viewModel.contacts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||
|
||||
(activity as? HomeActivity)?.loadAllContacts()
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
}
|
||||
|
||||
private fun attachMediator(pages: List<TabDef>) {
|
||||
@@ -183,6 +188,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
private fun buildPageItems(tabTag: String?): List<ContactPickerAdapter.PickerItem> {
|
||||
val search = binding.etSheetSearch.text?.toString()?.trim() ?: ""
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val items = mutableListOf<ContactPickerAdapter.PickerItem>()
|
||||
|
||||
if (tabTag == RECENTS_TAG) {
|
||||
@@ -209,11 +215,11 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
|
||||
val fromCurrency = fromAccount?.currencyName ?: ""
|
||||
val fromLoginTag = fromAccount?.loginTag ?: ""
|
||||
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT"
|
||||
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT" || fromAccount?.profileType == "BML_DEBIT"
|
||||
|
||||
if (tabTag == MY_ACCOUNTS_TAG) {
|
||||
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
|
||||
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
|
||||
val regularAccounts = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" }
|
||||
val cards = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
|
||||
|
||||
val filteredRegular = if (search.isBlank()) regularAccounts else regularAccounts.filter {
|
||||
it.accountBriefName.contains(search, ignoreCase = true) || it.accountNumber.contains(search)
|
||||
@@ -223,16 +229,29 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
for (acc in filteredRegular) {
|
||||
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
|
||||
val isSame = acc.accountNumber == fromAccountNumber
|
||||
val parsedBalance = AccountListParser.from(acc)?.balance
|
||||
?: "${acc.currencyName} ${acc.availableBalance}"
|
||||
val balance = if (hide) maskAmount(parsedBalance) else parsedBalance
|
||||
val logoRes = when (acc.bank) {
|
||||
"BML" -> R.drawable.bml_logo_vector
|
||||
"FAHIPAY" -> R.drawable.fahipay_logo
|
||||
"MIB" -> R.drawable.mib_logo
|
||||
else -> null
|
||||
}
|
||||
val localKey = localImageKeyFor(acc)
|
||||
if (localKey != null) profileImageHashes.add("local:$localKey")
|
||||
items.add(ContactPickerAdapter.PickerItem.Row(
|
||||
accountNumber = acc.accountNumber,
|
||||
displayName = acc.accountBriefName,
|
||||
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}",
|
||||
subtitle = acc.accountNumber,
|
||||
colorHex = "#FE860E",
|
||||
isSameAsFrom = isSame,
|
||||
imageHash = acc.profileImageHash,
|
||||
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
||||
inactiveReason = if (isSame) null
|
||||
else if (fromIsCard && acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
||||
else currencyMismatchReason(fromCurrency, acc.currencyName)
|
||||
else currencyMismatchReason(fromCurrency, acc.currencyName),
|
||||
balance = balance,
|
||||
bankLogoRes = logoRes
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -246,17 +265,26 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
if (acc.profileImageHash != null) profileImageHashes.add(acc.profileImageHash)
|
||||
val isSame = acc.accountNumber == fromAccountNumber
|
||||
val isActive = acc.statusDesc.equals("Active", ignoreCase = true)
|
||||
val isDebit = acc.profileType == "BML_DEBIT"
|
||||
val parsedBalance = if (isDebit) null
|
||||
else AccountListParser.from(acc)?.balance ?: "${acc.currencyName} ${acc.availableBalance}"
|
||||
val balance = parsedBalance?.let { if (hide) maskAmount(it) else it }
|
||||
val logoRes = BmlCardParser.cardNetworkIcon(acc) ?: R.drawable.bml_logo_vector
|
||||
val localKey = localImageKeyFor(acc)
|
||||
if (localKey != null) profileImageHashes.add("local:$localKey")
|
||||
items.add(ContactPickerAdapter.PickerItem.Row(
|
||||
accountNumber = acc.accountNumber,
|
||||
displayName = acc.accountBriefName,
|
||||
subtitle = "${acc.accountNumber} · ${acc.currencyName} ${acc.availableBalance}",
|
||||
subtitle = acc.accountNumber,
|
||||
colorHex = "#FE860E",
|
||||
isSameAsFrom = isSame,
|
||||
imageHash = acc.profileImageHash,
|
||||
imageHash = acc.profileImageHash ?: localKey?.let { "local:$it" },
|
||||
inactiveReason = if (isSame) null
|
||||
else if (!isActive) acc.statusDesc
|
||||
else if (acc.loginTag != fromLoginTag) "Cards can only be used within the same BML account"
|
||||
else currencyMismatchReason(fromCurrency, acc.currencyName)
|
||||
else currencyMismatchReason(fromCurrency, acc.currencyName),
|
||||
balance = balance,
|
||||
bankLogoRes = logoRes
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -287,6 +315,17 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
private fun fetchImage(hash: String) {
|
||||
if (!pendingHashes.add(hash)) return
|
||||
// Local image keys for BML/Fahipay (prefixed with "local:")
|
||||
if (hash.startsWith("local:")) {
|
||||
val key = hash.removePrefix("local:")
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val bitmap = ProfileImageStore.load(requireContext(), key) ?: run {
|
||||
pendingHashes.remove(hash); return@launch
|
||||
}
|
||||
withContext(Dispatchers.Main) { pagerAdapter.updateImage(hash, bitmap) }
|
||||
}
|
||||
return
|
||||
}
|
||||
val sess = session ?: return
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
@@ -306,9 +345,24 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun maskAmount(formatted: String): String {
|
||||
val currency = formatted.substringBefore(' ', formatted)
|
||||
return "$currency ••••••"
|
||||
}
|
||||
|
||||
private fun currencyMismatchReason(fromCurrency: String, toCurrency: String): String? =
|
||||
if (fromCurrency == "MVR" && toCurrency == "USD") "Cannot transfer from MVR to USD account" else null
|
||||
|
||||
/** Returns the ProfileImageStore key for BML/Fahipay accounts, or null for MIB/others. */
|
||||
private fun localImageKeyFor(acc: sh.sar.basedbank.api.models.BankAccount): String? = when (acc.bank) {
|
||||
"BML" -> if (acc.profileId.isNotBlank()) sh.sar.basedbank.util.ProfileImageStore.bmlKey(acc.profileId) else null
|
||||
"FAHIPAY" -> {
|
||||
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(acc.loginTag)
|
||||
if (loginId.isNotBlank()) sh.sar.basedbank.util.ProfileImageStore.fahipayKey(loginId) else null
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
|
||||
@@ -48,7 +48,6 @@ class ContactsFragment : Fragment() {
|
||||
private var currentSearch: String = ""
|
||||
private var mediator: TabLayoutMediator? = null
|
||||
private lateinit var pagerAdapter: ContactsPagerAdapter
|
||||
private var contactsRefreshing = false
|
||||
|
||||
private data class TabPage(val categoryId: String?, val label: String)
|
||||
|
||||
@@ -136,8 +135,8 @@ class ContactsFragment : Fragment() {
|
||||
(activity as? HomeActivity)?.loadAllContacts()
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
contactsRefreshing = true
|
||||
(activity as? HomeActivity)?.loadAllContacts()
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
|
||||
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
|
||||
@@ -149,10 +148,6 @@ class ContactsFragment : Fragment() {
|
||||
pagerAdapter.updateContacts(allContacts)
|
||||
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
|
||||
binding.loadingView.visibility = View.GONE
|
||||
if (contactsRefreshing) {
|
||||
contactsRefreshing = false
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.app.Activity
|
||||
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.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.LinearSnapHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibCard
|
||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||
import sh.sar.basedbank.util.PaymvQrParser
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import kotlin.math.abs
|
||||
import sh.sar.basedbank.databinding.FragmentDashboardBinding
|
||||
import sh.sar.basedbank.databinding.ItemForeignLimitBinding
|
||||
@@ -24,20 +37,46 @@ class DashboardFragment : Fragment() {
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
private var pendingQrAccountNumber: String? = null
|
||||
|
||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, pendingQrAccountNumber)
|
||||
)
|
||||
} 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 {
|
||||
_binding = FragmentDashboardBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
|
||||
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances() }
|
||||
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) { updatePendingFinances() }
|
||||
viewModel.accounts.observe(viewLifecycleOwner) {
|
||||
updateBalances(it)
|
||||
updateAttentionRow()
|
||||
}
|
||||
viewModel.financing.observe(viewLifecycleOwner) {
|
||||
updatePendingFinances()
|
||||
updateAttentionRow()
|
||||
}
|
||||
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) {
|
||||
updatePendingFinances()
|
||||
updateAttentionRow()
|
||||
}
|
||||
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
||||
updateBalances(viewModel.accounts.value ?: emptyList())
|
||||
updatePendingFinances()
|
||||
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
|
||||
updateAttentionRow()
|
||||
}
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
@@ -45,6 +84,40 @@ class DashboardFragment : Fragment() {
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
|
||||
binding.cardPendingFinances.setOnClickListener {
|
||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
|
||||
}
|
||||
|
||||
binding.cardOverdue.setOnClickListener {
|
||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
|
||||
}
|
||||
|
||||
val cardAdapter = DashboardCardAdapter()
|
||||
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
||||
binding.rvCards.adapter = cardAdapter
|
||||
LinearSnapHelper().attachToRecyclerView(binding.rvCards)
|
||||
|
||||
val updateCardList = {
|
||||
val credStore = CredentialStore(requireContext())
|
||||
val hidden = credStore.getHiddenDashboardCardNumbers()
|
||||
val mibItems = (viewModel.mibCards.value ?: emptyList())
|
||||
.filter { !hidden.contains(it.maskedCardNumber) }
|
||||
.map { CardItem.Mib(it) }
|
||||
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
||||
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
|
||||
.map { CardItem.Bml(it) }
|
||||
val all = mibItems + bmlItems
|
||||
val defaultNum = credStore.getDefaultCardAccountNumber()
|
||||
val ordered = if (defaultNum != null) {
|
||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||
if (def != null) listOf(def) + all.filter { it !== def } else all
|
||||
} else all
|
||||
cardAdapter.update(ordered)
|
||||
binding.sectionCards.visibility = if (ordered.isNotEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
|
||||
|
||||
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
|
||||
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||
@@ -64,6 +137,12 @@ class DashboardFragment : Fragment() {
|
||||
|
||||
private fun refreshQuickActions() {
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||
if (isBottom) {
|
||||
binding.buttonBar.visibility = View.GONE
|
||||
return
|
||||
}
|
||||
binding.buttonBar.visibility = View.VISIBLE
|
||||
val ids = NavCustomization.getQuickActions(prefs)
|
||||
listOf(binding.btnQuickAction1, binding.btnQuickAction2).forEachIndexed { i, btn ->
|
||||
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == ids[i] }
|
||||
@@ -78,7 +157,7 @@ class DashboardFragment : Fragment() {
|
||||
private fun updateBalances(accounts: List<BankAccount>) {
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
|
||||
val nonCreditAccounts = accounts.filter { it.profileType != "BML_CREDIT" }
|
||||
val nonCreditAccounts = accounts.filter { it.profileType != "BML_CREDIT" && it.profileType != "BML_LOAN" }
|
||||
val creditAccounts = accounts.filter { it.profileType == "BML_CREDIT" }
|
||||
|
||||
if (hide) {
|
||||
@@ -196,6 +275,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() {
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val mibTotal = (viewModel.financing.value ?: emptyList()).sumOf { it.outstandingAmount }
|
||||
@@ -209,4 +334,73 @@ class DashboardFragment : Fragment() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private inner class DashboardCardAdapter : RecyclerView.Adapter<DashboardCardAdapter.VH>() {
|
||||
private var cards: List<CardItem> = emptyList()
|
||||
|
||||
fun update(newCards: List<CardItem>) {
|
||||
cards = newCards
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val view = LayoutInflater.from(parent.context)
|
||||
.inflate(R.layout.item_card_dashboard, parent, false)
|
||||
return VH(view)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position])
|
||||
override fun getItemCount() = cards.size
|
||||
|
||||
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
|
||||
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
|
||||
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
|
||||
private val tvCardStatus: TextView = view.findViewById(R.id.tvCardStatus)
|
||||
private val btnPayQr: View = view.findViewById(R.id.btnPayQr)
|
||||
private val btnPayNfc: View = view.findViewById(R.id.btnPayNfc)
|
||||
|
||||
fun bind(item: CardItem) {
|
||||
when (item) {
|
||||
is CardItem.Mib -> {
|
||||
tvCardOwner.text = item.card.cardHolderName
|
||||
tvCardNumber.text = CardsFragment.formatMasked(item.card.maskedCardNumber)
|
||||
val assetPath = CardsFragment.cardImageAsset(item.card)
|
||||
if (assetPath != null) CardsFragment.loadCardImage(ivCardImage, assetPath)
|
||||
else ivCardImage.setImageDrawable(null)
|
||||
CardsFragment.bindCardStatus(tvCardStatus, CardsFragment.mibCardStatusLabel(item.card.cardStatus))
|
||||
}
|
||||
is CardItem.Bml -> {
|
||||
tvCardOwner.text = item.account.accountBriefName
|
||||
tvCardNumber.text = CardsFragment.formatMasked(item.account.accountNumber)
|
||||
CardsFragment.loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
|
||||
CardsFragment.bindCardStatus(tvCardStatus, null) // only Active BML cards reach dashboard
|
||||
}
|
||||
}
|
||||
val isMib = item is CardItem.Mib
|
||||
btnPayQr.setOnClickListener {
|
||||
if (isMib) {
|
||||
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
|
||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||
}
|
||||
}
|
||||
val nfcAdapter = android.nfc.NfcAdapter.getDefaultAdapter(requireContext())
|
||||
val nfcSupported = nfcAdapter != null
|
||||
btnPayNfc.isEnabled = nfcSupported
|
||||
btnPayNfc.setOnClickListener {
|
||||
if (isMib) {
|
||||
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
val accountNumber = (item as CardItem.Bml).account.accountNumber
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_pay_with_card,
|
||||
CardsFragment.newInstanceWithAutoTapMode(accountNumber)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ class FinancingFragment : Fragment() {
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
private lateinit var adapter: FinancingAdapter
|
||||
private var financingRefreshing = false
|
||||
|
||||
private var latestMibDeals: List<MibFinanceDeal> = emptyList()
|
||||
private var latestBmlLoanDetails: Map<String, BmlLoanDetail> = emptyMap()
|
||||
@@ -46,8 +45,8 @@ class FinancingFragment : Fragment() {
|
||||
}
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
financingRefreshing = true
|
||||
(activity as? HomeActivity)?.triggerRefreshFinancing()
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { rebuildAdapter() }
|
||||
@@ -74,10 +73,6 @@ class FinancingFragment : Fragment() {
|
||||
binding.recyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
|
||||
binding.emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE
|
||||
binding.loadingView.visibility = View.GONE
|
||||
if (financingRefreshing) {
|
||||
financingRefreshing = false
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
||||
@@ -14,8 +14,10 @@ import android.widget.Toast
|
||||
import sh.sar.basedbank.ui.home.NavCustomization
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.ActionBarDrawerToggle
|
||||
import androidx.core.view.GravityCompat
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
@@ -36,8 +38,9 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.AuthExpiredException
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||
import sh.sar.basedbank.api.bml.BmlActivationResult
|
||||
import sh.sar.basedbank.api.bml.BmlContactsClient
|
||||
import sh.sar.basedbank.api.bml.BmlForeignLimitsClient
|
||||
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||
@@ -54,6 +57,7 @@ import sh.sar.basedbank.ui.login.LoginActivity
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.api.models.BankContactCategory
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.MibCardsClient
|
||||
import sh.sar.basedbank.api.mib.MibFinancingClient
|
||||
import sh.sar.basedbank.api.mib.MibProfile
|
||||
import sh.sar.basedbank.api.mib.MibSession
|
||||
@@ -61,8 +65,10 @@ import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||
import sh.sar.basedbank.util.AccountCache
|
||||
import sh.sar.basedbank.util.ContactsCache
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.CardsCache
|
||||
import sh.sar.basedbank.util.FinancingCache
|
||||
import sh.sar.basedbank.util.ForeignLimitsCache
|
||||
import sh.sar.basedbank.util.ThemeHelper
|
||||
|
||||
class HomeActivity : AppCompatActivity() {
|
||||
|
||||
@@ -71,6 +77,10 @@ class HomeActivity : AppCompatActivity() {
|
||||
private lateinit var toggle: ActionBarDrawerToggle
|
||||
private var suppressBottomNavCallback = false
|
||||
|
||||
private var backPressedOnce = false
|
||||
private val backPressHandler = Handler(Looper.getMainLooper())
|
||||
private val resetBackPress = Runnable { backPressedOnce = false }
|
||||
|
||||
private val autolockHandler = Handler(Looper.getMainLooper())
|
||||
private var warningDialog: AlertDialog? = null
|
||||
private var countdownTimer: CountDownTimer? = null
|
||||
@@ -97,6 +107,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
ThemeHelper.applyAccent(this)
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
binding = ActivityHomeBinding.inflate(layoutInflater)
|
||||
@@ -109,6 +120,21 @@ class HomeActivity : AppCompatActivity() {
|
||||
isAppearanceLightStatusBars = isLight
|
||||
isAppearanceLightNavigationBars = isLight
|
||||
}
|
||||
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
|
||||
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
|
||||
ta.recycle()
|
||||
// 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)
|
||||
|
||||
toggle = ActionBarDrawerToggle(
|
||||
@@ -138,6 +164,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
R.id.nav_finances -> FinancingFragment()
|
||||
R.id.nav_otp -> OtpFragment()
|
||||
R.id.nav_settings -> SettingsFragment()
|
||||
R.id.nav_pay_with_card -> CardsFragment()
|
||||
else -> null
|
||||
}
|
||||
if (frag != null) show(frag)
|
||||
@@ -154,7 +181,6 @@ class HomeActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
// Load data
|
||||
val app = application as BasedBankApp
|
||||
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
|
||||
// Came from fresh manual login — accounts ready, rest fetched in background
|
||||
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts
|
||||
@@ -169,6 +195,8 @@ class HomeActivity : AppCompatActivity() {
|
||||
byLoginId.forEach { (loginId, accs) -> AccountCache.saveFahipay(this, loginId, accs) }
|
||||
}
|
||||
|
||||
val cachedCards = CardsCache.load(this)
|
||||
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
||||
val cachedFinancing = FinancingCache.load(this)
|
||||
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
|
||||
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
|
||||
@@ -182,6 +210,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
}
|
||||
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
|
||||
refreshBmlLoanDetails()
|
||||
triggerRefreshCards()
|
||||
} else {
|
||||
// Came from lock screen — show caches immediately, refresh everything in background
|
||||
val store = CredentialStore(this)
|
||||
@@ -190,6 +219,8 @@ class HomeActivity : AppCompatActivity() {
|
||||
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
|
||||
val merged = cachedMib + cachedBml + cachedFahipay
|
||||
if (merged.isNotEmpty()) viewModel.accounts.value = merged
|
||||
val cachedCards = CardsCache.load(this)
|
||||
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
||||
val cachedFinancing = FinancingCache.load(this)
|
||||
if (cachedFinancing.isNotEmpty()) viewModel.financing.value = cachedFinancing
|
||||
val cachedBmlLoans = FinancingCache.loadBmlLoans(this)
|
||||
@@ -202,12 +233,62 @@ class HomeActivity : AppCompatActivity() {
|
||||
|
||||
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) {
|
||||
show(DashboardFragment())
|
||||
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
||||
val navDest = intent.getIntExtra("nav_destination", -1)
|
||||
val autoScan = intent.getBooleanExtra("auto_scan", false)
|
||||
val autoTapMode = intent.getBooleanExtra("auto_tap_mode", false)
|
||||
if (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 {
|
||||
show(DashboardFragment())
|
||||
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
||||
}
|
||||
}
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
// Close drawer if open (drawer-nav mode)
|
||||
if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) {
|
||||
binding.drawerLayout.closeDrawers()
|
||||
return
|
||||
}
|
||||
// Let CardsFragment handle back if in manage mode
|
||||
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
|
||||
if (currentFrag is CardsFragment && currentFrag.onBackPressed()) return
|
||||
// Pop fragment back stack if there's anything on it (e.g. showWithBackStack)
|
||||
if (supportFragmentManager.backStackEntryCount > 0) {
|
||||
supportFragmentManager.popBackStack()
|
||||
return
|
||||
}
|
||||
// In bottom nav mode, pressing back navigates up the hierarchy
|
||||
val isBottomNav = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||
if (isBottomNav && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
|
||||
// Sub-page reached via More (e.g. Settings, Activities) — go back to More
|
||||
if (binding.bottomNavigation.selectedItemId == R.id.nav_more && currentFrag !is MoreFragment) {
|
||||
show(MoreFragment())
|
||||
return
|
||||
}
|
||||
binding.bottomNavigation.selectedItemId = R.id.nav_dashboard
|
||||
return
|
||||
}
|
||||
// At top level — require double-tap to exit
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Keep all MIB sessions alive every 25 seconds while the app is in the foreground
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
@@ -307,6 +388,7 @@ fun applyNavLabelVisibility() {
|
||||
R.id.nav_finances -> FinancingFragment()
|
||||
R.id.nav_otp -> OtpFragment()
|
||||
R.id.nav_settings -> SettingsFragment()
|
||||
R.id.nav_pay_with_card -> CardsFragment()
|
||||
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
|
||||
}
|
||||
show(dest)
|
||||
@@ -440,9 +522,8 @@ fun applyNavLabelVisibility() {
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
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)
|
||||
eyeItem?.isVisible = eyeEnabled
|
||||
eyeItem?.isVisible = true
|
||||
val hidden = viewModel.hideAmounts.value ?: false
|
||||
eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility)
|
||||
return true
|
||||
@@ -495,14 +576,25 @@ fun applyNavLabelVisibility() {
|
||||
autoRefresh(store)
|
||||
}
|
||||
|
||||
fun showConnectivityBanner(message: String) {
|
||||
binding.connectivityBanner.text = message
|
||||
binding.connectivityBanner.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
fun hideConnectivityBanner() {
|
||||
binding.connectivityBanner.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun autoRefresh(store: CredentialStore) {
|
||||
val mibLoginIds = store.getMibLoginIds()
|
||||
val bmlLoginIds = store.getBmlLoginIds()
|
||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) return
|
||||
binding.refreshIndicator.visibility = View.VISIBLE
|
||||
hideConnectivityBanner()
|
||||
|
||||
lifecycleScope.launch {
|
||||
val refreshErrors = ConcurrentLinkedQueue<String>()
|
||||
// One async job per MIB login, all run in parallel
|
||||
val mibJobs = mibLoginIds.mapNotNull { loginId ->
|
||||
val creds = store.loadMibCredentials(loginId) ?: return@mapNotNull null
|
||||
@@ -516,39 +608,84 @@ fun applyNavLabelVisibility() {
|
||||
app.mibLoginFlows[loginId] = flow
|
||||
store.saveMibProfiles(loginId, flow.lastProfiles)
|
||||
accounts
|
||||
} catch (_: Exception) { AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" } }
|
||||
} catch (e: java.io.IOException) {
|
||||
refreshErrors.add("NO_INTERNET")
|
||||
AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" }
|
||||
} catch (e: BankServerException) {
|
||||
refreshErrors.add("SERVER:${e.bankName}")
|
||||
AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" }
|
||||
} catch (_: Exception) {
|
||||
AccountCache.load(this@HomeActivity).filter { it.loginTag == "mib_$loginId" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One async job per BML login, all run in parallel
|
||||
val bmlJobs = bmlLoginIds.mapNotNull { loginId ->
|
||||
val creds = store.loadBmlCredentials(loginId) ?: return@mapNotNull null
|
||||
val bmlJobs = bmlLoginIds.map { loginId ->
|
||||
loginId to async(Dispatchers.IO) {
|
||||
val loginTag = "bml_$loginId"
|
||||
val app = application as BasedBankApp
|
||||
val savedProfiles = store.loadBmlProfiles(loginId)
|
||||
val allAccounts = mutableListOf<BankAccount>()
|
||||
var anyExpired = savedProfiles.isEmpty()
|
||||
|
||||
// Try each saved profile's cached session
|
||||
for (profile in savedProfiles) {
|
||||
val saved = store.loadBmlProfileSession(profile.profileId)
|
||||
if (saved != null) {
|
||||
try {
|
||||
val session = BmlSession(saved.first, saved.second)
|
||||
val accounts = BmlAccountClient().fetchAccounts(session, loginTag, profile.name, profile.profileId)
|
||||
app.bmlSessions[profile.profileId] = session
|
||||
allAccounts += accounts
|
||||
} catch (_: AuthExpiredException) { anyExpired = true
|
||||
} catch (_: Exception) { anyExpired = true }
|
||||
} else {
|
||||
anyExpired = true
|
||||
}
|
||||
}
|
||||
|
||||
if (savedProfiles.isNotEmpty()) app.bmlProfilesMap[loginId] = savedProfiles
|
||||
|
||||
// Also try legacy single-profile session token (pre-multi-profile installs)
|
||||
val bmlClient = BmlAccountClient()
|
||||
for (profile in savedProfiles) {
|
||||
val saved = store.loadBmlProfileSession(profile.profileId)
|
||||
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 {
|
||||
if (tokenKnownExpired) {
|
||||
tryRefresh()
|
||||
} else {
|
||||
try {
|
||||
fetchWithSession(BmlSession(saved.first, saved.second))
|
||||
} catch (_: AuthExpiredException) {
|
||||
tryRefresh()
|
||||
}
|
||||
}
|
||||
} catch (e: java.io.IOException) {
|
||||
refreshErrors.add("NO_INTERNET")
|
||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||
.filter { it.profileId == profile.profileId }
|
||||
} catch (e: BankServerException) {
|
||||
refreshErrors.add("SERVER:${e.bankName}")
|
||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||
.filter { it.profileId == profile.profileId }
|
||||
} catch (_: Exception) {
|
||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||
.filter { it.profileId == profile.profileId }
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy single-profile session (pre-multi-profile installs)
|
||||
if (savedProfiles.isEmpty()) {
|
||||
val legacyToken = store.loadBmlSession(loginId)
|
||||
if (legacyToken != null) {
|
||||
@@ -557,47 +694,17 @@ fun applyNavLabelVisibility() {
|
||||
val accounts = BmlAccountClient().fetchAccounts(session, loginTag)
|
||||
app.bmlSessions[loginId] = session
|
||||
allAccounts += accounts
|
||||
anyExpired = false
|
||||
} catch (_: AuthExpiredException) { anyExpired = true
|
||||
} catch (_: Exception) { anyExpired = true }
|
||||
}
|
||||
}
|
||||
|
||||
if (anyExpired || allAccounts.isEmpty()) {
|
||||
// Re-authenticate to refresh personal profile sessions
|
||||
try {
|
||||
val flow = app.bmlFlowFor(loginId)
|
||||
val profiles = flow.login(creds.username, creds.password, creds.otpSeed)
|
||||
store.saveBmlProfiles(loginId, profiles)
|
||||
app.bmlProfilesMap[loginId] = profiles
|
||||
|
||||
for (profile in profiles) {
|
||||
if (profile.profileType == "business") {
|
||||
// Can't activate business profiles without user OTP — use cached
|
||||
val cached = AccountCache.loadBml(this@HomeActivity, loginId)
|
||||
.filter { it.profileId == profile.profileId }
|
||||
if (allAccounts.none { it.profileId == profile.profileId })
|
||||
allAccounts += cached
|
||||
continue
|
||||
}
|
||||
try {
|
||||
val result = flow.activateProfile(profile, loginTag)
|
||||
if (result is BmlActivationResult.Success) {
|
||||
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
|
||||
app.bmlSessions[profile.profileId] = result.session
|
||||
allAccounts.removeAll { it.profileId == profile.profileId }
|
||||
allAccounts += result.accounts
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
if (allAccounts.none { it.profileId == profile.profileId }) {
|
||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||
.filter { it.profileId == profile.profileId }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
if (allAccounts.isEmpty())
|
||||
} catch (e: java.io.IOException) {
|
||||
refreshErrors.add("NO_INTERNET")
|
||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||
} catch (e: BankServerException) {
|
||||
refreshErrors.add("SERVER:${e.bankName}")
|
||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||
} catch (_: Exception) {
|
||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||
}
|
||||
} else {
|
||||
allAccounts += AccountCache.loadBml(this@HomeActivity, loginId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,6 +751,12 @@ fun applyNavLabelVisibility() {
|
||||
app.fahipaySessions[loginId] = session
|
||||
AccountCache.saveFahipay(this@HomeActivity, loginId, accounts)
|
||||
accounts
|
||||
} catch (e: java.io.IOException) {
|
||||
refreshErrors.add("NO_INTERNET")
|
||||
AccountCache.loadFahipay(this@HomeActivity, loginId)
|
||||
} catch (e: BankServerException) {
|
||||
refreshErrors.add("SERVER:${e.bankName}")
|
||||
AccountCache.loadFahipay(this@HomeActivity, loginId)
|
||||
} catch (_: Exception) {
|
||||
AccountCache.loadFahipay(this@HomeActivity, loginId)
|
||||
}
|
||||
@@ -663,12 +776,32 @@ fun applyNavLabelVisibility() {
|
||||
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts())
|
||||
binding.refreshIndicator.visibility = View.GONE
|
||||
|
||||
val noInternet = refreshErrors.any { it == "NO_INTERNET" }
|
||||
val serverErrors = refreshErrors.filter { it.startsWith("SERVER:") }
|
||||
.map { it.removePrefix("SERVER:") }.distinct()
|
||||
when {
|
||||
noInternet -> showConnectivityBanner(getString(R.string.connectivity_no_internet))
|
||||
serverErrors.isNotEmpty() -> showConnectivityBanner(
|
||||
getString(R.string.connectivity_server_error, serverErrors.joinToString(", "))
|
||||
)
|
||||
else -> hideConnectivityBanner()
|
||||
}
|
||||
|
||||
val errors = mutableSetOf<String>()
|
||||
if (noInternet) errors.add("NO_INTERNET")
|
||||
serverErrors.forEach { errors.add(it.uppercase()) }
|
||||
viewModel.connectivityErrors.postValue(errors)
|
||||
|
||||
for ((_, session) in app.bmlSessions) refreshBmlLimits(session)
|
||||
for ((loginId, session) in app.mibSessions) {
|
||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||
refreshFinancing(loginId, session, profiles.filterVisibleProfiles(loginId))
|
||||
}
|
||||
refreshBmlLoanDetails()
|
||||
for ((loginId, session) in app.mibSessions) {
|
||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||
refreshMibCards(loginId, session, profiles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -932,6 +1065,44 @@ fun applyNavLabelVisibility() {
|
||||
}
|
||||
}
|
||||
|
||||
fun triggerRefreshCards() {
|
||||
val app = application as BasedBankApp
|
||||
for ((loginId, session) in app.mibSessions) {
|
||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||
refreshMibCards(loginId, session, profiles)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshMibCards(loginId: String, session: MibSession, profiles: List<MibProfile>) {
|
||||
if (profiles.isEmpty()) return
|
||||
val flow = (application as BasedBankApp).mibFlowFor(loginId)
|
||||
val client = MibCardsClient()
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val cards = withContext(Dispatchers.IO) {
|
||||
val result = mutableListOf<sh.sar.basedbank.api.mib.MibCard>()
|
||||
val seen = mutableSetOf<String>()
|
||||
for (profile in profiles) {
|
||||
try {
|
||||
flow.switchProfile(session, profile)
|
||||
for (card in client.fetchCards(session, "mib_$loginId")) {
|
||||
if (seen.add(card.cardId)) result += card
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
result
|
||||
}
|
||||
if (cards.isNotEmpty()) {
|
||||
val existing = viewModel.mibCards.value?.toMutableList() ?: mutableListOf()
|
||||
existing.removeAll { it.loginTag == "mib_$loginId" }
|
||||
existing += cards
|
||||
viewModel.mibCards.postValue(existing)
|
||||
CardsCache.save(this@HomeActivity, existing)
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshFinancing(loginId: String, session: MibSession, profiles: List<MibProfile>) {
|
||||
if (profiles.isEmpty()) return
|
||||
val flow = (application as BasedBankApp).mibFlowFor(loginId)
|
||||
|
||||
@@ -7,8 +7,14 @@ import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.api.models.BankContactCategory
|
||||
import sh.sar.basedbank.api.mib.MibCard
|
||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||
|
||||
sealed class CardItem {
|
||||
data class Mib(val card: MibCard) : CardItem()
|
||||
data class Bml(val account: BankAccount) : CardItem()
|
||||
}
|
||||
|
||||
class HomeViewModel : ViewModel() {
|
||||
val accounts = MutableLiveData<List<BankAccount>>(emptyList())
|
||||
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
|
||||
@@ -20,5 +26,14 @@ class HomeViewModel : ViewModel() {
|
||||
data class BmlLimitsData(val userName: String, val limits: List<BmlForeignLimit>)
|
||||
val bmlLimits = MutableLiveData<List<BmlLimitsData>>(emptyList())
|
||||
|
||||
val mibCards = MutableLiveData<List<MibCard>?>(null)
|
||||
|
||||
val hideAmounts = MutableLiveData<Boolean>(false)
|
||||
|
||||
/**
|
||||
* Set of connectivity error keys from the last refresh.
|
||||
* Contains "NO_INTERNET" for no network, or uppercase bank names ("MIB", "BML", "FAHIPAY")
|
||||
* for HTTP 5xx server errors from specific banks.
|
||||
*/
|
||||
val connectivityErrors = MutableLiveData<Set<String>>(emptySet())
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ class MoreFragment : Fragment() {
|
||||
val row = inflater.inflate(R.layout.item_more_nav, list, false)
|
||||
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
|
||||
row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
|
||||
row.findViewById<TextView>(R.id.tvDescription).setText(item.descriptionRes)
|
||||
row.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(item.id) }
|
||||
list.addView(row)
|
||||
}
|
||||
|
||||
@@ -9,47 +9,55 @@ object NavCustomization {
|
||||
|
||||
data class NavItemDef(
|
||||
val id: Int,
|
||||
val key: String,
|
||||
@DrawableRes val iconRes: Int,
|
||||
@StringRes val titleRes: Int
|
||||
@StringRes val titleRes: Int,
|
||||
@StringRes val descriptionRes: Int
|
||||
)
|
||||
|
||||
/** All items that can occupy either a bottom nav slot or the "More" screen. */
|
||||
val ALL_SWAPPABLE = listOf(
|
||||
NavItemDef(R.id.nav_accounts, R.drawable.ic_nav_accounts, R.string.nav_accounts),
|
||||
NavItemDef(R.id.nav_contacts, R.drawable.ic_contacts, R.string.nav_contacts),
|
||||
NavItemDef(R.id.nav_transfer, R.drawable.ic_send, R.string.transfer),
|
||||
NavItemDef(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr),
|
||||
NavItemDef(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities),
|
||||
NavItemDef(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history),
|
||||
NavItemDef(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances),
|
||||
NavItemDef(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings),
|
||||
NavItemDef(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp),
|
||||
NavItemDef(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings),
|
||||
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_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_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_activities, "nav_activities", R.drawable.ic_nav_activities, R.string.nav_activities, R.string.nav_desc_activities),
|
||||
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_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_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),
|
||||
)
|
||||
|
||||
private fun keyToId(key: String?, default: Int) =
|
||||
ALL_SWAPPABLE.find { it.key == key }?.id ?: default
|
||||
|
||||
private fun idToKey(id: Int) =
|
||||
ALL_SWAPPABLE.find { it.id == id }?.key
|
||||
|
||||
fun getSlots(prefs: SharedPreferences): List<Int> = listOf(
|
||||
prefs.getInt("bottom_nav_slot_1", R.id.nav_accounts),
|
||||
prefs.getInt("bottom_nav_slot_2", R.id.nav_contacts),
|
||||
prefs.getInt("bottom_nav_slot_3", R.id.nav_transfer),
|
||||
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_3_key", null), R.id.nav_transfer),
|
||||
)
|
||||
|
||||
fun saveSlots(prefs: SharedPreferences, slots: List<Int>) {
|
||||
prefs.edit()
|
||||
.putInt("bottom_nav_slot_1", slots[0])
|
||||
.putInt("bottom_nav_slot_2", slots[1])
|
||||
.putInt("bottom_nav_slot_3", slots[2])
|
||||
.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_3_key", idToKey(slots[2]) ?: "nav_transfer")
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getQuickActions(prefs: SharedPreferences): List<Int> = listOf(
|
||||
prefs.getInt("quick_action_1", R.id.nav_transfer),
|
||||
prefs.getInt("quick_action_2", R.id.nav_pay_mv_qr),
|
||||
keyToId(prefs.getString("quick_action_1_key", null), R.id.nav_transfer),
|
||||
keyToId(prefs.getString("quick_action_2_key", null), R.id.nav_pay_mv_qr),
|
||||
)
|
||||
|
||||
fun saveQuickActions(prefs: SharedPreferences, ids: List<Int>) {
|
||||
prefs.edit()
|
||||
.putInt("quick_action_1", ids[0])
|
||||
.putInt("quick_action_2", ids[1])
|
||||
.putString("quick_action_1_key", idToKey(ids[0]) ?: "nav_transfer")
|
||||
.putString("quick_action_2_key", idToKey(ids[1]) ?: "nav_pay_mv_qr")
|
||||
.apply()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
@@ -42,6 +47,16 @@ class OtpFragment : Fragment() {
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
holder.b.tvOtpLabel.text = entries[position].label
|
||||
update(holder.b, entries[position].seed)
|
||||
holder.b.root.setOnClickListener {
|
||||
val code = holder.b.tvOtpCode.text.toString().replace(" ", "")
|
||||
if (code.isNotEmpty()) {
|
||||
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
Toast.makeText(it.context, "OTP copied", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun tick() {
|
||||
|
||||
@@ -32,11 +32,16 @@ import kotlinx.coroutines.Job
|
||||
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.api.models.BankAccount
|
||||
import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||
import sh.sar.basedbank.util.AccountListParser
|
||||
import sh.sar.basedbank.util.PaymvQrParser
|
||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
@@ -49,10 +54,19 @@ class PayMvQrFragment : Fragment() {
|
||||
private var selectedAccount: BankAccount? = null
|
||||
private var generatedBitmap: Bitmap? = null
|
||||
private var generateJob: Job? = null
|
||||
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
||||
|
||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
|
||||
// BML card/gateway QR — hand off to dedicated payment screen
|
||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw))
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
val qr = PaymvQrParser.parse(raw)
|
||||
if (qr == null || qr.accountNumber == null) {
|
||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||
@@ -77,12 +91,16 @@ class PayMvQrFragment : Fragment() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val basePaddingBottom = view.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.updatePadding(bottom = basePaddingBottom + navBar.bottom)
|
||||
val bottomNav = activity?.findViewById<View>(R.id.bottomNavigation)
|
||||
val navBarBottom = if (bottomNav?.visibility == View.VISIBLE) 0
|
||||
else insets.getInsets(WindowInsetsCompat.Type.systemBars()).bottom
|
||||
v.updatePadding(bottom = basePaddingBottom + navBarBottom)
|
||||
insets
|
||||
}
|
||||
setupDropdown()
|
||||
binding.etAmount.addTextChangedListener { scheduleGenerate() }
|
||||
binding.etReference.addTextChangedListener { scheduleGenerate() }
|
||||
binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() }
|
||||
binding.btnShare.isEnabled = false
|
||||
binding.btnSave.isEnabled = false
|
||||
binding.btnShare.setOnClickListener { shareQr() }
|
||||
@@ -95,7 +113,9 @@ class PayMvQrFragment : Fragment() {
|
||||
private fun setupDropdown() {
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||
val eligible = accounts.filter {
|
||||
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT"
|
||||
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" &&
|
||||
it.bank != "MIB" && // TODO: MIB does not support PayMV QR
|
||||
!(it.bank == "BML" && it.currencyName.contains("USD", ignoreCase = true)) // TODO: BML USD not supported by MMA
|
||||
}
|
||||
val adapter = QrAccountAdapter(requireContext(), eligible)
|
||||
binding.actvAccount.setAdapter(adapter)
|
||||
@@ -130,8 +150,28 @@ class PayMvQrFragment : Fragment() {
|
||||
?.let { "%.2f".format(it) }
|
||||
|
||||
val ctx = requireContext()
|
||||
val includePhone = binding.switchIncludePhone.isChecked
|
||||
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
|
||||
val store = CredentialStore(ctx)
|
||||
val mobile = if (includePhone) {
|
||||
when (account.bank) {
|
||||
"BML" -> store.loadBmlUserProfile(loginId)?.mobile
|
||||
"FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile
|
||||
else -> null
|
||||
}?.let { m ->
|
||||
when {
|
||||
m.startsWith("+") -> m
|
||||
m.length == 7 -> "+960$m"
|
||||
else -> m
|
||||
}
|
||||
}
|
||||
} else null
|
||||
|
||||
val purpose = binding.etReference.text?.toString()?.trim()
|
||||
?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default)
|
||||
|
||||
val bmp = withContext(Dispatchers.Default) {
|
||||
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted)
|
||||
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted, mobile, purpose)
|
||||
renderQrCard(ctx, account, payload, amountFormatted)
|
||||
}
|
||||
if (_binding == null) return
|
||||
@@ -149,7 +189,9 @@ class PayMvQrFragment : Fragment() {
|
||||
accountNumber: String,
|
||||
accountName: String,
|
||||
acquirer: String,
|
||||
amountStr: String?
|
||||
amountStr: String?,
|
||||
mobile: String?,
|
||||
purpose: String
|
||||
): String {
|
||||
fun tlv(tag: String, value: String): String {
|
||||
val len = value.length
|
||||
@@ -159,17 +201,30 @@ class PayMvQrFragment : Fragment() {
|
||||
val poi = tlv("01", "11")
|
||||
val sub00 = tlv("00", "mv.favara.mpqr")
|
||||
val sub01 = tlv("01", acquirer)
|
||||
val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app
|
||||
val sub03 = tlv("03", accountNumber)
|
||||
val sub05 = if (!mobile.isNullOrBlank()) tlv("05", mobile) else ""
|
||||
val sub10 = tlv("10", "IPAY")
|
||||
val merchantAcct = tlv("26", sub00 + sub01 + sub03 + sub10)
|
||||
val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10)
|
||||
val mcc = tlv("52", "0000")
|
||||
val currency = tlv("53", "462")
|
||||
val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else ""
|
||||
val country = tlv("58", "MV")
|
||||
val name = tlv("59", accountName.take(25))
|
||||
val prefix = format + poi + merchantAcct + currency + amountTLV + country + name + "6304"
|
||||
val ref = generateReference()
|
||||
val addlData = tlv("62", tlv("05", ref) + tlv("08", purpose))
|
||||
val timestamp = java.time.LocalDateTime.now()
|
||||
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.00000"))
|
||||
val tag80 = tlv("80", tlv("00", "mv.favara.mpqr") + tlv("01", timestamp))
|
||||
val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + addlData + tag80 + "6304"
|
||||
return prefix + crc16(prefix)
|
||||
}
|
||||
|
||||
private fun generateReference(): String {
|
||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
return (1..9).map { chars.random() }.joinToString("")
|
||||
}
|
||||
|
||||
private fun crc16(data: String): String {
|
||||
var crc = 0xFFFF
|
||||
for (c in data) {
|
||||
@@ -400,9 +455,105 @@ class PayMvQrFragment : Fragment() {
|
||||
}
|
||||
val ownerPrefix = if (acc.bank == "BML" && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||
b.tvDropdownAccountName.text = "$ownerPrefix${acc.accountBriefName}"
|
||||
|
||||
val displayData = AccountListParser.from(acc)
|
||||
val typeLabel = displayData?.typeLabel
|
||||
?: if (acc.bank == "BML") BmlDashboardParser.productLabel(acc.accountTypeName)
|
||||
else acc.accountTypeName.trim()
|
||||
b.tvDropdownAccountNumber.text = acc.accountNumber
|
||||
b.tvDropdownBalance.text = ""
|
||||
if (typeLabel.isNotBlank()) {
|
||||
b.tvDropdownAccountType.text = typeLabel
|
||||
b.tvDropdownAccountType.visibility = View.VISIBLE
|
||||
} else {
|
||||
b.tvDropdownAccountType.visibility = View.GONE
|
||||
}
|
||||
b.tvDropdownBalance.visibility = View.GONE
|
||||
b.root.alpha = 1f
|
||||
|
||||
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
|
||||
when {
|
||||
networkIcon != null -> {
|
||||
b.ivDropdownCardLogo.setImageResource(networkIcon)
|
||||
b.ivDropdownCardLogo.visibility = View.VISIBLE
|
||||
}
|
||||
acc.bank == "BML" -> {
|
||||
val localKey = sh.sar.basedbank.util.ProfileImageStore.bmlKey(acc.profileId)
|
||||
val cachedLocal = dropdownProfileImageCache[localKey]
|
||||
val imageView = b.ivDropdownCardLogo
|
||||
imageView.tag = localKey
|
||||
if (cachedLocal != null) {
|
||||
imageView.setImageBitmap(cachedLocal)
|
||||
} else {
|
||||
imageView.setImageResource(R.drawable.bml_logo_vector)
|
||||
if (acc.profileId.isNotBlank()) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
sh.sar.basedbank.util.ProfileImageStore.load(requireContext(), localKey)
|
||||
}
|
||||
if (bitmap != null) {
|
||||
dropdownProfileImageCache[localKey] = bitmap
|
||||
if (imageView.tag == localKey) imageView.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
imageView.visibility = View.VISIBLE
|
||||
}
|
||||
acc.bank == "FAHIPAY" -> {
|
||||
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(acc.loginTag)
|
||||
val localKey = sh.sar.basedbank.util.ProfileImageStore.fahipayKey(loginId)
|
||||
val cachedLocal = dropdownProfileImageCache[localKey]
|
||||
val imageView = b.ivDropdownCardLogo
|
||||
imageView.tag = localKey
|
||||
if (cachedLocal != null) {
|
||||
imageView.setImageBitmap(cachedLocal)
|
||||
} else {
|
||||
imageView.setImageResource(R.drawable.fahipay_logo)
|
||||
if (loginId.isNotBlank()) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
sh.sar.basedbank.util.ProfileImageStore.load(requireContext(), localKey)
|
||||
}
|
||||
if (bitmap != null) {
|
||||
dropdownProfileImageCache[localKey] = bitmap
|
||||
if (imageView.tag == localKey) imageView.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
imageView.visibility = View.VISIBLE
|
||||
}
|
||||
acc.bank == "MIB" -> {
|
||||
val hash = acc.profileImageHash
|
||||
val cached = hash?.let { dropdownProfileImageCache[it] }
|
||||
val imageView = b.ivDropdownCardLogo
|
||||
imageView.tag = hash
|
||||
if (cached != null) {
|
||||
imageView.setImageBitmap(cached)
|
||||
} else {
|
||||
imageView.setImageResource(R.drawable.mib_logo)
|
||||
if (hash != null) {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val bitmap = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val sess = app.anyMibSession() ?: return@withContext null
|
||||
val b64 = app.anyMibFlow()?.fetchProfileImage(sess, hash) ?: return@withContext null
|
||||
val bytes = android.util.Base64.decode(b64, android.util.Base64.DEFAULT)
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
if (bitmap != null) {
|
||||
dropdownProfileImageCache[hash] = bitmap
|
||||
if (imageView.tag == hash) imageView.setImageBitmap(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
imageView.visibility = View.VISIBLE
|
||||
}
|
||||
else -> b.ivDropdownCardLogo.visibility = View.GONE
|
||||
}
|
||||
return b.root
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.Animatable
|
||||
import android.view.ScaleGestureDetector
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.camera.core.Camera
|
||||
import androidx.camera.core.CameraSelector
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
@@ -31,8 +37,10 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.ActivityQrScannerBinding
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class QrScannerActivity : AppCompatActivity() {
|
||||
@@ -52,6 +60,8 @@ class QrScannerActivity : AppCompatActivity() {
|
||||
textMode = ZxingCpp.TextMode.PLAIN
|
||||
)
|
||||
|
||||
private var camera: Camera? = null
|
||||
private var torchEnabled = false
|
||||
private var cameraStarted = false
|
||||
|
||||
private val permissionLauncher = registerForActivityResult(
|
||||
@@ -87,6 +97,14 @@ class QrScannerActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
if (CredentialStore(this).loadSecurityHash() != null &&
|
||||
!(application as BasedBankApp).isUnlocked) {
|
||||
startActivity(Intent(this, sh.sar.basedbank.LockActivity::class.java))
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
binding = ActivityQrScannerBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
@@ -104,8 +122,36 @@ class QrScannerActivity : AppCompatActivity() {
|
||||
}
|
||||
insets
|
||||
}
|
||||
binding.btnCancel.setOnClickListener { finish() }
|
||||
binding.btnPickImage.setOnClickListener { pickImageLauncher.launch("image/*") }
|
||||
binding.zoomSlider.addOnChangeListener { _, value, fromUser ->
|
||||
if (fromUser) camera?.cameraControl?.setLinearZoom(value)
|
||||
}
|
||||
val scaleDetector = ScaleGestureDetector(this,
|
||||
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
|
||||
override fun onScale(detector: ScaleGestureDetector): Boolean {
|
||||
val state = camera?.cameraInfo?.zoomState?.value ?: return true
|
||||
camera?.cameraControl?.setZoomRatio(
|
||||
(state.zoomRatio * detector.scaleFactor)
|
||||
.coerceIn(state.minZoomRatio, state.maxZoomRatio)
|
||||
)
|
||||
return true
|
||||
}
|
||||
})
|
||||
binding.previewView.setOnTouchListener { _, event ->
|
||||
scaleDetector.onTouchEvent(event)
|
||||
true
|
||||
}
|
||||
binding.btnFlashlight.setOnClickListener {
|
||||
torchEnabled = !torchEnabled
|
||||
camera?.cameraControl?.enableTorch(torchEnabled)
|
||||
val drawableRes = if (torchEnabled) R.drawable.ic_flashlight_to_on else R.drawable.ic_flashlight_to_off
|
||||
val drawable = AppCompatResources.getDrawable(this, drawableRes)
|
||||
binding.btnFlashlight.icon = drawable
|
||||
(drawable as? Animatable)?.start()
|
||||
binding.btnFlashlight.iconTint = ColorStateList.valueOf(
|
||||
if (torchEnabled) Color.parseColor("#FFEB3B") else Color.WHITE
|
||||
)
|
||||
}
|
||||
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
@@ -179,9 +225,12 @@ class QrScannerActivity : AppCompatActivity() {
|
||||
|
||||
try {
|
||||
provider.unbindAll()
|
||||
provider.bindToLifecycle(
|
||||
camera = provider.bindToLifecycle(
|
||||
this, CameraSelector.DEFAULT_BACK_CAMERA, preview, analysis
|
||||
)
|
||||
camera?.cameraInfo?.zoomState?.observe(this@QrScannerActivity) { state ->
|
||||
binding.zoomSlider.value = state.linearZoom
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
finish()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.text.InputType
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
@@ -10,6 +12,7 @@ import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
|
||||
import androidx.core.os.LocaleListCompat
|
||||
@@ -18,8 +21,11 @@ import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentSettingsAppearanceBinding
|
||||
import sh.sar.basedbank.util.ThemeHelper
|
||||
import java.util.Collections
|
||||
|
||||
class SettingsAppearanceFragment : Fragment() {
|
||||
@@ -54,19 +60,29 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
// Quick actions
|
||||
quickActions.clear()
|
||||
quickActions.addAll(NavCustomization.getQuickActions(prefs))
|
||||
quickActionAdapter = NavItemAdapter(quickActions) {
|
||||
NavCustomization.saveQuickActions(prefs, quickActions)
|
||||
quickActionAdapter = NavItemAdapter(
|
||||
items = quickActions,
|
||||
onSave = { NavCustomization.saveQuickActions(prefs, quickActions) },
|
||||
isEnabled = { !prefs.getBoolean("bottom_nav", false) }
|
||||
)
|
||||
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions) {
|
||||
!prefs.getBoolean("bottom_nav", false)
|
||||
}
|
||||
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions)
|
||||
|
||||
// Bottom bar shortcuts
|
||||
slots.clear()
|
||||
slots.addAll(NavCustomization.getSlots(prefs))
|
||||
slotAdapter = NavItemAdapter(slots) {
|
||||
NavCustomization.saveSlots(prefs, slots)
|
||||
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
|
||||
slotAdapter = NavItemAdapter(
|
||||
items = slots,
|
||||
onSave = {
|
||||
NavCustomization.saveSlots(prefs, slots)
|
||||
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
|
||||
},
|
||||
isEnabled = { prefs.getBoolean("bottom_nav", false) }
|
||||
)
|
||||
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) {
|
||||
prefs.getBoolean("bottom_nav", false)
|
||||
}
|
||||
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots)
|
||||
// Show labels toggle
|
||||
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
|
||||
binding.switchShowLabels.isChecked = showLabels
|
||||
@@ -93,8 +109,45 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
}
|
||||
prefs.edit().putString("theme", key).apply()
|
||||
AppCompatDelegate.setDefaultNightMode(mode)
|
||||
updateAccentState(key == "system")
|
||||
updatePitchBlackState(key == "dark")
|
||||
}
|
||||
|
||||
// Pitch black
|
||||
binding.switchPitchBlack.isChecked = prefs.getBoolean("pitch_black", false)
|
||||
binding.switchPitchBlack.setOnCheckedChangeListener { _, checked ->
|
||||
prefs.edit().putBoolean("pitch_black", checked).apply()
|
||||
requireActivity().recreate()
|
||||
}
|
||||
val isDark = prefs.getString("theme", "system") == "dark"
|
||||
updatePitchBlackState(isDark)
|
||||
|
||||
// Accent color
|
||||
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
||||
binding.accentToggle.check(when (savedPreset) {
|
||||
ThemeHelper.PRESET_RED -> R.id.btnAccentOrange
|
||||
ThemeHelper.PRESET_GREEN -> R.id.btnAccentGreen
|
||||
ThemeHelper.PRESET_CUSTOM -> R.id.btnAccentCustom
|
||||
else -> R.id.btnAccentBlue
|
||||
})
|
||||
binding.accentToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||
if (!isChecked) return@addOnButtonCheckedListener
|
||||
val preset = when (checkedId) {
|
||||
R.id.btnAccentOrange -> ThemeHelper.PRESET_RED
|
||||
R.id.btnAccentGreen -> ThemeHelper.PRESET_GREEN
|
||||
R.id.btnAccentCustom -> ThemeHelper.PRESET_CUSTOM
|
||||
else -> ThemeHelper.PRESET_BLUE
|
||||
}
|
||||
if (preset == ThemeHelper.PRESET_CUSTOM) {
|
||||
showCustomColorPicker()
|
||||
} else {
|
||||
prefs.edit().putString("accent_preset", preset).apply()
|
||||
requireActivity().recreate()
|
||||
}
|
||||
}
|
||||
val isSystem = prefs.getString("theme", "system") == "system"
|
||||
updateAccentState(isSystem)
|
||||
|
||||
// Language
|
||||
val currentLocales = AppCompatDelegate.getApplicationLocales()
|
||||
val currentLang = if (currentLocales.isEmpty) "en" else currentLocales[0]?.language ?: "en"
|
||||
@@ -109,13 +162,18 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
private fun setupNavItemRecyclerView(
|
||||
rv: RecyclerView,
|
||||
adapter: NavItemAdapter,
|
||||
items: MutableList<Int>
|
||||
items: MutableList<Int>,
|
||||
isEnabled: () -> Boolean
|
||||
) {
|
||||
rv.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
||||
rv.adapter = adapter
|
||||
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
||||
ItemTouchHelper.START or ItemTouchHelper.END, 0
|
||||
) {
|
||||
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
|
||||
if (!isEnabled()) return 0
|
||||
return super.getMovementFlags(recyclerView, viewHolder)
|
||||
}
|
||||
override fun onMove(
|
||||
rv: RecyclerView,
|
||||
from: RecyclerView.ViewHolder,
|
||||
@@ -134,11 +192,79 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
|
||||
private fun updateShortcutsVisibility() {
|
||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||
binding.sectionQuickActions.alpha = if (isBottom) 0.38f else 1f
|
||||
binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
|
||||
binding.switchShowLabels.isClickable = isBottom
|
||||
quickActionAdapter.notifyDataSetChanged()
|
||||
slotAdapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private fun updatePitchBlackState(isDark: Boolean) {
|
||||
binding.rowPitchBlack.alpha = if (isDark) 1f else 0.38f
|
||||
binding.switchPitchBlack.isEnabled = isDark
|
||||
}
|
||||
|
||||
private fun updateAccentState(isSystem: Boolean) {
|
||||
binding.sectionAccentColor.alpha = if (isSystem) 0.38f else 1f
|
||||
for (i in 0 until binding.accentToggle.childCount) {
|
||||
binding.accentToggle.getChildAt(i)?.isEnabled = !isSystem
|
||||
}
|
||||
}
|
||||
|
||||
private fun showCustomColorPicker() {
|
||||
val ctx = requireContext()
|
||||
val currentHex = prefs.getString("accent_custom_color", "") ?: ""
|
||||
val inputLayout = TextInputLayout(ctx).apply {
|
||||
hint = getString(R.string.accent_custom_hint)
|
||||
val pad = (16 * resources.displayMetrics.density).toInt()
|
||||
setPadding(pad, pad / 2, pad, 0)
|
||||
}
|
||||
val input = TextInputEditText(ctx).apply {
|
||||
setText(currentHex)
|
||||
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
|
||||
setSingleLine(true)
|
||||
}
|
||||
inputLayout.addView(input)
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(R.string.accent_custom_pick)
|
||||
.setView(inputLayout)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.setNegativeButton(R.string.cancel) { _, _ -> revertAccentToggle() }
|
||||
.setOnCancelListener { revertAccentToggle() }
|
||||
.show()
|
||||
|
||||
dialog.getButton(androidx.appcompat.app.AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||
val raw = input.text.toString().trim()
|
||||
val hex = if (raw.startsWith("#")) raw else "#$raw"
|
||||
try {
|
||||
Color.parseColor(hex)
|
||||
prefs.edit()
|
||||
.putString("accent_preset", ThemeHelper.PRESET_CUSTOM)
|
||||
.putString("accent_custom_color", hex)
|
||||
.apply()
|
||||
dialog.dismiss()
|
||||
requireActivity().recreate()
|
||||
} catch (_: Exception) {
|
||||
Toast.makeText(ctx, R.string.accent_invalid_color, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun revertAccentToggle() {
|
||||
val saved = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
||||
binding.accentToggle.check(when (saved) {
|
||||
ThemeHelper.PRESET_RED -> R.id.btnAccentOrange
|
||||
ThemeHelper.PRESET_GREEN -> R.id.btnAccentGreen
|
||||
ThemeHelper.PRESET_CUSTOM -> R.id.btnAccentCustom
|
||||
else -> R.id.btnAccentBlue
|
||||
})
|
||||
}
|
||||
|
||||
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
|
||||
if (items === slots && !prefs.getBoolean("bottom_nav", false)) return
|
||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||
if (items === slots && !isBottom) return
|
||||
if (items === quickActions && isBottom) return
|
||||
val ctx = requireContext()
|
||||
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
|
||||
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
|
||||
@@ -147,6 +273,7 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
LayoutInflater.from(ctx).inflate(R.layout.item_more_nav, listLayout, false).also { row ->
|
||||
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.iconRes)
|
||||
row.findViewById<TextView>(R.id.tvLabel).setText(item.titleRes)
|
||||
row.findViewById<TextView>(R.id.tvDescription).visibility = View.GONE
|
||||
listLayout.addView(row)
|
||||
}
|
||||
}
|
||||
@@ -169,7 +296,8 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
|
||||
private inner class NavItemAdapter(
|
||||
val items: MutableList<Int>,
|
||||
val onSave: () -> Unit
|
||||
val onSave: () -> Unit,
|
||||
val isEnabled: () -> Boolean = { true }
|
||||
) : RecyclerView.Adapter<NavItemAdapter.VH>() {
|
||||
|
||||
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||
@@ -191,7 +319,12 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == items[position] } ?: return
|
||||
holder.ivNavIcon.setImageResource(def.iconRes)
|
||||
holder.tvNavLabel.setText(def.titleRes)
|
||||
holder.itemView.setOnClickListener { showItemPicker(items, holder.adapterPosition, this) }
|
||||
val enabled = isEnabled()
|
||||
holder.itemView.setOnClickListener(
|
||||
if (enabled) View.OnClickListener { showItemPicker(items, holder.adapterPosition, this) }
|
||||
else null
|
||||
)
|
||||
holder.itemView.isClickable = enabled
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,14 +17,15 @@ class SettingsFragment : Fragment() {
|
||||
private data class SettingsItem(
|
||||
@DrawableRes val icon: Int,
|
||||
@StringRes val title: Int,
|
||||
@StringRes val description: Int,
|
||||
val dest: () -> Fragment
|
||||
)
|
||||
|
||||
private val items = listOf(
|
||||
SettingsItem(R.drawable.ic_contacts, R.string.settings_logins) { SettingsLoginsFragment() },
|
||||
SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance) { SettingsAppearanceFragment() },
|
||||
SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security) { SettingsSecurityFragment() },
|
||||
SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage) { SettingsStorageFragment() },
|
||||
SettingsItem(R.drawable.ic_contacts, R.string.settings_logins, R.string.settings_desc_logins) { SettingsLoginsFragment() },
|
||||
SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance, R.string.settings_desc_appearance) { SettingsAppearanceFragment() },
|
||||
SettingsItem(R.drawable.ic_lock, R.string.settings_privacy_security, R.string.settings_desc_privacy_security) { SettingsSecurityFragment() },
|
||||
SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage, R.string.settings_desc_storage) { SettingsStorageFragment() },
|
||||
)
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
||||
@@ -37,6 +38,7 @@ class SettingsFragment : Fragment() {
|
||||
val row = inflater.inflate(R.layout.item_more_nav, list, false)
|
||||
row.findViewById<ImageView>(R.id.ivIcon).setImageResource(item.icon)
|
||||
row.findViewById<TextView>(R.id.tvLabel).setText(item.title)
|
||||
row.findViewById<TextView>(R.id.tvDescription).setText(item.description)
|
||||
row.setOnClickListener {
|
||||
(requireActivity() as HomeActivity).showWithBackStack(item.dest())
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.provider.Settings as AndroidSettings
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
@@ -10,6 +17,9 @@ import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
@@ -22,11 +32,13 @@ import sh.sar.basedbank.api.mib.TransactionCache
|
||||
import sh.sar.basedbank.databinding.FragmentSettingsLoginsBinding
|
||||
import sh.sar.basedbank.ui.login.LoginActivity
|
||||
import sh.sar.basedbank.util.AccountCache
|
||||
import sh.sar.basedbank.util.CardsCache
|
||||
import sh.sar.basedbank.util.ContactImageCache
|
||||
import sh.sar.basedbank.util.ContactsCache
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.FinancingCache
|
||||
import sh.sar.basedbank.util.ForeignLimitsCache
|
||||
import sh.sar.basedbank.util.ProfileImageStore
|
||||
import sh.sar.basedbank.util.RecentsCache
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlin.coroutines.resume
|
||||
@@ -37,6 +49,8 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.api.bml.BmlActivationResult
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
|
||||
class SettingsLoginsFragment : Fragment() {
|
||||
|
||||
@@ -44,6 +58,275 @@ class SettingsLoginsFragment : Fragment() {
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
// ── Profile image picker state ─────────────────────────────────────────────
|
||||
|
||||
private sealed class PendingImageTarget {
|
||||
data class Mib(val loginId: String, val profile: MibProfile) : PendingImageTarget()
|
||||
data class Bml(val profileId: String, val refreshImage: (Bitmap?) -> Unit) : PendingImageTarget()
|
||||
data class Fahipay(val loginId: String, val refreshImage: (Bitmap?) -> Unit) : PendingImageTarget()
|
||||
}
|
||||
private var pendingImageTarget: PendingImageTarget? = null
|
||||
private var cameraPhotoUri: Uri? = null
|
||||
|
||||
private val galleryLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) return@registerForActivityResult
|
||||
val bitmap = loadAndScaleBitmap(uri) ?: return@registerForActivityResult
|
||||
handlePickedImage(bitmap)
|
||||
}
|
||||
|
||||
private val cameraLauncher = registerForActivityResult(ActivityResultContracts.TakePicture()) { success ->
|
||||
if (!success) return@registerForActivityResult
|
||||
val uri = cameraPhotoUri ?: return@registerForActivityResult
|
||||
val bitmap = loadAndScaleBitmap(uri) ?: return@registerForActivityResult
|
||||
handlePickedImage(bitmap)
|
||||
}
|
||||
|
||||
private val cameraPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) cameraLauncher.launch(cameraPhotoUri ?: return@registerForActivityResult)
|
||||
else showCameraPermissionRationale()
|
||||
}
|
||||
|
||||
private fun showCameraPermissionRationale() {
|
||||
val ctx = requireContext()
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(R.string.qr_camera_permission_title)
|
||||
.setMessage(R.string.camera_permission_profile_message)
|
||||
.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() }
|
||||
.setPositiveButton(R.string.go_to_settings) { _, _ ->
|
||||
startActivity(
|
||||
Intent(AndroidSettings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||
data = Uri.fromParts("package", ctx.packageName, null)
|
||||
}
|
||||
)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun loadAndScaleBitmap(uri: Uri): Bitmap? {
|
||||
return try {
|
||||
val ctx = requireContext()
|
||||
val inputStream = ctx.contentResolver.openInputStream(uri) ?: return null
|
||||
val original = BitmapFactory.decodeStream(inputStream)
|
||||
inputStream.close()
|
||||
if (original == null) return null
|
||||
val maxDim = 512
|
||||
val scale = minOf(maxDim.toFloat() / original.width, maxDim.toFloat() / original.height, 1f)
|
||||
if (scale < 1f) {
|
||||
Bitmap.createScaledBitmap(original, (original.width * scale).toInt(), (original.height * scale).toInt(), true)
|
||||
} else original
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
private fun handlePickedImage(bitmap: Bitmap) {
|
||||
val target = pendingImageTarget ?: return
|
||||
when (target) {
|
||||
is PendingImageTarget.Mib -> uploadMibProfileImage(target.loginId, target.profile, bitmap)
|
||||
is PendingImageTarget.Bml -> {
|
||||
ProfileImageStore.save(requireContext(), ProfileImageStore.bmlKey(target.profileId), bitmap)
|
||||
target.refreshImage(bitmap)
|
||||
}
|
||||
is PendingImageTarget.Fahipay -> {
|
||||
ProfileImageStore.save(requireContext(), ProfileImageStore.fahipayKey(target.loginId), bitmap)
|
||||
target.refreshImage(bitmap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun uploadMibProfileImage(loginId: String, profile: MibProfile, bitmap: Bitmap) {
|
||||
val ctx = requireContext()
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val progress = MaterialAlertDialogBuilder(ctx)
|
||||
.setMessage(getString(R.string.profile_image_uploading))
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val session = app.mibSessions[loginId] ?: app.anyMibSession() ?: return@withContext null
|
||||
val flow = app.mibLoginFlows[loginId] ?: return@withContext null
|
||||
flow.switchProfile(session, profile)
|
||||
// MIB server enforces a small payload limit (~4KB); scale to 100px max
|
||||
val mibMax = 100
|
||||
val mibScale = minOf(mibMax.toFloat() / bitmap.width, mibMax.toFloat() / bitmap.height, 1f)
|
||||
val mibBitmap = if (mibScale < 1f)
|
||||
Bitmap.createScaledBitmap(bitmap, (bitmap.width * mibScale).toInt(), (bitmap.height * mibScale).toInt(), true)
|
||||
else bitmap
|
||||
val baos = ByteArrayOutputStream()
|
||||
mibBitmap.compress(Bitmap.CompressFormat.JPEG, 50, baos)
|
||||
val base64 = Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
|
||||
flow.uploadProfileImage(session, profile, base64)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
progress.dismiss()
|
||||
if (result == null) {
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setMessage(getString(R.string.profile_image_upload_failed))
|
||||
.setPositiveButton(R.string.close, null)
|
||||
.show()
|
||||
} else {
|
||||
clearAllCaches(ctx)
|
||||
(activity as? HomeActivity)?.relogin()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showImagePickerMenu(anchor: View, target: PendingImageTarget, currentBitmap: Bitmap?) {
|
||||
val ctx = requireContext()
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
|
||||
val items = mutableListOf<Triple<Int, String, () -> Unit>>()
|
||||
items += Triple(R.drawable.ic_image, getString(R.string.profile_image_select)) {
|
||||
pendingImageTarget = target
|
||||
galleryLauncher.launch("image/*")
|
||||
}
|
||||
items += Triple(R.drawable.ic_camera, getString(R.string.profile_image_camera)) {
|
||||
pendingImageTarget = target
|
||||
val photoFile = File(ctx.cacheDir, "profile_photo_tmp.jpg")
|
||||
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", photoFile)
|
||||
cameraPhotoUri = uri
|
||||
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||
cameraLauncher.launch(uri)
|
||||
} else {
|
||||
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||
}
|
||||
}
|
||||
if (target is PendingImageTarget.Mib || currentBitmap != null || hasSavedImage(ctx, target)) {
|
||||
items += Triple(R.drawable.ic_delete, getString(R.string.profile_image_remove)) {
|
||||
removeProfileImage(ctx, target)
|
||||
}
|
||||
}
|
||||
|
||||
val list = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
val vp = (8 * dp).toInt()
|
||||
setPadding(0, vp, 0, vp)
|
||||
}
|
||||
for ((iconRes, label, action) in items) {
|
||||
val iconSize = (24 * dp).toInt()
|
||||
val row = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||
background = ta.getDrawable(0); ta.recycle()
|
||||
isClickable = true; isFocusable = true
|
||||
val hp = (24 * dp).toInt(); val vp2 = (12 * dp).toInt()
|
||||
setPadding(hp, vp2, hp, vp2)
|
||||
}
|
||||
val iconColor = com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK)
|
||||
val icon = ImageView(ctx).apply {
|
||||
setImageResource(iconRes)
|
||||
imageTintList = android.content.res.ColorStateList.valueOf(iconColor)
|
||||
}
|
||||
val tv = TextView(ctx).apply {
|
||||
text = label
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
|
||||
marginStart = (12 * dp).toInt()
|
||||
}
|
||||
}
|
||||
row.addView(icon, LinearLayout.LayoutParams(iconSize, iconSize))
|
||||
row.addView(tv)
|
||||
list.addView(row)
|
||||
}
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(R.string.profile_image_title)
|
||||
.setView(list)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
|
||||
val rows = (0 until list.childCount).map { list.getChildAt(it) }
|
||||
rows.forEachIndexed { i, row ->
|
||||
row.setOnClickListener {
|
||||
dialog.dismiss()
|
||||
items[i].third()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasSavedImage(ctx: Context, target: PendingImageTarget): Boolean = when (target) {
|
||||
is PendingImageTarget.Mib -> target.profile.customerImage != null
|
||||
is PendingImageTarget.Bml -> ProfileImageStore.exists(ctx, ProfileImageStore.bmlKey(target.profileId))
|
||||
is PendingImageTarget.Fahipay -> ProfileImageStore.exists(ctx, ProfileImageStore.fahipayKey(target.loginId))
|
||||
}
|
||||
|
||||
private fun removeProfileImage(ctx: Context, target: PendingImageTarget) {
|
||||
when (target) {
|
||||
is PendingImageTarget.Mib -> {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val progress = MaterialAlertDialogBuilder(ctx)
|
||||
.setMessage(getString(R.string.profile_image_deleting))
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val session = app.mibSessions[target.loginId] ?: app.anyMibSession() ?: return@withContext
|
||||
val flow = app.mibLoginFlows[target.loginId] ?: return@withContext
|
||||
flow.switchProfile(session, target.profile)
|
||||
flow.deleteProfileImage(session, target.profile)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
progress.dismiss()
|
||||
clearAllCaches(ctx)
|
||||
(activity as? HomeActivity)?.relogin()
|
||||
}
|
||||
}
|
||||
is PendingImageTarget.Bml -> {
|
||||
ProfileImageStore.delete(ctx, ProfileImageStore.bmlKey(target.profileId))
|
||||
target.refreshImage(null)
|
||||
}
|
||||
is PendingImageTarget.Fahipay -> {
|
||||
ProfileImageStore.delete(ctx, ProfileImageStore.fahipayKey(target.loginId))
|
||||
target.refreshImage(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun makePencilButton(ctx: Context): ImageView {
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val size = (32 * dp).toInt()
|
||||
return ImageView(ctx).apply {
|
||||
setImageResource(R.drawable.ic_edit)
|
||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackgroundBorderless))
|
||||
background = ta.getDrawable(0); ta.recycle()
|
||||
isClickable = true; isFocusable = true
|
||||
layoutParams = LinearLayout.LayoutParams(size, size).apply {
|
||||
marginStart = (4 * dp).toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeCircleAvatarView(ctx: Context, sizeDp: Int): ImageView {
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val size = (sizeDp * dp).toInt()
|
||||
return ImageView(ctx).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(size, size)
|
||||
scaleType = ImageView.ScaleType.CENTER_CROP
|
||||
clipToOutline = true
|
||||
outlineProvider = object : android.view.ViewOutlineProvider() {
|
||||
override fun getOutline(view: View, outline: android.graphics.Outline) {
|
||||
outline.setOval(0, 0, view.width, view.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setAvatarBitmap(iv: ImageView, bitmap: Bitmap?) {
|
||||
if (bitmap != null) {
|
||||
iv.setImageBitmap(bitmap)
|
||||
iv.imageTintList = null
|
||||
} else {
|
||||
iv.setImageResource(R.drawable.ic_image)
|
||||
val color = com.google.android.material.color.MaterialColors.getColor(
|
||||
iv, com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK)
|
||||
iv.imageTintList = android.content.res.ColorStateList.valueOf(color)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentSettingsLoginsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
@@ -100,18 +383,7 @@ class SettingsLoginsFragment : Fragment() {
|
||||
val profile = store.loadFahipayUserProfile(loginId)
|
||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
||||
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val masked = "••••••"
|
||||
showLoginDetails(
|
||||
title = getString(R.string.fahipay_name),
|
||||
details = buildString {
|
||||
if (!profile?.fullName.isNullOrBlank()) appendLine("${getString(R.string.login_detail_name)}: ${profile!!.fullName}")
|
||||
if (!profile?.email.isNullOrBlank()) appendLine("${getString(R.string.login_detail_email)}: ${if (hide) masked else profile!!.email}")
|
||||
if (!profile?.mobile.isNullOrBlank()) appendLine("${getString(R.string.login_detail_mobile)}: ${if (hide) masked else profile!!.mobile}")
|
||||
if (!profile?.nid.isNullOrBlank()) appendLine("${getString(R.string.login_detail_id_card)}: ${if (hide) masked else profile!!.nid}")
|
||||
}.trim(),
|
||||
onLogout = { confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) } }
|
||||
)
|
||||
showFahipayLoginDetails(store, loginId, profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,8 +490,21 @@ class SettingsLoginsFragment : Fragment() {
|
||||
alpha = 0.6f
|
||||
})
|
||||
}
|
||||
val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
|
||||
val pencil = makePencilButton(ctx).apply {
|
||||
alpha = 0.38f
|
||||
isEnabled = false
|
||||
}
|
||||
val toggle = MaterialSwitch(ctx).apply {
|
||||
isChecked = p.profileId !in hidden
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
marginStart = (4 * dp).toInt()
|
||||
}
|
||||
}
|
||||
pencil.setOnClickListener {
|
||||
android.widget.Toast.makeText(ctx, "Work in progress", android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
row.addView(textCol)
|
||||
row.addView(pencil)
|
||||
row.addView(toggle)
|
||||
container.addView(row)
|
||||
p to toggle
|
||||
@@ -327,6 +612,10 @@ class SettingsLoginsFragment : Fragment() {
|
||||
}
|
||||
|
||||
val toggleRows = bmlProfiles.map { p ->
|
||||
val avatarIv = makeCircleAvatarView(ctx, 36)
|
||||
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
|
||||
setAvatarBitmap(avatarIv, currentBitmap)
|
||||
|
||||
val row = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
@@ -349,8 +638,24 @@ class SettingsLoginsFragment : Fragment() {
|
||||
alpha = 0.6f
|
||||
})
|
||||
}
|
||||
val toggle = MaterialSwitch(ctx).apply { isChecked = p.profileId !in hidden }
|
||||
val pencil = makePencilButton(ctx)
|
||||
val toggle = MaterialSwitch(ctx).apply {
|
||||
isChecked = p.profileId !in hidden
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
marginStart = (4 * dp).toInt()
|
||||
}
|
||||
}
|
||||
val target = PendingImageTarget.Bml(p.profileId) { newBitmap ->
|
||||
setAvatarBitmap(avatarIv, newBitmap)
|
||||
}
|
||||
pencil.setOnClickListener {
|
||||
val cur = ProfileImageStore.load(ctx, ProfileImageStore.bmlKey(p.profileId))
|
||||
showImagePickerMenu(pencil, target, cur)
|
||||
}
|
||||
val avatarSize = (36 * dp).toInt()
|
||||
row.addView(avatarIv, LinearLayout.LayoutParams(avatarSize, avatarSize).apply { marginEnd = (8 * dp).toInt() })
|
||||
row.addView(textCol)
|
||||
row.addView(pencil)
|
||||
row.addView(toggle)
|
||||
container.addView(row)
|
||||
p to toggle
|
||||
@@ -433,6 +738,10 @@ class SettingsLoginsFragment : Fragment() {
|
||||
return when (activationResult) {
|
||||
is BmlActivationResult.Success -> {
|
||||
store.saveBmlProfileSession(profile.profileId, activationResult.session.accessToken, activationResult.session.deviceId)
|
||||
if (activationResult.session.refreshToken.isNotBlank())
|
||||
store.saveBmlProfileRefreshToken(profile.profileId, activationResult.session.refreshToken)
|
||||
if (activationResult.session.expiresAt > 0)
|
||||
store.saveBmlProfileExpiresAt(profile.profileId, activationResult.session.expiresAt)
|
||||
true
|
||||
}
|
||||
is BmlActivationResult.NeedsBusinessOtp ->
|
||||
@@ -475,6 +784,10 @@ class SettingsLoginsFragment : Fragment() {
|
||||
}
|
||||
verifyProgress.dismiss()
|
||||
store.saveBmlProfileSession(profile.profileId, session.accessToken, session.deviceId)
|
||||
if (session.refreshToken.isNotBlank())
|
||||
store.saveBmlProfileRefreshToken(profile.profileId, session.refreshToken)
|
||||
if (session.expiresAt > 0)
|
||||
store.saveBmlProfileExpiresAt(profile.profileId, session.expiresAt)
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
verifyProgress.dismiss()
|
||||
@@ -606,6 +919,75 @@ class SettingsLoginsFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun showFahipayLoginDetails(
|
||||
store: CredentialStore,
|
||||
loginId: String,
|
||||
profile: CredentialStore.FahipayUserProfile?
|
||||
) {
|
||||
val ctx = requireContext()
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val masked = "••••••"
|
||||
|
||||
val scroll = android.widget.ScrollView(ctx)
|
||||
val container = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
val pad = (16 * dp).toInt()
|
||||
setPadding(pad, (8 * dp).toInt(), pad, pad)
|
||||
}
|
||||
scroll.addView(container)
|
||||
|
||||
// Avatar row with pencil
|
||||
val avatarRow = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
bottomMargin = (12 * dp).toInt()
|
||||
}
|
||||
}
|
||||
val avatarSize = (56 * dp).toInt()
|
||||
val avatarIv = makeCircleAvatarView(ctx, 56)
|
||||
val currentBitmap = ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
|
||||
setAvatarBitmap(avatarIv, currentBitmap)
|
||||
|
||||
val pencil = makePencilButton(ctx)
|
||||
val target = PendingImageTarget.Fahipay(loginId) { newBitmap ->
|
||||
setAvatarBitmap(avatarIv, newBitmap)
|
||||
}
|
||||
pencil.setOnClickListener {
|
||||
val cur = ProfileImageStore.load(ctx, ProfileImageStore.fahipayKey(loginId))
|
||||
showImagePickerMenu(pencil, target, cur)
|
||||
}
|
||||
avatarRow.addView(avatarIv, LinearLayout.LayoutParams(avatarSize, avatarSize).apply { marginEnd = (8 * dp).toInt() })
|
||||
avatarRow.addView(pencil)
|
||||
container.addView(avatarRow)
|
||||
|
||||
// Account info lines
|
||||
listOfNotNull(
|
||||
profile?.fullName?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
|
||||
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: ${if (hide) masked else it}" },
|
||||
profile?.mobile?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: ${if (hide) masked else it}" },
|
||||
profile?.nid?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_id_card)}: ${if (hide) masked else it}" }
|
||||
).forEach { line ->
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = line
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
bottomMargin = (4 * dp).toInt()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(getString(R.string.fahipay_name))
|
||||
.setView(scroll)
|
||||
.setPositiveButton(R.string.close, null)
|
||||
.setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||
confirmLogout(getString(R.string.fahipay_name)) { logoutFahipay(store, loginId) }
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showLoginDetails(title: String, details: String, onLogout: () -> Unit) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(title)
|
||||
@@ -634,6 +1016,7 @@ class SettingsLoginsFragment : Fragment() {
|
||||
app.mibLoginFlows.remove(loginId)
|
||||
app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" }
|
||||
app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" }
|
||||
viewModel.financing.value = emptyList()
|
||||
clearAllCaches(ctx)
|
||||
(activity as HomeActivity).relogin()
|
||||
buildLoginsSection()
|
||||
@@ -644,12 +1027,17 @@ class SettingsLoginsFragment : Fragment() {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
// Remove all per-profile sessions for this login from the in-memory map
|
||||
val profiles = app.bmlProfilesMap[loginId] ?: emptyList()
|
||||
profiles.forEach { app.bmlSessions.remove(it.profileId) }
|
||||
profiles.forEach { p ->
|
||||
app.bmlSessions.remove(p.profileId)
|
||||
ProfileImageStore.delete(ctx, ProfileImageStore.bmlKey(p.profileId))
|
||||
}
|
||||
// clearBmlCredentials also clears per-profile tokens via loadBmlProfiles internally
|
||||
store.clearBmlCredentials(loginId)
|
||||
app.bmlProfilesMap.remove(loginId)
|
||||
app.bmlLoginFlows.remove(loginId)
|
||||
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" }
|
||||
viewModel.bmlLimits.value = emptyList()
|
||||
viewModel.bmlLoanDetails.value = emptyMap()
|
||||
clearAllCaches(ctx)
|
||||
(activity as HomeActivity).relogin()
|
||||
buildLoginsSection()
|
||||
@@ -657,6 +1045,7 @@ class SettingsLoginsFragment : Fragment() {
|
||||
|
||||
private fun logoutFahipay(store: CredentialStore, loginId: String) {
|
||||
val ctx = requireContext()
|
||||
ProfileImageStore.delete(ctx, ProfileImageStore.fahipayKey(loginId))
|
||||
store.clearFahipayCredentials(loginId)
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
app.fahipaySessions.remove(loginId)
|
||||
@@ -669,6 +1058,7 @@ class SettingsLoginsFragment : Fragment() {
|
||||
private fun clearAllCaches(ctx: Context) {
|
||||
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
||||
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
||||
TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
|
||||
CardsCache.clear(ctx); TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
|
||||
// Note: ProfileImageStore is intentionally NOT cleared here — profile images are user-set data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,15 @@ class SettingsSecurityFragment : Fragment() {
|
||||
)
|
||||
}
|
||||
|
||||
// Auto unlock on correct PIN (only for pin method)
|
||||
if (prefs.getString("security_method", null) == "pin") {
|
||||
binding.rowAutoUnlockPin.visibility = View.VISIBLE
|
||||
binding.switchAutoUnlockPin.isChecked = prefs.getBoolean("auto_unlock_pin", false)
|
||||
binding.switchAutoUnlockPin.setOnCheckedChangeListener { _, isChecked ->
|
||||
prefs.edit().putBoolean("auto_unlock_pin", isChecked).apply()
|
||||
}
|
||||
}
|
||||
|
||||
// Biometrics
|
||||
val canUseBiometrics = BiometricManager.from(requireContext())
|
||||
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
@@ -77,17 +86,6 @@ class SettingsSecurityFragment : Fragment() {
|
||||
(activity as? HomeActivity)?.resetAutolockTimer()
|
||||
}
|
||||
|
||||
// Hide sensitive information (enables/disables the eye icon in toolbar)
|
||||
val viewModel = (requireActivity() as HomeActivity).let {
|
||||
androidx.lifecycle.ViewModelProvider(it)[HomeViewModel::class.java]
|
||||
}
|
||||
binding.switchHideAmounts.isChecked = prefs.getBoolean("hide_sensitive_info", false)
|
||||
binding.switchHideAmounts.setOnCheckedChangeListener { _, isChecked ->
|
||||
prefs.edit().putBoolean("hide_sensitive_info", isChecked).apply()
|
||||
if (!isChecked) viewModel.hideAmounts.value = false
|
||||
requireActivity().invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
// Block screenshots
|
||||
val blockScreenshots = prefs.getBoolean("block_screenshots", true)
|
||||
binding.switchBlockScreenshots.isChecked = blockScreenshots
|
||||
|
||||
@@ -7,10 +7,12 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.TransactionCache
|
||||
import sh.sar.basedbank.databinding.FragmentSettingsStorageBinding
|
||||
import sh.sar.basedbank.util.AccountCache
|
||||
import sh.sar.basedbank.util.CardsCache
|
||||
import sh.sar.basedbank.util.ContactImageCache
|
||||
import sh.sar.basedbank.util.ContactsCache
|
||||
import sh.sar.basedbank.util.FinancingCache
|
||||
@@ -21,6 +23,7 @@ class SettingsStorageFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentSettingsStorageBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentSettingsStorageBinding.inflate(inflater, container, false)
|
||||
@@ -32,6 +35,7 @@ class SettingsStorageFragment : Fragment() {
|
||||
val ctx = requireContext()
|
||||
clearAllCaches(ctx)
|
||||
Toast.makeText(ctx, R.string.settings_cache_cleared, Toast.LENGTH_SHORT).show()
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +52,13 @@ class SettingsStorageFragment : Fragment() {
|
||||
private fun clearAllCaches(ctx: Context) {
|
||||
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
||||
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
||||
TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
|
||||
CardsCache.clear(ctx); TransactionCache.clearAll(ctx); ContactImageCache.clearAll(ctx)
|
||||
viewModel.accounts.value = emptyList()
|
||||
viewModel.mibCards.value = null
|
||||
viewModel.financing.value = emptyList()
|
||||
viewModel.bmlLoanDetails.value = emptyMap()
|
||||
viewModel.bmlLimits.value = emptyList()
|
||||
viewModel.contacts.value = emptyList()
|
||||
viewModel.contactCategories.value = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import sh.sar.basedbank.api.mib.TransactionCache
|
||||
import sh.sar.basedbank.databinding.FragmentTransferHistoryBinding
|
||||
@@ -64,7 +65,7 @@ class TransferHistoryFragment : Fragment() {
|
||||
) {
|
||||
fun hasMore(): Boolean = when {
|
||||
account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2
|
||||
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT" -> cardMonthOffset < 2
|
||||
account.bank == "BML" -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||
}
|
||||
@@ -161,8 +162,15 @@ class TransferHistoryFragment : Fragment() {
|
||||
val accounts = accountStates.map { it.account }
|
||||
accountStates.clear()
|
||||
accounts.forEach { accountStates.add(AccountState(it)) }
|
||||
adapter.setTransactions(emptyList())
|
||||
binding.emptyView.visibility = View.GONE
|
||||
// Restore cache immediately so data stays visible while refreshing
|
||||
val cached = TransactionCache.load(requireContext(), "transfer")
|
||||
if (cached.isNotEmpty()) {
|
||||
allTransactions.addAll(cached)
|
||||
filterAndDisplay()
|
||||
} else {
|
||||
adapter.setTransactions(emptyList())
|
||||
binding.emptyView.visibility = View.GONE
|
||||
}
|
||||
loadNextPages()
|
||||
}
|
||||
|
||||
@@ -178,6 +186,14 @@ class TransferHistoryFragment : Fragment() {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
|
||||
lifecycleScope.launch {
|
||||
val bannerMsg = java.util.concurrent.atomic.AtomicReference<String?>(null)
|
||||
fun trackError(e: Exception) {
|
||||
when {
|
||||
e is java.io.IOException -> bannerMsg.compareAndSet(null, "IO")
|
||||
e is BankServerException -> bannerMsg.compareAndSet(null, "SERVER:${e.bankName}")
|
||||
}
|
||||
}
|
||||
|
||||
val newTransactions = withContext(Dispatchers.IO) {
|
||||
val results = mutableListOf<BankTransaction>()
|
||||
|
||||
@@ -187,7 +203,7 @@ class TransferHistoryFragment : Fragment() {
|
||||
async {
|
||||
try {
|
||||
when {
|
||||
state.account.profileType == "BML_PREPAID" || state.account.profileType == "BML_CREDIT" -> {
|
||||
state.account.profileType == "BML_PREPAID" || state.account.profileType == "BML_CREDIT" || state.account.profileType == "BML_DEBIT" -> {
|
||||
val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.MONTH, -state.cardMonthOffset)
|
||||
@@ -215,7 +231,7 @@ class TransferHistoryFragment : Fragment() {
|
||||
list
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { emptyList<BankTransaction>() }
|
||||
} catch (e: Exception) { trackError(e); emptyList<BankTransaction>() }
|
||||
}
|
||||
}.awaitAll().flatten())
|
||||
|
||||
@@ -233,7 +249,7 @@ class TransferHistoryFragment : Fragment() {
|
||||
if (total > 0) state.fahipayTotal = total
|
||||
state.fahipayNextStart += list.size
|
||||
results.addAll(list)
|
||||
} catch (_: Exception) {}
|
||||
} catch (e: Exception) { trackError(e) }
|
||||
}
|
||||
|
||||
// MIB accounts: serialized per profile, protected by mutex to prevent session race
|
||||
@@ -242,23 +258,25 @@ class TransferHistoryFragment : Fragment() {
|
||||
val session = app.mibSessions[loginId] ?: continue
|
||||
for ((profileId, states) in loginStates.groupBy { it.account.profileId }) {
|
||||
app.mibMutex.withLock {
|
||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||
val profile = profiles.firstOrNull { it.profileId == profileId }
|
||||
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
|
||||
for (state in states) {
|
||||
try {
|
||||
val (list, total) = MibHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountNo = state.account.accountNumber,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
start = state.mibNextStart,
|
||||
pageSize = pageSize
|
||||
)
|
||||
if (total > 0) state.mibTotalCount = total
|
||||
state.mibNextStart += list.size.coerceAtLeast(pageSize)
|
||||
results.addAll(list)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
try {
|
||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||
val profile = profiles.firstOrNull { it.profileId == profileId }
|
||||
if (profile != null) app.mibFlowFor(loginId).switchProfile(session, profile)
|
||||
for (state in states) {
|
||||
try {
|
||||
val (list, total) = MibHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountNo = state.account.accountNumber,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
start = state.mibNextStart,
|
||||
pageSize = pageSize
|
||||
)
|
||||
if (total > 0) state.mibTotalCount = total
|
||||
state.mibNextStart += list.size.coerceAtLeast(pageSize)
|
||||
results.addAll(list)
|
||||
} catch (e: Exception) { trackError(e) }
|
||||
}
|
||||
} catch (e: Exception) { trackError(e) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -267,6 +285,16 @@ class TransferHistoryFragment : Fragment() {
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
if (_binding == null) return@launch
|
||||
|
||||
val raw = bannerMsg.get()
|
||||
when {
|
||||
raw == null -> (activity as? HomeActivity)?.hideConnectivityBanner()
|
||||
raw == "IO" -> (activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
|
||||
raw.startsWith("SERVER:") -> (activity as? HomeActivity)?.showConnectivityBanner(
|
||||
getString(R.string.connectivity_server_error, raw.removePrefix("SERVER:"))
|
||||
)
|
||||
}
|
||||
|
||||
if (!firstBatchDone) {
|
||||
firstBatchDone = true
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package sh.sar.basedbank.ui.login
|
||||
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
@@ -81,6 +84,17 @@ class CredentialsFragment : Fragment() {
|
||||
binding.btnLogin.isEnabled = false
|
||||
binding.btnLogin.setOnClickListener { attemptLogin() }
|
||||
|
||||
binding.cardOtp.setOnClickListener {
|
||||
val code = binding.tvOtpCode.text.toString().replace(" ", "")
|
||||
if (code.isNotEmpty()) {
|
||||
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
||||
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||
Toast.makeText(requireContext(), "OTP copied", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val loginFieldWatcher = object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) { updateLoginButtonState() }
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
@@ -215,6 +229,7 @@ class CredentialsFragment : Fragment() {
|
||||
app.mibSessions[loginId] = flow.lastSession!!
|
||||
app.mibProfilesMap[loginId] = flow.lastProfiles
|
||||
app.mibLoginFlows[loginId] = flow
|
||||
app.isUnlocked = true
|
||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
startActivity(intent)
|
||||
@@ -267,6 +282,10 @@ class CredentialsFragment : Fragment() {
|
||||
bmlAccumulatedAccounts += result.accounts
|
||||
val store = CredentialStore(requireContext())
|
||||
store.saveBmlProfileSession(profile.profileId, result.session.accessToken, result.session.deviceId)
|
||||
if (result.session.refreshToken.isNotBlank())
|
||||
store.saveBmlProfileRefreshToken(profile.profileId, result.session.refreshToken)
|
||||
if (result.session.expiresAt > 0)
|
||||
store.saveBmlProfileExpiresAt(profile.profileId, result.session.expiresAt)
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
app.bmlSessions[profile.profileId] = result.session
|
||||
}
|
||||
@@ -326,8 +345,16 @@ class CredentialsFragment : Fragment() {
|
||||
val session = app.bmlSessions.remove(oldId)
|
||||
if (session != null) {
|
||||
app.bmlSessions[customerId] = session
|
||||
val savedRefresh = store.loadBmlProfileRefreshToken(oldId)
|
||||
val savedExpiry = store.loadBmlProfileExpiresAt(oldId)
|
||||
store.clearBmlProfileSession(oldId)
|
||||
store.saveBmlProfileSession(customerId, session.accessToken, session.deviceId)
|
||||
if (session.refreshToken.isNotBlank())
|
||||
store.saveBmlProfileRefreshToken(customerId, session.refreshToken)
|
||||
else if (savedRefresh != null)
|
||||
store.saveBmlProfileRefreshToken(customerId, savedRefresh)
|
||||
val expiryToSave = if (session.expiresAt > 0) session.expiresAt else savedExpiry
|
||||
if (expiryToSave > 0) store.saveBmlProfileExpiresAt(customerId, expiryToSave)
|
||||
}
|
||||
// Update stored profile list with the real ID
|
||||
val updatedProfiles = profiles.map {
|
||||
@@ -352,6 +379,7 @@ class CredentialsFragment : Fragment() {
|
||||
if (hasBusinessProfiles) {
|
||||
Toast.makeText(requireContext(), "Business profiles can be enabled in Settings → Logins", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
(requireActivity().application as BasedBankApp).isUnlocked = true
|
||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
startActivity(intent)
|
||||
@@ -484,6 +512,7 @@ class CredentialsFragment : Fragment() {
|
||||
app.fahipaySessions[loginId] = session
|
||||
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != loginTag } + listOf(account)
|
||||
app.accounts = app.accounts.filter { it.loginTag != loginTag } + listOf(account)
|
||||
app.isUnlocked = true
|
||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
startActivity(intent)
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
package sh.sar.basedbank.ui.login
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.view.WindowCompat
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.LockActivity
|
||||
import sh.sar.basedbank.databinding.ActivityLoginBinding
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.ThemeHelper
|
||||
|
||||
class LoginActivity : AppCompatActivity() {
|
||||
|
||||
private lateinit var binding: ActivityLoginBinding
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
ThemeHelper.applyAccent(this)
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// If security is configured and the user hasn't unlocked this session,
|
||||
// they must authenticate first before being able to add more accounts.
|
||||
val app = application as BasedBankApp
|
||||
if (CredentialStore(this).loadSecurityHash() != null && !app.isUnlocked) {
|
||||
startActivity(Intent(this, LockActivity::class.java))
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
binding = ActivityLoginBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
@@ -20,5 +36,8 @@ class LoginActivity : AppCompatActivity() {
|
||||
isAppearanceLightStatusBars = isLight
|
||||
isAppearanceLightNavigationBars = isLight
|
||||
}
|
||||
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
|
||||
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
|
||||
ta.recycle()
|
||||
}
|
||||
}
|
||||
|
||||