Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
da85a31bc6
|
|||
|
d292e73fd9
|
|||
|
3d632606a0
|
|||
|
6daeb5f72e
|
|||
|
c4d3c1efd4
|
|||
|
0560c53ae3
|
|||
|
a37454de00
|
|||
|
daf9b0475a
|
|||
|
c4ad35e6b9
|
|||
|
3e8ea90701
|
|||
|
ef919aa179
|
|||
|
c98a3e3e89
|
|||
|
0654c711d6
|
|||
|
b67368c94a
|
|||
|
a6e7e61b58
|
|||
|
e974a95708
|
|||
|
de11fbe0d3
|
|||
|
5d8ab76477
|
|||
|
d637877167
|
|||
|
ea227bf3b9
|
|||
|
6b3131069e
|
|||
|
8037ce3f02
|
|||
|
cecf0bedfc
|
|||
|
256f216da4
|
|||
|
0a27de4a34
|
|||
|
a3f8852163
|
|||
|
8e345746ed
|
|||
|
473e051282
|
|||
|
f9c182fe9a
|
|||
|
339dae8a37
|
|||
|
a6a1f28144
|
|||
|
523d1248bd
|
|||
|
ee9f98b720
|
|||
|
219ca9bf00
|
|||
|
e9f0cec698
|
|||
|
268f3dada0
|
|||
|
e0a554c769
|
|||
|
94b280a177
|
|||
|
88c9f153e5
|
|||
|
eb7da01b2e
|
|||
|
27270f1b7a
|
|||
|
fd7fcb41a6
|
|||
|
c9ae614fc7
|
|||
|
b784085605
|
|||
|
01e5c17284
|
|||
|
6d3c7036b5
|
|||
|
804712d22d
|
|||
|
f208ee6ad1
|
|||
|
51dbed94d4
|
|||
|
0b5a452046
|
|||
|
00297da71e
|
|||
|
1602d061c1
|
|||
|
ddd64e8624
|
|||
|
77f367844d
|
|||
|
e2729b1d1a
|
|||
|
105518e147
|
|||
|
38570615dd
|
|||
|
e82218e897
|
|||
|
50150b826f
|
|||
|
2d705457f8
|
|||
|
f03e23062b
|
|||
|
58f1b9fd6f
|
|||
|
240d04ad74
|
|||
|
fe507073b1
|
|||
|
6d48c27391
|
|||
|
e894f81887
|
|||
|
acc1278b34
|
|||
|
bc678d26ad
|
|||
|
bb2a80a5e3
|
|||
|
b107358266
|
|||
|
02a53c8219
|
|||
|
15a02cac1c
|
|||
|
35a1748055
|
|||
|
28682bba41
|
|||
|
25484addfb
|
|||
|
728c7d2aa3
|
|||
|
b24949c117
|
|||
|
28e5878668
|
|||
|
b1e73533f6
|
|||
|
3a5b9459a9
|
|||
|
9c9729e268
|
|||
|
399cfbf108
|
|||
|
19f4d01015
|
|||
|
8c40322ff0
|
|||
|
782e2e7674
|
|||
|
89a9731797
|
|||
|
50badc7d54
|
|||
|
d4f86bb738
|
|||
|
b35f44f35b
|
|||
|
1a58ce8b54
|
|||
|
2dd84ec50a
|
|||
|
33651ca107
|
|||
|
ae307e3118
|
|||
|
3ab75bff92
|
|||
|
1753d648bd
|
|||
|
423b0bf1e1
|
|||
|
d713047970
|
|||
|
d59a6fad82
|
|||
|
8e47101401
|
|||
|
9431a90cd0
|
|||
|
3a10f36c39
|
@@ -1,11 +1,9 @@
|
||||
services:
|
||||
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
|
||||
|
||||
19
.idea/deploymentTargetSelector.xml
generated
@@ -4,14 +4,27 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-05-15T13:54:16.798188666Z">
|
||||
<DropdownSelection timestamp="2026-05-22T00:11:32.873305232Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" />
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=683a9830" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
<DialogSelection>
|
||||
<targets>
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="Default" identifier="serial=10.0.1.239:5555;connection=67d022c2" />
|
||||
</handle>
|
||||
</Target>
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=683a9830" />
|
||||
</handle>
|
||||
</Target>
|
||||
</targets>
|
||||
</DialogSelection>
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
|
||||
8
.idea/markdown.xml
generated
Normal file
@@ -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>
|
||||
59
README.md
@@ -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,14 @@ 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
|
||||
[Download latest APK](https://git.shihaam.dev/shihaam/ISODroid/releases/latest)
|
||||
|
||||
## Privacy
|
||||
|
||||
@@ -70,3 +24,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 = 1
|
||||
versionName = "1.0"
|
||||
versionCode = 8
|
||||
versionName = "1.0.9"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
@@ -64,12 +64,18 @@ dependencies {
|
||||
// RecyclerView for accounts list
|
||||
implementation("androidx.recyclerview:recyclerview:1.3.2")
|
||||
|
||||
// CircularProgressDrawable for spinning search icons
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||
|
||||
// OkHttp for API calls
|
||||
implementation("com.squareup.okhttp3:okhttp:4.11.0")
|
||||
|
||||
// Coroutines
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3")
|
||||
|
||||
// ZXing core for QR code generation
|
||||
implementation("com.google.zxing:core:3.5.3")
|
||||
|
||||
// QR scanning — CameraX + zxing-cpp (MIT, same stack as BinaryEye)
|
||||
implementation("androidx.camera:camera-core:1.4.2")
|
||||
implementation("androidx.camera:camera-camera2:1.4.2")
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
|
||||
<activity
|
||||
android:name=".ui.home.HomeActivity"
|
||||
android:exported="false" />
|
||||
android:exported="false"
|
||||
android:windowSoftInputMode="adjustPan" />
|
||||
|
||||
<activity
|
||||
android:name=".ui.home.QrScannerActivity"
|
||||
|
||||
BIN
app/src/main/assets/cards/bml/amex_credit_gold.png
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
app/src/main/assets/cards/bml/amex_credit_green.png
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
app/src/main/assets/cards/bml/amex_debit_gold.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
app/src/main/assets/cards/bml/amex_debit_green.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
app/src/main/assets/cards/bml/amex_platinum.png
Normal file
|
After Width: | Height: | Size: 163 KiB |
BIN
app/src/main/assets/cards/bml/defaultcard.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
app/src/main/assets/cards/bml/master.png
Normal file
|
After Width: | Height: | Size: 294 KiB |
BIN
app/src/main/assets/cards/bml/master_business_debit.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
app/src/main/assets/cards/bml/master_gold.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
app/src/main/assets/cards/bml/master_islamic.png
Normal file
|
After Width: | Height: | Size: 227 KiB |
BIN
app/src/main/assets/cards/bml/master_masveriyaa.png
Normal file
|
After Width: | Height: | Size: 332 KiB |
BIN
app/src/main/assets/cards/bml/master_odiveriyaa.png
Normal file
|
After Width: | Height: | Size: 390 KiB |
BIN
app/src/main/assets/cards/bml/master_passport.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
app/src/main/assets/cards/bml/master_platinum.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
app/src/main/assets/cards/bml/master_prepaid.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
app/src/main/assets/cards/bml/master_prepaid_business.png
Normal file
|
After Width: | Height: | Size: 197 KiB |
BIN
app/src/main/assets/cards/bml/master_prepaid_travel.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
app/src/main/assets/cards/bml/master_world.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
app/src/main/assets/cards/bml/visa_corporate.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
app/src/main/assets/cards/bml/visa_credit.png
Normal file
|
After Width: | Height: | Size: 185 KiB |
BIN
app/src/main/assets/cards/bml/visa_debit.png
Normal file
|
After Width: | Height: | Size: 194 KiB |
BIN
app/src/main/assets/cards/bml/visa_debit_generic.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
app/src/main/assets/cards/bml/visa_debit_islamic.png
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
app/src/main/assets/cards/bml/visa_debit_platinum.png
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
app/src/main/assets/cards/bml/visa_gold.png
Normal file
|
After Width: | Height: | Size: 295 KiB |
BIN
app/src/main/assets/cards/bml/visa_infinite.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
app/src/main/assets/cards/bml/visa_platinum.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
app/src/main/assets/cards/bml/visa_student_black.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
app/src/main/assets/cards/bml/visa_student_blue.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
app/src/main/assets/cards/mib/visa_black_platinum.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
app/src/main/assets/cards/mib/visa_blue_everyday.png
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
app/src/main/assets/cards/mib/visa_business.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
@@ -4,9 +4,11 @@ import android.app.Application
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import com.google.android.material.color.DynamicColors
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.bml.BmlProfile
|
||||
import sh.sar.basedbank.api.bml.BmlSession
|
||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibProfile
|
||||
import sh.sar.basedbank.api.mib.MibSession
|
||||
@@ -15,35 +17,95 @@ import sh.sar.basedbank.util.CredentialStore
|
||||
class BasedBankApp : Application() {
|
||||
|
||||
// Held in memory after successful login; cleared on logout
|
||||
var accounts: List<MibAccount> = emptyList()
|
||||
var accounts: List<BankAccount> = emptyList()
|
||||
var fullName: String = ""
|
||||
var mibSession: MibSession? = null
|
||||
var mibProfiles: List<MibProfile> = emptyList()
|
||||
/** Active BML sessions keyed by loginId (= BML username). */
|
||||
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
|
||||
var bmlAccounts: List<MibAccount> = emptyList()
|
||||
var fahipaySession: FahipaySession? = null
|
||||
var fahipayAccounts: List<MibAccount> = emptyList()
|
||||
|
||||
/** Returns the BML session for the given account (matched via loginTag). */
|
||||
fun bmlSessionFor(account: MibAccount): BmlSession? =
|
||||
bmlSessions[account.loginTag.removePrefix("bml_")]
|
||||
/** Active MIB sessions keyed by loginId (= MIB username). */
|
||||
val mibSessions: MutableMap<String, MibSession> = mutableMapOf()
|
||||
val mibProfilesMap: MutableMap<String, List<MibProfile>> = mutableMapOf()
|
||||
val mibLoginFlows: MutableMap<String, MibLoginFlow> = mutableMapOf()
|
||||
var mibAccounts: List<BankAccount> = emptyList()
|
||||
|
||||
/**
|
||||
* Active BML sessions keyed by profileId (a globally unique GUID per BML profile).
|
||||
* Use [bmlSessionFor] to look up the session for an account.
|
||||
*/
|
||||
val bmlSessions: MutableMap<String, BmlSession> = mutableMapOf()
|
||||
/** BML profiles per loginId (= BML username). */
|
||||
val bmlProfilesMap: MutableMap<String, List<BmlProfile>> = mutableMapOf()
|
||||
/** BML login flows per loginId — hold the web session (cookies) needed for profile activation. */
|
||||
val bmlLoginFlows: MutableMap<String, BmlLoginFlow> = mutableMapOf()
|
||||
var bmlAccounts: List<BankAccount> = emptyList()
|
||||
|
||||
/** Active Fahipay sessions keyed by loginId (= profileId). */
|
||||
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
|
||||
var fahipayAccounts: List<BankAccount> = emptyList()
|
||||
|
||||
// ─── MIB helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the MIB session for the given account (matched via loginTag). */
|
||||
fun mibSessionFor(account: BankAccount): MibSession? =
|
||||
mibSessions[account.loginTag.removePrefix("mib_")]
|
||||
|
||||
/** Returns any available MIB session. */
|
||||
fun anyMibSession(): MibSession? = mibSessions.values.firstOrNull()
|
||||
|
||||
/** Returns all MIB profiles across all logins. */
|
||||
fun allMibProfiles(): List<MibProfile> = mibProfilesMap.values.flatten()
|
||||
|
||||
/** Returns the MibLoginFlow for a given loginId, creating and caching it if needed. */
|
||||
fun mibFlowFor(loginId: String): MibLoginFlow =
|
||||
mibLoginFlows.getOrPut(loginId) {
|
||||
MibLoginFlow(CredentialStore(this)).also { flow ->
|
||||
flow.onSessionRefreshed = { session, profiles ->
|
||||
mibSessions[loginId] = session
|
||||
mibProfilesMap[loginId] = profiles
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns any available MibLoginFlow. */
|
||||
fun anyMibFlow(): MibLoginFlow? = mibLoginFlows.values.firstOrNull()
|
||||
|
||||
// ─── BML helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the BML session for the given account.
|
||||
* Looks up by profileId first (multi-profile), falls back to loginId (legacy single-profile).
|
||||
*/
|
||||
fun bmlSessionFor(account: BankAccount): BmlSession? {
|
||||
val byProfile = if (account.profileId.isNotBlank()) bmlSessions[account.profileId] else null
|
||||
return byProfile ?: bmlSessions[account.loginTag.removePrefix("bml_")]
|
||||
}
|
||||
|
||||
/** Returns any available BML session (for non-account-specific operations). */
|
||||
fun anyBmlSession(): BmlSession? = bmlSessions.values.firstOrNull()
|
||||
|
||||
/**
|
||||
* Returns any active BML session for the given loginId.
|
||||
* Tries all profiles for that login; falls back to legacy loginId key.
|
||||
*/
|
||||
fun anyBmlSessionFor(loginId: String): BmlSession? {
|
||||
val profiles = bmlProfilesMap[loginId]
|
||||
if (!profiles.isNullOrEmpty()) {
|
||||
return profiles.firstNotNullOfOrNull { bmlSessions[it.profileId] }
|
||||
}
|
||||
return bmlSessions[loginId]
|
||||
}
|
||||
|
||||
/** Returns the BmlLoginFlow for a given loginId, creating and caching it if needed. */
|
||||
fun bmlFlowFor(loginId: String): BmlLoginFlow =
|
||||
bmlLoginFlows.getOrPut(loginId) { BmlLoginFlow() }
|
||||
|
||||
// ─── Fahipay helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the Fahipay session for the given account (matched via loginTag = "fahipay_${profileId}"). */
|
||||
fun fahipaySessionFor(account: BankAccount): FahipaySession? =
|
||||
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
|
||||
|
||||
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
|
||||
val mibMutex = Mutex()
|
||||
|
||||
val mibLoginFlow by lazy {
|
||||
MibLoginFlow(CredentialStore(this)).also { flow ->
|
||||
flow.onSessionRefreshed = { session, profiles ->
|
||||
mibSession = session
|
||||
mibProfiles = profiles
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
DynamicColors.applyToActivitiesIfAvailable(this)
|
||||
|
||||
@@ -10,6 +10,9 @@ import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -18,8 +21,6 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.databinding.ActivityLockBinding
|
||||
import sh.sar.basedbank.ui.home.HomeActivity
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.PBEKeySpec
|
||||
|
||||
@@ -31,7 +32,8 @@ class LockActivity : AppCompatActivity() {
|
||||
private lateinit var salt: String
|
||||
private lateinit var storedHash: String
|
||||
private var biometricsEnabled = false
|
||||
private var isLegacyFormat = false
|
||||
private var autoUnlockPin = false
|
||||
private var pinLength = 4
|
||||
private var isVerifying = false
|
||||
|
||||
private val lockPrefs get() = getSharedPreferences("lock_attempts", MODE_PRIVATE)
|
||||
@@ -39,28 +41,34 @@ class LockActivity : AppCompatActivity() {
|
||||
companion object {
|
||||
private const val MAX_ATTEMPTS = 5
|
||||
private const val LOCKOUT_MS = 30_000L
|
||||
const val EXTRA_RESUME = "resume"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
binding = ActivityLockBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
val isLight = (resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK) == android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
isAppearanceLightStatusBars = isLight
|
||||
isAppearanceLightNavigationBars = isLight
|
||||
}
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets ->
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
|
||||
insets
|
||||
}
|
||||
|
||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
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)
|
||||
|
||||
// Try new encrypted format first; fall back to legacy SHA-256
|
||||
val stored = CredentialStore(this).loadSecurityHash()
|
||||
if (stored != null) {
|
||||
salt = stored.first
|
||||
storedHash = stored.second
|
||||
isLegacyFormat = false
|
||||
} else {
|
||||
salt = prefs.getString("security_salt", "") ?: ""
|
||||
storedHash = prefs.getString("security_hash", "") ?: ""
|
||||
isLegacyFormat = true
|
||||
}
|
||||
val stored = CredentialStore(this).loadSecurityHash() ?: run { finish(); return }
|
||||
salt = stored.first
|
||||
storedHash = stored.second
|
||||
|
||||
if (method == "pin") {
|
||||
binding.viewPin.visibility = View.VISIBLE
|
||||
@@ -130,13 +138,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() {
|
||||
@@ -149,7 +162,6 @@ class LockActivity : AppCompatActivity() {
|
||||
val ok = withContext(Dispatchers.Default) { verify(entered) }
|
||||
isVerifying = false
|
||||
if (ok) {
|
||||
migrateIfNeeded(entered)
|
||||
resetFailures()
|
||||
proceed()
|
||||
} else {
|
||||
@@ -174,7 +186,6 @@ class LockActivity : AppCompatActivity() {
|
||||
val ok = withContext(Dispatchers.Default) { verify(entered) }
|
||||
isVerifying = false
|
||||
if (ok) {
|
||||
migrateIfNeeded(entered)
|
||||
resetFailures()
|
||||
proceed()
|
||||
} else {
|
||||
@@ -192,15 +203,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 {
|
||||
@@ -216,30 +227,8 @@ class LockActivity : AppCompatActivity() {
|
||||
|
||||
private fun verify(input: String): Boolean {
|
||||
if (storedHash.isBlank()) return false
|
||||
return if (isLegacyFormat) {
|
||||
sha256Legacy(salt + input) == storedHash
|
||||
} else {
|
||||
val saltBytes = Base64.decode(salt, Base64.NO_WRAP)
|
||||
pbkdf2(input, saltBytes) == storedHash
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On the first successful unlock after legacy SHA-256 format is detected,
|
||||
* transparently migrate to PBKDF2 + CredentialStore.
|
||||
*/
|
||||
private fun migrateIfNeeded(input: String) {
|
||||
if (!isLegacyFormat) return
|
||||
try {
|
||||
val newSalt = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||
val newHash = pbkdf2(input, newSalt)
|
||||
val saltB64 = Base64.encodeToString(newSalt, Base64.NO_WRAP)
|
||||
CredentialStore(this).saveSecurityHash(saltB64, newHash)
|
||||
// Remove legacy plaintext fields
|
||||
getSharedPreferences("prefs", MODE_PRIVATE).edit()
|
||||
.remove("security_salt").remove("security_hash").apply()
|
||||
isLegacyFormat = false
|
||||
} catch (_: Exception) { /* migration will retry next unlock */ }
|
||||
val saltBytes = Base64.decode(salt, Base64.NO_WRAP)
|
||||
return pbkdf2(input, saltBytes) == storedHash
|
||||
}
|
||||
|
||||
private fun triggerBiometric() {
|
||||
@@ -270,8 +259,12 @@ class LockActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun proceed() {
|
||||
startActivity(Intent(this, HomeActivity::class.java))
|
||||
finish()
|
||||
if (intent.getBooleanExtra(EXTRA_RESUME, false)) {
|
||||
finish()
|
||||
} else {
|
||||
startActivity(Intent(this, HomeActivity::class.java))
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Brute-force tracking ──────────────────────────────────────────────────
|
||||
@@ -308,9 +301,4 @@ class LockActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Legacy: raw SHA-256(salt + input) — only used for migration path. */
|
||||
private fun sha256Legacy(input: String) = MessageDigest.getInstance("SHA-256")
|
||||
.digest(input.toByteArray()).joinToString("") { "%02x".format(it) }
|
||||
|
||||
|
||||
}
|
||||
|
||||
207
app/src/main/java/sh/sar/basedbank/api/bml/BmlAccountClient.kt
Normal file
@@ -0,0 +1,207 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
|
||||
data class BmlUserInfo(
|
||||
val fullName: String,
|
||||
val email: String,
|
||||
val mobile: String,
|
||||
val customerId: String,
|
||||
val idCard: String,
|
||||
val birthdate: String
|
||||
)
|
||||
|
||||
class BmlAccountClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
fun fetchAccounts(
|
||||
session: BmlSession,
|
||||
loginTag: String,
|
||||
profileName: String = "Personal",
|
||||
profileId: String = ""
|
||||
): List<BankAccount> {
|
||||
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/dashboard")).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
if (code in 500..599) throw BankServerException("BML")
|
||||
return parseDashboard(json ?: return emptyList(), loginTag, profileName, profileId)
|
||||
}
|
||||
|
||||
/** Lightweight call to verify the session is alive. Throws [AuthExpiredException] on 401/419. */
|
||||
fun checkProfile(session: BmlSession) {
|
||||
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/profile")).execute()
|
||||
val code = resp.code
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
if (code in 500..599) throw BankServerException("BML")
|
||||
}
|
||||
|
||||
fun fetchUserInfo(session: BmlSession): BmlUserInfo? {
|
||||
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/userinfo")).execute()
|
||||
val json = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return null
|
||||
val user = root.optJSONObject("payload")?.optJSONObject("user") ?: return null
|
||||
BmlUserInfo(
|
||||
fullName = user.optString("fullname").trim(),
|
||||
email = user.optString("email").trim(),
|
||||
mobile = user.optString("mobile_phone").trim(),
|
||||
customerId = user.optString("customer_number").trim(),
|
||||
idCard = user.optString("idcard").trim(),
|
||||
birthdate = user.optString("birthdate").trim()
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun fetchLoanDetail(session: BmlSession, internalId: String): BmlLoanDetail? {
|
||||
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/account/$internalId")).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return null
|
||||
val p = root.optJSONObject("payload") ?: return null
|
||||
BmlLoanDetail(
|
||||
loanAmount = p.optDouble("loanAmount", 0.0),
|
||||
outstandingAmt = p.optDouble("outstandingAmt", 0.0),
|
||||
repayAmount = p.optDouble("repayAmount", 0.0),
|
||||
intRate = p.optDouble("intRate", 0.0),
|
||||
loanStatus = p.optString("loanStatus"),
|
||||
startDate = p.optString("startDate"),
|
||||
endDate = p.optString("endDate"),
|
||||
noOfRepayOverdue = p.optInt("noOfRepayOverdue", 0),
|
||||
overdueAmount = p.optDouble("overdueAmount", 0.0)
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun fetchTransferChannels(session: BmlSession): List<BmlOtpChannel> {
|
||||
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/transfer")).execute()
|
||||
val json = resp.body?.string() ?: run { resp.close(); return emptyList() }
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val arr = root.optJSONObject("payload")
|
||||
?.optJSONObject("transfer")
|
||||
?.optJSONArray("otpChannel") ?: return emptyList()
|
||||
(0 until arr.length()).map { i ->
|
||||
val ch = arr.getJSONObject(i)
|
||||
BmlOtpChannel(
|
||||
channel = ch.optString("channel"),
|
||||
description = ch.optString("description"),
|
||||
masked = ch.optString("masked")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
private fun parseDashboard(
|
||||
json: String,
|
||||
loginTag: String,
|
||||
profileName: String,
|
||||
profileId: String
|
||||
): List<BankAccount> {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
|
||||
|
||||
val casaAccounts = mutableListOf<BankAccount>()
|
||||
val prepaidCards = mutableListOf<BankAccount>()
|
||||
val loanAccounts = mutableListOf<BankAccount>()
|
||||
|
||||
for (i in 0 until dashboard.length()) {
|
||||
val item = dashboard.getJSONObject(i)
|
||||
val currency = item.optString("currency", "MVR")
|
||||
val accountType = item.optString("account_type", "CASA")
|
||||
val product = item.optString("product")
|
||||
val accountNumber = item.optString("account")
|
||||
val status = item.optString("account_status", "Active")
|
||||
val internalId = item.optString("id", "")
|
||||
|
||||
if (accountType == "CASA") {
|
||||
val available = item.optDouble("availableBalance", 0.0)
|
||||
casaAccounts.add(BankAccount(
|
||||
bank = "BML",
|
||||
profileName = profileName,
|
||||
profileType = "BML",
|
||||
accountNumber = accountNumber,
|
||||
accountBriefName = item.optString("alias"),
|
||||
currencyName = currency,
|
||||
accountTypeName = product,
|
||||
availableBalance = "%.2f".format(available),
|
||||
currentBalance = "%.2f".format(item.optDouble("ledgerBalance", 0.0)),
|
||||
blockedAmount = "%.2f".format(item.optDouble("lockedAmount", 0.0)),
|
||||
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
||||
statusDesc = status,
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
profileId = profileId,
|
||||
internalId = internalId
|
||||
))
|
||||
} else if (accountType == "Loan") {
|
||||
val outstanding = Math.abs(item.optDouble("availableBalance", 0.0))
|
||||
loanAccounts.add(BankAccount(
|
||||
bank = "BML",
|
||||
profileName = profileName,
|
||||
profileType = "BML_LOAN",
|
||||
accountNumber = accountNumber,
|
||||
accountBriefName = item.optString("alias"),
|
||||
currencyName = currency,
|
||||
accountTypeName = product,
|
||||
availableBalance = "%.2f".format(outstanding),
|
||||
currentBalance = "%.2f".format(outstanding),
|
||||
blockedAmount = "0.00",
|
||||
mvrBalance = "0.00",
|
||||
statusDesc = status,
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
profileId = profileId,
|
||||
internalId = internalId
|
||||
))
|
||||
} else if (accountType == "Card") {
|
||||
val isPrepaid = item.optBoolean("prepaid_card", false)
|
||||
val productCode = item.optString("product_code", "")
|
||||
val cardBalance = item.optJSONObject("cardBalance")
|
||||
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
|
||||
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
|
||||
val isVisible = item.optBoolean("account_visible", false)
|
||||
val cardProfileType = when {
|
||||
isPrepaid -> "BML_PREPAID"
|
||||
isVisible -> "BML_CREDIT" // non-prepaid, visible = credit card
|
||||
else -> "BML_DEBIT" // non-prepaid, not visible = debit card
|
||||
}
|
||||
prepaidCards.add(BankAccount(
|
||||
bank = "BML",
|
||||
profileName = profileName,
|
||||
profileType = cardProfileType,
|
||||
productCode = productCode,
|
||||
accountNumber = accountNumber,
|
||||
accountBriefName = item.optString("alias").ifBlank { product },
|
||||
currencyName = currency,
|
||||
accountTypeName = product,
|
||||
availableBalance = "%.2f".format(available),
|
||||
currentBalance = "%.2f".format(current),
|
||||
blockedAmount = "0.00",
|
||||
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
||||
statusDesc = status,
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
profileId = profileId,
|
||||
internalId = internalId
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return casaAccounts + prepaidCards + loanAccounts
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import android.os.Build
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
internal const val BML_BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
||||
internal val BML_USER_AGENT = "bml-mobile-banking/348 (${Build.MANUFACTURER}; Android ${Build.VERSION.RELEASE}; ${Build.MODEL})"
|
||||
internal const val BML_APP_VERSION = "2.1.44.348"
|
||||
|
||||
internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
internal fun bmlApiRequest(session: BmlSession, url: String): Request =
|
||||
Request.Builder().url(url)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.build()
|
||||
@@ -0,0 +1,95 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
|
||||
class BmlContactsClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
fun fetchContacts(session: BmlSession, loginId: String): List<BankContact> {
|
||||
val resp = client.newCall(bmlApiRequest(session, "$BML_BASE_URL/api/mobile/contacts")).execute()
|
||||
val json = resp.body?.string() ?: return emptyList()
|
||||
resp.close()
|
||||
return parseContacts(json, loginId)
|
||||
}
|
||||
|
||||
fun saveContact(
|
||||
session: BmlSession,
|
||||
contactType: String,
|
||||
account: String,
|
||||
alias: String,
|
||||
currency: String? = null,
|
||||
name: String? = null,
|
||||
swift: String? = null
|
||||
): Boolean {
|
||||
val bodyObj = JSONObject().apply {
|
||||
put("contact_type", contactType)
|
||||
put("account", account)
|
||||
put("alias", alias)
|
||||
if (currency != null) put("currency", currency)
|
||||
if (name != null) put("name", name)
|
||||
if (swift != null) put("swift", swift)
|
||||
}
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/contacts")
|
||||
.post(bodyObj.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return false
|
||||
resp.close()
|
||||
return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
fun deleteContact(session: BmlSession, contactId: String): Boolean {
|
||||
val body = """{"_method":"delete"}""".toRequestBody("application/json".toMediaType())
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/contacts/$contactId")
|
||||
.post(body)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val bodyStr = resp.body?.string() ?: return false
|
||||
resp.close()
|
||||
return try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
private fun parseContacts(json: String, loginId: String): List<BankContact> {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList()
|
||||
val result = mutableListOf<BankContact>()
|
||||
for (i in 0 until payload.length()) {
|
||||
val item = payload.getJSONObject(i)
|
||||
val account = item.optString("account", "")
|
||||
if (account.isBlank()) continue
|
||||
result.add(BankContact(
|
||||
benefNo = "bml_${item.optInt("id")}",
|
||||
benefName = item.optString("name"),
|
||||
benefNickName = item.optString("alias", item.optString("name")),
|
||||
benefAccount = account,
|
||||
benefType = "I",
|
||||
bankColor = "#0066A1",
|
||||
benefBankName = "Bank of Maldives",
|
||||
bankCode = "",
|
||||
benefStatus = item.optString("status", "S"),
|
||||
transferCyDesc = item.optString("currency", "MVR"),
|
||||
customerImgHash = null,
|
||||
benefCategoryId = "BML",
|
||||
profileId = loginId
|
||||
))
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class BmlForeignLimitsClient {
|
||||
|
||||
// Foreign limits use a different host than the main BML API
|
||||
private val BASE_URL = "https://app.bankofmaldives.com.mv/api/v2"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/foreign-limits")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
return parseForeignLimits(json ?: return emptyList())
|
||||
}
|
||||
|
||||
private fun parseForeignLimits(json: String): List<BmlForeignLimit> {
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload = root.optJSONArray("payload") ?: return emptyList()
|
||||
(0 until payload.length()).map { i ->
|
||||
val item = payload.getJSONObject(i)
|
||||
val usage = item.optJSONObject("usageByCategory") ?: JSONObject()
|
||||
val atm = usage.optJSONObject("ATM") ?: JSONObject()
|
||||
val ecom = usage.optJSONObject("ECOM") ?: JSONObject()
|
||||
val pos = usage.optJSONObject("POS") ?: JSONObject()
|
||||
BmlForeignLimit(
|
||||
type = item.optString("type", "Debit"),
|
||||
used = item.optDouble("used", 0.0),
|
||||
totalLimit = item.optDouble("totalLimit", 0.0),
|
||||
generalCap = item.optDouble("generalCap", 0.0),
|
||||
generalRemaining = item.optDouble("generalRemaining", 0.0),
|
||||
medicalRemaining = item.optDouble("medicalRemaining", 0.0),
|
||||
isAtmEnabled = item.optBoolean("isAtmEnabled", false),
|
||||
isPosEnabled = item.optBoolean("isPosEnabled", false),
|
||||
atmRemaining = atm.optDouble("remaining", 0.0),
|
||||
atmLimit = atm.optDouble("limit", 0.0),
|
||||
ecomRemaining = ecom.optDouble("remaining", 0.0),
|
||||
ecomLimit = ecom.optDouble("limit", 0.0),
|
||||
posRemaining = pos.optDouble("remaining", 0.0),
|
||||
posLimit = pos.optDouble("limit", 0.0)
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
}
|
||||
177
app/src/main/java/sh/sar/basedbank/api/bml/BmlHistoryClient.kt
Normal file
@@ -0,0 +1,177 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
class BmlHistoryClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
fun fetchAccountHistory(
|
||||
session: BmlSession,
|
||||
accountId: String,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String,
|
||||
page: Int
|
||||
): Pair<List<BankTransaction>, Int> {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/account/$accountId/history/$page")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
if (code in 500..599) throw BankServerException("BML")
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return Pair(emptyList(), 0)
|
||||
val payload = root.optJSONObject("payload") ?: return Pair(emptyList(), 0)
|
||||
val totalPages = payload.optInt("totalPages", 0)
|
||||
val history = payload.optJSONArray("history") ?: return Pair(emptyList(), totalPages)
|
||||
val transactions = (0 until history.length()).map { i ->
|
||||
val item = history.getJSONObject(i)
|
||||
val desc = item.optString("description").trim()
|
||||
val narrative1 = item.optString("narrative1")
|
||||
val date = when (desc) {
|
||||
"Purchase" -> parsePurchaseNarrative1(narrative1) ?: item.optString("bookingDate")
|
||||
"Transfer Debit", "Transfer Credit" -> parseTransferNarrative1(narrative1) ?: item.optString("bookingDate")
|
||||
else -> item.optString("bookingDate")
|
||||
}
|
||||
BankTransaction(
|
||||
id = item.optString("id"),
|
||||
date = date,
|
||||
description = desc,
|
||||
amount = item.optDouble("amount", 0.0),
|
||||
currency = item.optString("currency"),
|
||||
counterpartyName = item.optString("narrative2").takeIf { it.isNotBlank() },
|
||||
reference = item.optString("reference").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML"
|
||||
)
|
||||
}
|
||||
Pair(transactions, totalPages)
|
||||
} catch (_: Exception) { Pair(emptyList(), 0) }
|
||||
}
|
||||
|
||||
fun fetchCardHistory(
|
||||
session: BmlSession,
|
||||
cardId: String,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String,
|
||||
month: String
|
||||
): List<BankTransaction> {
|
||||
val body = """{"card":"$cardId","month":"$month"}"""
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/card/statement").post(body)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return emptyList()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
if (code in 500..599) throw BankServerException("BML")
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload = root.optJSONObject("payload") ?: return emptyList()
|
||||
val result = mutableListOf<BankTransaction>()
|
||||
|
||||
val authDetails = payload.optJSONObject("outstanding")
|
||||
?.optJSONArray("CardOutStdAuthDetails")
|
||||
if (authDetails != null) {
|
||||
for (i in 0 until authDetails.length()) {
|
||||
val item = authDetails.getJSONObject(i)
|
||||
result.add(BankTransaction(
|
||||
id = "auth_${item.optString("TranApprCode")}_$i",
|
||||
date = item.optString("DateTime"),
|
||||
description = item.optString("TranDesc").trim(),
|
||||
amount = item.optDouble("BillingAmount", 0.0),
|
||||
currency = item.optString("BillingCcy", "MVR"),
|
||||
counterpartyName = null,
|
||||
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
val unbilled = payload.optJSONObject("unbilled")
|
||||
?.optJSONArray("CardUnbillTxnDetails")
|
||||
if (unbilled != null) {
|
||||
for (i in 0 until unbilled.length()) {
|
||||
val item = unbilled.getJSONObject(i)
|
||||
result.add(BankTransaction(
|
||||
id = "unbilled_${item.optString("TranApprCode")}_$i",
|
||||
date = item.optString("DateTime"),
|
||||
description = item.optString("TranDesc").trim(),
|
||||
amount = item.optDouble("BillingAmount", 0.0),
|
||||
currency = item.optString("BillingCcy", "MVR"),
|
||||
counterpartyName = null,
|
||||
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
val statement = payload.optJSONArray("cardstatement")
|
||||
if (statement != null) {
|
||||
for (i in 0 until statement.length()) {
|
||||
val item = statement.getJSONObject(i)
|
||||
result.add(BankTransaction(
|
||||
id = "stmt_${item.optString("TranRef", i.toString())}",
|
||||
date = item.optString("TransDate", item.optString("TranDate", "")),
|
||||
description = item.optString("TranDesc", item.optString("Description", "")).trim(),
|
||||
amount = -item.optDouble("TranAmount", 0.0),
|
||||
currency = item.optString("TranCcy", "MVR"),
|
||||
counterpartyName = null,
|
||||
reference = item.optString("TranRef").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
))
|
||||
}
|
||||
}
|
||||
result
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
|
||||
private fun parsePurchaseNarrative1(narrative1: String): String? {
|
||||
return try {
|
||||
val parts = narrative1.split(" ")
|
||||
if (parts.size < 2) null
|
||||
else {
|
||||
val timePart = parts[1].take(4)
|
||||
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
|
||||
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
|
||||
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
|
||||
private fun parseTransferNarrative1(narrative1: String): String? {
|
||||
return try {
|
||||
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
|
||||
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
}
|
||||
@@ -7,19 +7,15 @@ import okhttp3.FormBody
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.Totp
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Base64
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AuthExpiredException : Exception("Session expired")
|
||||
@@ -29,9 +25,9 @@ class BmlLoginFlow {
|
||||
private val BASE_URL = "https://www.bankofmaldives.com.mv/internetbanking"
|
||||
private val CLIENT_ID = "98C83590-513F-4716-B02B-EC68B7D9E7E7"
|
||||
private val REDIRECT_URI = "https://app.bankofmaldives.com.mv/oauth/mobile-callback"
|
||||
private val APP_USER_AGENT = "bml-mobile-banking/345 (POCO; Android 14; 22101320I)"
|
||||
private val APP_VERSION = "2.1.43.345"
|
||||
private val WEB_USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0"
|
||||
private val APP_USER_AGENT = "bml-mobile-banking/348 (${android.os.Build.MANUFACTURER}; Android ${android.os.Build.VERSION.RELEASE}; ${android.os.Build.MODEL})"
|
||||
private val APP_VERSION = "2.1.44.348"
|
||||
private val WEB_USER_AGENT = "Mozilla/5.0 (Android ${android.os.Build.VERSION.RELEASE}; Mobile; rv:150.0) Gecko/150.0 Firefox/150.0"
|
||||
|
||||
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
||||
private val cookieJar = object : CookieJar {
|
||||
@@ -53,14 +49,27 @@ class BmlLoginFlow {
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val apiClient = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
/** PKCE params — generated once per login and reused across all profile activations. */
|
||||
private var codeVerifier: String = ""
|
||||
private var codeChallenge: String = ""
|
||||
private var deviceId: String = ""
|
||||
|
||||
/** Full login: returns a BmlSession and the account list. */
|
||||
fun login(username: String, password: String, otpSeed: String): Pair<BmlSession, List<MibAccount>> {
|
||||
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session cookies
|
||||
/** Profiles returned by the last successful [login] call. */
|
||||
var lastProfiles: List<BmlProfile> = emptyList()
|
||||
private set
|
||||
|
||||
// ─── Login ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Performs web authentication (login + TOTP) and returns the list of available profiles.
|
||||
* Call [activateProfile] for each profile to obtain an access token + accounts.
|
||||
*/
|
||||
fun login(username: String, password: String, otpSeed: String): List<BmlProfile> {
|
||||
codeVerifier = generateCodeVerifier()
|
||||
codeChallenge = generateCodeChallenge(codeVerifier)
|
||||
deviceId = generateDeviceId()
|
||||
|
||||
// Step 1: GET login page — seeds XSRF-TOKEN + blaze_session
|
||||
client.newCall(
|
||||
Request.Builder().url("$BASE_URL/web/login")
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
@@ -82,15 +91,14 @@ class BmlLoginFlow {
|
||||
loginResp.close()
|
||||
if (loginResp.code != 302) throw Exception("Login failed — check your username/password")
|
||||
|
||||
// Step 3: GET 2FA page (refreshes blaze_session)
|
||||
// Step 3: GET 2FA page (refreshes session cookies)
|
||||
client.newCall(
|
||||
Request.Builder().url("$BASE_URL/web/login/2fa")
|
||||
.header("X-XSRF-TOKEN", xsrf)
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
).execute().close()
|
||||
val xsrf2 = xsrfToken() ?: xsrf
|
||||
|
||||
// Step 4: POST OTP
|
||||
// Step 4: POST TOTP
|
||||
val otp = Totp.generate(otpSeed)
|
||||
val twoFaBody = JSONObject().apply {
|
||||
put("code", otp)
|
||||
@@ -104,18 +112,161 @@ class BmlLoginFlow {
|
||||
twoFaResp.close()
|
||||
if (twoFaResp.code != 302) throw Exception("OTP verification failed — check your OTP seed")
|
||||
|
||||
// Step 5: GET /web/profile (sets blaze_identity cookie for profile selection)
|
||||
client.newCall(
|
||||
// Step 5: GET /web/profile — multi-profile accounts return a 200 with a profile picker;
|
||||
// single-profile accounts skip the picker and redirect straight to /web/redirect with
|
||||
// blaze_identity already set in the response cookies.
|
||||
val profileResp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/web/profile")
|
||||
.header("X-XSRF-TOKEN", xsrf2)
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
).execute()
|
||||
val profileCode = profileResp.code
|
||||
val profileLocation = profileResp.header("Location") ?: ""
|
||||
val profileBody = profileResp.body?.string() ?: ""
|
||||
profileResp.close()
|
||||
|
||||
lastProfiles = if (profileCode == 302) {
|
||||
// Any 302 from GET /web/profile means the server auto-activated the sole profile
|
||||
// and blaze_identity is already set — no profile picker shown.
|
||||
// Use username as a stable temporary profileId (unique per login); it will be
|
||||
// replaced by the real BML customer ID after fetchUserInfo in finishBmlLogin().
|
||||
listOf(BmlProfile(profileId = username, name = "Personal", type = "Profile", profileType = "default", autoActivated = true))
|
||||
} else {
|
||||
parseProfiles(profileBody)
|
||||
}
|
||||
return lastProfiles
|
||||
}
|
||||
|
||||
// ─── Profile activation ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Activates a profile in the current web session and returns the result.
|
||||
*
|
||||
* - Personal profiles (profile_type="default") succeed immediately and return [BmlActivationResult.Success].
|
||||
* - Business profiles (profile_type="business") require SMS/email OTP; returns
|
||||
* [BmlActivationResult.NeedsBusinessOtp] with available channels. Follow up with
|
||||
* [requestBusinessOtp] + [submitBusinessOtp].
|
||||
*/
|
||||
fun activateProfile(profile: BmlProfile, loginTag: String): BmlActivationResult {
|
||||
// Single-profile accounts: server already activated during login() and set blaze_identity.
|
||||
// autoActivated=true is the sentinel for this case — skip the profile GET entirely.
|
||||
if (profile.autoActivated) {
|
||||
val (session, accounts) = doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
|
||||
return BmlActivationResult.Success(session, accounts)
|
||||
}
|
||||
|
||||
val xsrf = xsrfToken()
|
||||
val reqBuilder = Request.Builder()
|
||||
.url("$BASE_URL/web/profile/${profile.profileId}")
|
||||
.header("User-Agent", WEB_USER_AGENT)
|
||||
if (xsrf != null) reqBuilder.header("X-XSRF-TOKEN", xsrf)
|
||||
|
||||
val resp = client.newCall(reqBuilder.build()).execute()
|
||||
val code = resp.code
|
||||
val location = resp.header("Location") ?: ""
|
||||
resp.close()
|
||||
|
||||
return when {
|
||||
code == 409 || (code == 302 && "/web/profile/2fa/business" !in location) -> {
|
||||
// Profile activated — blaze_identity cookie set in response headers.
|
||||
// Any 302 that isn't to the business 2FA page means success.
|
||||
val (session, accounts) = doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
|
||||
BmlActivationResult.Success(session, accounts)
|
||||
}
|
||||
code == 302 && "/web/profile/2fa/business" in location -> {
|
||||
// Business profile: server requires SMS/email OTP
|
||||
val channels = fetchBusinessOtpChannels()
|
||||
BmlActivationResult.NeedsBusinessOtp(channels)
|
||||
}
|
||||
else -> throw Exception("Profile activation failed (HTTP $code)")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns available OTP channels for the business 2FA page.
|
||||
* Also refreshes cookies so the subsequent POST has a valid XSRF token.
|
||||
*/
|
||||
private fun fetchBusinessOtpChannels(): List<BmlOtpChannel> {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/web/profile/2fa/business")
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
).execute()
|
||||
val body = resp.body?.string() ?: ""
|
||||
resp.close()
|
||||
return parseBusinessOtpChannels(body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an OTP to [channel] for business profile activation.
|
||||
* Must be called before [submitBusinessOtp].
|
||||
*/
|
||||
fun requestBusinessOtp(channel: String) {
|
||||
val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
|
||||
val body = JSONObject().apply {
|
||||
put("code", "")
|
||||
put("channel", channel)
|
||||
}.toString().toRequestBody("application/json".toMediaType())
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body)
|
||||
.header("X-XSRF-TOKEN", xsrf)
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
).execute()
|
||||
val respCode = resp.code
|
||||
resp.close()
|
||||
if (respCode != 302) throw Exception("Failed to request OTP (HTTP $respCode)")
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the OTP and activates the business profile.
|
||||
* Returns a new [BmlSession] and accounts on success.
|
||||
* @throws Exception if the OTP is invalid (retry is allowed).
|
||||
*/
|
||||
fun submitBusinessOtp(
|
||||
channel: String,
|
||||
code: String,
|
||||
profile: BmlProfile,
|
||||
loginTag: String
|
||||
): Pair<BmlSession, List<BankAccount>> {
|
||||
// Refresh XSRF token before submitting
|
||||
client.newCall(
|
||||
Request.Builder().url("$BASE_URL/web/profile/2fa/business")
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
).execute().close()
|
||||
|
||||
// Step 6: PKCE OAuth authorize → extract auth code
|
||||
val codeVerifier = generateCodeVerifier()
|
||||
val codeChallenge = generateCodeChallenge(codeVerifier)
|
||||
val deviceId = generateDeviceId()
|
||||
val xsrf = xsrfToken() ?: throw Exception("Session expired — please log in again")
|
||||
val body = JSONObject().apply {
|
||||
put("code", code)
|
||||
put("channel", channel)
|
||||
}.toString().toRequestBody("application/json".toMediaType())
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/web/profile/2fa/business").post(body)
|
||||
.header("X-XSRF-TOKEN", xsrf)
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
).execute()
|
||||
val respCode = resp.code
|
||||
val location = resp.header("Location") ?: ""
|
||||
resp.close()
|
||||
|
||||
return when {
|
||||
respCode == 409 || (respCode == 302 && "/web/redirect" in location) ->
|
||||
doOAuthAndFetchAccounts(loginTag, profile.name, profile.profileId)
|
||||
respCode == 302 ->
|
||||
throw Exception("Invalid OTP — please try again")
|
||||
else ->
|
||||
throw Exception("Business OTP verification failed (HTTP $respCode)")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── OAuth + account fetch ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Completes PKCE OAuth for the currently activated profile (blaze_identity cookie set).
|
||||
* Returns a fresh [BmlSession] and the profile's accounts.
|
||||
*/
|
||||
private fun doOAuthAndFetchAccounts(
|
||||
loginTag: String,
|
||||
profileName: String,
|
||||
profileId: String
|
||||
): Pair<BmlSession, List<BankAccount>> {
|
||||
val authorizeUrl = HttpUrl.Builder()
|
||||
.scheme("https").host("www.bankofmaldives.com.mv")
|
||||
.addPathSegments("internetbanking/oauth/authorize")
|
||||
@@ -135,14 +286,12 @@ class BmlLoginFlow {
|
||||
Request.Builder().url(authorizeUrl)
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
).execute()
|
||||
val location = authorizeResp.header("Location")
|
||||
authorizeResp.close()
|
||||
|
||||
val location = authorizeResp.header("Location")
|
||||
?: throw Exception("OAuth authorize did not redirect")
|
||||
val authCode = Uri.parse(location).getQueryParameter("code")
|
||||
?: throw Exception("No auth code in OAuth redirect")
|
||||
val authCode = location?.let { Uri.parse(it).getQueryParameter("code") }
|
||||
?: throw Exception("OAuth authorize did not return auth code")
|
||||
|
||||
// Step 7: Exchange auth code for access token
|
||||
val tokenBody = FormBody.Builder()
|
||||
.add("Device-ID", deviceId)
|
||||
.add("code", authCode)
|
||||
@@ -164,563 +313,95 @@ class BmlLoginFlow {
|
||||
val tokenObj = JSONObject(tokenJson)
|
||||
val accessToken = tokenObj.optString("access_token")
|
||||
.takeIf { it.isNotBlank() } ?: throw Exception("Token exchange failed")
|
||||
val refreshToken = tokenObj.optString("refresh_token", "")
|
||||
val expiresIn = tokenObj.optLong("expires_in", 0L)
|
||||
val expiresAt = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000L else 0L
|
||||
|
||||
val session = BmlSession(accessToken = accessToken, deviceId = deviceId)
|
||||
val accounts = fetchAccounts(session, "bml_$username")
|
||||
val session = BmlSession(accessToken = accessToken, deviceId = deviceId, refreshToken = refreshToken, expiresAt = expiresAt)
|
||||
val accounts = BmlAccountClient().fetchAccounts(session, loginTag, profileName, profileId)
|
||||
return Pair(session, accounts)
|
||||
}
|
||||
|
||||
fun fetchAccounts(session: BmlSession, loginTag: String): List<MibAccount> {
|
||||
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/dashboard")).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
return parseDashboard(json ?: return emptyList(), loginTag)
|
||||
}
|
||||
|
||||
fun fetchForeignLimits(session: BmlSession): List<BmlForeignLimit> {
|
||||
val resp = apiClient.newCall(
|
||||
Request.Builder().url("https://app.bankofmaldives.com.mv/api/v2/foreign-limits")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
return parseForeignLimits(json ?: return emptyList())
|
||||
}
|
||||
|
||||
data class BmlUserInfo(
|
||||
val fullName: String,
|
||||
val email: String,
|
||||
val mobile: String,
|
||||
val customerId: String,
|
||||
val idCard: String,
|
||||
val birthdate: String
|
||||
)
|
||||
|
||||
fun fetchUserInfo(session: BmlSession): BmlUserInfo? {
|
||||
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/userinfo")).execute()
|
||||
val json = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return null
|
||||
val user = root.optJSONObject("payload")?.optJSONObject("user") ?: return null
|
||||
BmlUserInfo(
|
||||
fullName = user.optString("fullname").trim(),
|
||||
email = user.optString("email").trim(),
|
||||
mobile = user.optString("mobile_phone").trim(),
|
||||
customerId = user.optString("customer_number").trim(),
|
||||
idCard = user.optString("idcard").trim(),
|
||||
birthdate = user.optString("birthdate").trim()
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun validateAccount(session: BmlSession, input: String): BmlAccountValidation? {
|
||||
val resp = apiClient.newCall(
|
||||
Request.Builder().url("$BASE_URL/api/mobile/validate/account/$input")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return null
|
||||
val payload = root.optJSONObject("payload") ?: return null
|
||||
val trnType = payload.optString("trnType", "")
|
||||
val validationType = payload.optString("validationType", "")
|
||||
if (validationType == "alias") {
|
||||
val cdtrAcct = payload.optJSONObject("CdtrAcct") ?: return null
|
||||
BmlAccountValidation(
|
||||
trnType = trnType,
|
||||
validationType = validationType,
|
||||
account = cdtrAcct.optString("Acct"),
|
||||
originalInput = input,
|
||||
name = payload.optString("contact_name").trim(),
|
||||
alias = null,
|
||||
currency = payload.optString("currency", "MVR"),
|
||||
agnt = cdtrAcct.optString("FinInstnId").takeIf { it.isNotBlank() }
|
||||
)
|
||||
} else {
|
||||
BmlAccountValidation(
|
||||
trnType = trnType,
|
||||
validationType = validationType,
|
||||
account = payload.optString("account"),
|
||||
originalInput = input,
|
||||
name = payload.optString("name"),
|
||||
alias = payload.optString("alias").takeIf { it.isNotBlank() && it != "null" },
|
||||
currency = payload.optString("currency", "MVR")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun verifyMibAccount(session: BmlSession, account: String): BmlAccountValidation? {
|
||||
val resp = apiClient.newCall(
|
||||
Request.Builder().url("$BASE_URL/api/mobile/favara/account-verification/$account/MIB")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return null
|
||||
BmlAccountValidation(
|
||||
trnType = "DOT",
|
||||
validationType = "MIB",
|
||||
account = root.optString("account"),
|
||||
originalInput = account,
|
||||
name = root.optString("name"),
|
||||
alias = null,
|
||||
currency = "MVR",
|
||||
agnt = root.optString("agnt").takeIf { it.isNotBlank() }
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun saveContact(
|
||||
session: BmlSession,
|
||||
contactType: String,
|
||||
account: String,
|
||||
alias: String,
|
||||
currency: String? = null,
|
||||
name: String? = null,
|
||||
swift: String? = null
|
||||
): Boolean {
|
||||
val bodyObj = JSONObject().apply {
|
||||
put("contact_type", contactType)
|
||||
put("account", account)
|
||||
put("alias", alias)
|
||||
if (currency != null) put("currency", currency)
|
||||
if (name != null) put("name", name)
|
||||
if (swift != null) put("swift", swift)
|
||||
}
|
||||
val resp = apiClient.newCall(
|
||||
Request.Builder().url("$BASE_URL/api/mobile/contacts")
|
||||
.post(bodyObj.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return false
|
||||
resp.close()
|
||||
return try { JSONObject(json).optBoolean("success") } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
fun fetchContacts(session: BmlSession, loginId: String): List<MibBeneficiary> {
|
||||
val resp = apiClient.newCall(apiRequest(session, "$BASE_URL/api/mobile/contacts")).execute()
|
||||
val json = resp.body?.string() ?: return emptyList()
|
||||
resp.close()
|
||||
return parseContacts(json, loginId)
|
||||
}
|
||||
// ─── Token refresh ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Step 1 of BML transfer: POST without OTP. Returns true if server responds code=22 (OTP ready).
|
||||
* Uses the saved refresh token to obtain a new access token without re-login.
|
||||
* Returns a new [BmlSession] with updated tokens.
|
||||
*/
|
||||
fun initiateTransfer(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
creditAccount: String,
|
||||
amount: Double,
|
||||
transferType: String,
|
||||
currency: String,
|
||||
bank: String? = null
|
||||
): Boolean {
|
||||
val jo = JSONObject().apply {
|
||||
put("debitAccount", debitAccount)
|
||||
put("creditAccount", creditAccount)
|
||||
put("debitAmount", amount)
|
||||
put("transfertype", transferType)
|
||||
put("currency", currency)
|
||||
put("channel", "token")
|
||||
if (bank != null) put("bank", bank)
|
||||
}
|
||||
val body = jo.toString().toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("$BASE_URL/api/mobile/transfer")
|
||||
.post(body)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
fun refreshSession(session: BmlSession): BmlSession {
|
||||
val body = FormBody.Builder()
|
||||
.add("grant_type", "refresh_token")
|
||||
.add("refresh_token", session.refreshToken)
|
||||
.add("client_id", CLIENT_ID)
|
||||
.add("Device-ID", session.deviceId)
|
||||
.add("User-Agent", APP_USER_AGENT)
|
||||
.add("x-app-version", APP_VERSION)
|
||||
.build()
|
||||
return apiClient.newCall(request).execute().use { response ->
|
||||
val bodyStr = response.body?.string() ?: return@use false
|
||||
try {
|
||||
val json = JSONObject(bodyStr)
|
||||
json.optBoolean("success") && json.optInt("code") == 22
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2 of BML transfer: POST with OTP + remarks. Returns BmlTransferResult.
|
||||
*/
|
||||
fun confirmTransfer(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
creditAccount: String,
|
||||
amount: Double,
|
||||
transferType: String,
|
||||
currency: String,
|
||||
otp: String,
|
||||
remarks: String = "",
|
||||
bank: String? = null
|
||||
): BmlTransferResult {
|
||||
val jo = JSONObject().apply {
|
||||
put("debitAccount", debitAccount)
|
||||
put("creditAccount", creditAccount)
|
||||
put("debitAmount", amount)
|
||||
put("transfertype", transferType)
|
||||
put("currency", currency)
|
||||
put("channel", "token")
|
||||
put("otp", otp)
|
||||
if (remarks.isNotBlank()) put("remarks", remarks)
|
||||
if (bank != null) put("bank", bank)
|
||||
}
|
||||
val body = jo.toString().toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("$BASE_URL/api/mobile/transfer")
|
||||
.post(body)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return apiClient.newCall(request).execute().use { response ->
|
||||
val bodyStr = response.body?.string()
|
||||
?: return@use BmlTransferResult(false, errorMessage = "No response")
|
||||
try {
|
||||
val json = JSONObject(bodyStr)
|
||||
if (!json.optBoolean("success")) {
|
||||
BmlTransferResult(false, errorMessage = json.optString("message").ifBlank { "Transfer failed" })
|
||||
} else {
|
||||
val payload = json.optJSONObject("payload")
|
||||
BmlTransferResult(
|
||||
success = true,
|
||||
reference = payload?.optString("reference") ?: "",
|
||||
timestamp = payload?.optString("timestamp") ?: "",
|
||||
message = json.optString("message")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { BmlTransferResult(false, errorMessage = "Parse error") }
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteContact(session: BmlSession, contactId: String): Boolean {
|
||||
val body = """{"_method":"delete"}""".toRequestBody("application/json".toMediaType())
|
||||
val request = Request.Builder()
|
||||
.url("$BASE_URL/api/mobile/contacts/$contactId")
|
||||
.post(body)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return apiClient.newCall(request).execute().use { response ->
|
||||
val bodyStr = response.body?.string() ?: return@use false
|
||||
try { JSONObject(bodyStr).optBoolean("success") } catch (_: Exception) { false }
|
||||
}
|
||||
}
|
||||
|
||||
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
|
||||
private fun parsePurchaseNarrative1(narrative1: String): String? {
|
||||
return try {
|
||||
val parts = narrative1.split(" ")
|
||||
if (parts.size < 2) null
|
||||
else {
|
||||
val timePart = parts[1].take(4)
|
||||
val combined = "${parts[0]} ${timePart.take(2)}:${timePart.drop(2)}:00"
|
||||
val date = SimpleDateFormat("dd-MM-yyyy HH:mm:ss", Locale.US).parse(combined)
|
||||
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
// "11-04-2026 13-17-08" → yyyy-MM-dd HH:mm:ss
|
||||
private fun parseTransferNarrative1(narrative1: String): String? {
|
||||
return try {
|
||||
val date = SimpleDateFormat("dd-MM-yyyy HH-mm-ss", Locale.US).parse(narrative1)
|
||||
date?.let { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).format(it) }
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches paginated transaction history for a BML CASA account.
|
||||
* @return Pair of (transactions, totalPages)
|
||||
*/
|
||||
fun fetchAccountHistory(
|
||||
session: BmlSession,
|
||||
accountId: String,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String,
|
||||
page: Int
|
||||
): Pair<List<Transaction>, Int> {
|
||||
val resp = apiClient.newCall(
|
||||
Request.Builder().url("$BASE_URL/api/mobile/account/$accountId/history/$page")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.build()
|
||||
val resp = newBmlApiClient().newCall(
|
||||
Request.Builder().url("$BASE_URL/oauth/token").post(body)
|
||||
.header("User-Agent", WEB_USER_AGENT).build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||
val json = resp.body?.string() ?: throw Exception("Empty refresh response")
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return Pair(emptyList(), 0)
|
||||
val payload = root.optJSONObject("payload") ?: return Pair(emptyList(), 0)
|
||||
val totalPages = payload.optInt("totalPages", 0)
|
||||
val history = payload.optJSONArray("history") ?: return Pair(emptyList(), totalPages)
|
||||
val transactions = (0 until history.length()).map { i ->
|
||||
val item = history.getJSONObject(i)
|
||||
val desc = item.optString("description").trim()
|
||||
val narrative1 = item.optString("narrative1")
|
||||
val date = when (desc) {
|
||||
"Purchase" -> parsePurchaseNarrative1(narrative1) ?: item.optString("bookingDate")
|
||||
"Transfer Debit", "Transfer Credit" -> parseTransferNarrative1(narrative1) ?: item.optString("bookingDate")
|
||||
else -> item.optString("bookingDate")
|
||||
}
|
||||
Transaction(
|
||||
id = item.optString("id"),
|
||||
date = date,
|
||||
description = desc,
|
||||
amount = item.optDouble("amount", 0.0),
|
||||
currency = item.optString("currency"),
|
||||
counterpartyName = item.optString("narrative2").takeIf { it.isNotBlank() },
|
||||
reference = item.optString("reference").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML"
|
||||
)
|
||||
}
|
||||
Pair(transactions, totalPages)
|
||||
} catch (_: Exception) { Pair(emptyList(), 0) }
|
||||
val obj = JSONObject(json)
|
||||
val newAccess = obj.optString("access_token").takeIf { it.isNotBlank() }
|
||||
?: throw Exception("Token refresh failed")
|
||||
val newRefresh = obj.optString("refresh_token", "").ifBlank { session.refreshToken }
|
||||
val expiresIn = obj.optLong("expires_in", 0L)
|
||||
val expiresAt = if (expiresIn > 0) System.currentTimeMillis() + expiresIn * 1000L else 0L
|
||||
return BmlSession(accessToken = newAccess, deviceId = session.deviceId, refreshToken = newRefresh, expiresAt = expiresAt)
|
||||
}
|
||||
|
||||
// ─── Parsing ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetches card statement for a BML prepaid card for the given month ("YYYYMM").
|
||||
* Returns combined outstanding authorizations + settled statement entries.
|
||||
* BML web responses are Inertia.js pages — the data is embedded as HTML-escaped JSON
|
||||
* in the `data-page="..."` attribute of the root div. This extracts and unescapes it.
|
||||
*/
|
||||
fun fetchCardHistory(
|
||||
session: BmlSession,
|
||||
cardId: String,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String,
|
||||
month: String
|
||||
): List<Transaction> {
|
||||
val body = """{"card":"$cardId","month":"$month"}"""
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val resp = apiClient.newCall(
|
||||
Request.Builder().url("$BASE_URL/api/mobile/card/statement").post(body)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return emptyList()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload = root.optJSONObject("payload") ?: return emptyList()
|
||||
val result = mutableListOf<Transaction>()
|
||||
|
||||
// Outstanding authorizations
|
||||
val authDetails = payload.optJSONObject("outstanding")
|
||||
?.optJSONArray("CardOutStdAuthDetails")
|
||||
if (authDetails != null) {
|
||||
for (i in 0 until authDetails.length()) {
|
||||
val item = authDetails.getJSONObject(i)
|
||||
result.add(Transaction(
|
||||
id = "auth_${item.optString("TranApprCode")}_$i",
|
||||
date = item.optString("DateTime"),
|
||||
description = item.optString("TranDesc").trim(),
|
||||
amount = item.optDouble("BillingAmount", 0.0),
|
||||
currency = item.optString("BillingCcy", "MVR"),
|
||||
counterpartyName = null,
|
||||
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// Settled statement entries
|
||||
val statement = payload.optJSONArray("cardstatement")
|
||||
if (statement != null) {
|
||||
for (i in 0 until statement.length()) {
|
||||
val item = statement.getJSONObject(i)
|
||||
result.add(Transaction(
|
||||
id = "stmt_${item.optString("TranRef", i.toString())}",
|
||||
date = item.optString("TransDate", item.optString("TranDate", "")),
|
||||
description = item.optString("TranDesc", item.optString("Description", "")).trim(),
|
||||
amount = -item.optDouble("TranAmount", 0.0),
|
||||
currency = item.optString("TranCcy", "MVR"),
|
||||
counterpartyName = null,
|
||||
reference = item.optString("TranRef").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
))
|
||||
}
|
||||
}
|
||||
result
|
||||
} catch (_: Exception) { emptyList() }
|
||||
private fun extractInertiaJson(html: String): String? {
|
||||
val match = Regex("""data-page="([^"]+)"""").find(html) ?: return null
|
||||
return match.groupValues[1]
|
||||
.replace(""", "\"")
|
||||
.replace("&", "&")
|
||||
.replace("'", "'")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
}
|
||||
|
||||
private fun apiRequest(session: BmlSession, url: String) =
|
||||
Request.Builder().url(url)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", APP_USER_AGENT)
|
||||
.header("x-app-version", APP_VERSION)
|
||||
.build()
|
||||
|
||||
private fun parseDashboard(json: String, loginTag: String): List<MibAccount> {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val dashboard = root.optJSONObject("payload")?.optJSONArray("dashboard") ?: return emptyList()
|
||||
|
||||
val casaAccounts = mutableListOf<MibAccount>()
|
||||
val prepaidCards = mutableListOf<MibAccount>()
|
||||
|
||||
for (i in 0 until dashboard.length()) {
|
||||
val item = dashboard.getJSONObject(i)
|
||||
val currency = item.optString("currency", "MVR")
|
||||
val accountType = item.optString("account_type", "CASA")
|
||||
val product = item.optString("product")
|
||||
val accountNumber = item.optString("account")
|
||||
val status = item.optString("account_status", "Active")
|
||||
|
||||
val internalId = item.optString("id", "")
|
||||
|
||||
if (accountType == "CASA") {
|
||||
val available = item.optDouble("availableBalance", 0.0)
|
||||
casaAccounts.add(MibAccount(
|
||||
profileName = "Personal",
|
||||
profileType = "BML",
|
||||
accountNumber = accountNumber,
|
||||
accountBriefName = item.optString("alias"),
|
||||
currencyName = currency,
|
||||
accountTypeName = product,
|
||||
availableBalance = "%.2f".format(available),
|
||||
currentBalance = "%.2f".format(item.optDouble("ledgerBalance", 0.0)),
|
||||
blockedAmount = "%.2f".format(item.optDouble("lockedAmount", 0.0)),
|
||||
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
||||
statusDesc = status,
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
internalId = internalId
|
||||
))
|
||||
} else if (accountType == "Card") {
|
||||
val isVisible = item.optBoolean("account_visible", false)
|
||||
if (!isVisible) continue // debit cards and other hidden cards — skip
|
||||
val isPrepaid = item.optBoolean("prepaid_card", false)
|
||||
val cardBalance = item.optJSONObject("cardBalance")
|
||||
val available = cardBalance?.optDouble("AvailableLimit", 0.0) ?: 0.0
|
||||
val current = cardBalance?.optDouble("CurrentBalance", 0.0) ?: 0.0
|
||||
prepaidCards.add(MibAccount(
|
||||
profileName = "Personal",
|
||||
profileType = if (isPrepaid) "BML_PREPAID" else "BML_CREDIT",
|
||||
accountNumber = accountNumber,
|
||||
accountBriefName = item.optString("alias").ifBlank { product },
|
||||
currencyName = currency,
|
||||
accountTypeName = product,
|
||||
availableBalance = "%.2f".format(available),
|
||||
currentBalance = "%.2f".format(current),
|
||||
blockedAmount = "0.00",
|
||||
mvrBalance = if (currency == "MVR") "%.2f".format(available) else "0.00",
|
||||
statusDesc = status,
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
internalId = internalId
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
return casaAccounts + prepaidCards
|
||||
}
|
||||
|
||||
private fun parseForeignLimits(json: String): List<BmlForeignLimit> {
|
||||
private fun parseProfiles(html: String): List<BmlProfile> {
|
||||
return try {
|
||||
val json = extractInertiaJson(html) ?: html
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload = root.optJSONArray("payload") ?: return emptyList()
|
||||
(0 until payload.length()).map { i ->
|
||||
val item = payload.getJSONObject(i)
|
||||
val usage = item.optJSONObject("usageByCategory") ?: JSONObject()
|
||||
val atm = usage.optJSONObject("ATM") ?: JSONObject()
|
||||
val ecom = usage.optJSONObject("ECOM") ?: JSONObject()
|
||||
val pos = usage.optJSONObject("POS") ?: JSONObject()
|
||||
BmlForeignLimit(
|
||||
type = item.optString("type", "Debit"),
|
||||
used = item.optDouble("used", 0.0),
|
||||
totalLimit = item.optDouble("totalLimit", 0.0),
|
||||
generalCap = item.optDouble("generalCap", 0.0),
|
||||
generalRemaining = item.optDouble("generalRemaining", 0.0),
|
||||
medicalRemaining = item.optDouble("medicalRemaining", 0.0),
|
||||
isAtmEnabled = item.optBoolean("isAtmEnabled", false),
|
||||
isPosEnabled = item.optBoolean("isPosEnabled", false),
|
||||
atmRemaining = atm.optDouble("remaining", 0.0),
|
||||
atmLimit = atm.optDouble("limit", 0.0),
|
||||
ecomRemaining = ecom.optDouble("remaining", 0.0),
|
||||
ecomLimit = ecom.optDouble("limit", 0.0),
|
||||
posRemaining = pos.optDouble("remaining", 0.0),
|
||||
posLimit = pos.optDouble("limit", 0.0)
|
||||
val props = root.optJSONObject("props") ?: return emptyList()
|
||||
val profiles = props.optJSONArray("profiles") ?: return emptyList()
|
||||
(0 until profiles.length()).mapNotNull { i ->
|
||||
val p = profiles.getJSONObject(i)
|
||||
val profileObj = p.optJSONObject("profile") ?: return@mapNotNull null
|
||||
BmlProfile(
|
||||
profileId = p.optString("profile_id"),
|
||||
name = p.optString("name"),
|
||||
type = p.optString("type"),
|
||||
profileType = profileObj.optString("profile_type", "default")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
private fun parseContacts(json: String, loginId: String = ""): List<MibBeneficiary> {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return emptyList()
|
||||
val payload: JSONArray = root.optJSONArray("payload") ?: return emptyList()
|
||||
val result = mutableListOf<MibBeneficiary>()
|
||||
for (i in 0 until payload.length()) {
|
||||
val item = payload.getJSONObject(i)
|
||||
val account = item.optString("account", "")
|
||||
if (account.isBlank()) continue
|
||||
result.add(MibBeneficiary(
|
||||
benefNo = "bml_${item.optInt("id")}",
|
||||
benefName = item.optString("name"),
|
||||
benefNickName = item.optString("alias", item.optString("name")),
|
||||
benefAccount = account,
|
||||
benefType = "I",
|
||||
bankColor = "#0066A1",
|
||||
benefBankName = "Bank of Maldives",
|
||||
bankCode = "",
|
||||
benefStatus = item.optString("status", "S"),
|
||||
transferCyDesc = item.optString("currency", "MVR"),
|
||||
customerImgHash = null,
|
||||
benefCategoryId = "BML",
|
||||
profileId = loginId
|
||||
))
|
||||
}
|
||||
return result
|
||||
private fun parseBusinessOtpChannels(html: String): List<BmlOtpChannel> {
|
||||
return try {
|
||||
val json = extractInertiaJson(html) ?: html
|
||||
val root = JSONObject(json)
|
||||
val props = root.optJSONObject("props") ?: return emptyList()
|
||||
val channels = props.optJSONArray("channels") ?: return emptyList()
|
||||
(0 until channels.length()).map { i ->
|
||||
val c = channels.getJSONObject(i)
|
||||
BmlOtpChannel(
|
||||
channel = c.optString("channel"),
|
||||
description = c.optString("description"),
|
||||
masked = c.optString("masked")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
private fun xsrfToken(): String? =
|
||||
cookieStore["www.bankofmaldives.com.mv"]?.firstOrNull { it.name == "XSRF-TOKEN" }?.value
|
||||
|
||||
@@ -746,4 +427,5 @@ class BmlLoginFlow {
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,10 +1,40 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
|
||||
data class BmlSession(
|
||||
val accessToken: String,
|
||||
val deviceId: String
|
||||
val deviceId: String,
|
||||
val refreshToken: String = "",
|
||||
val expiresAt: Long = 0L // Unix millis; 0 = unknown
|
||||
) {
|
||||
fun isExpired() = expiresAt > 0L && System.currentTimeMillis() >= expiresAt
|
||||
}
|
||||
|
||||
data class BmlProfile(
|
||||
val profileId: String,
|
||||
val name: String,
|
||||
val type: String, // "Profile" (personal) or "Business"
|
||||
val profileType: String, // "default" or "business"
|
||||
val autoActivated: Boolean = false // true for single-profile accounts where server skips the picker
|
||||
)
|
||||
|
||||
data class BmlOtpChannel(
|
||||
val channel: String,
|
||||
val description: String,
|
||||
val masked: String
|
||||
)
|
||||
|
||||
sealed class BmlActivationResult {
|
||||
data class Success(
|
||||
val session: BmlSession,
|
||||
val accounts: List<BankAccount>
|
||||
) : BmlActivationResult()
|
||||
data class NeedsBusinessOtp(
|
||||
val channels: List<BmlOtpChannel>
|
||||
) : BmlActivationResult()
|
||||
}
|
||||
|
||||
data class BmlAccountValidation(
|
||||
val trnType: String, // IAT, QTR, DOT
|
||||
val validationType: String, // BML, alias, MIB
|
||||
@@ -24,6 +54,34 @@ data class BmlTransferResult(
|
||||
val errorMessage: String = ""
|
||||
)
|
||||
|
||||
data class BmlLoanDetail(
|
||||
val loanAmount: Double,
|
||||
val outstandingAmt: Double, // negative as returned by API
|
||||
val repayAmount: Double,
|
||||
val intRate: Double,
|
||||
val loanStatus: String,
|
||||
val startDate: String, // ISO8601 e.g. "2023-10-26T00:00:00+05:00"
|
||||
val endDate: String,
|
||||
val noOfRepayOverdue: Int,
|
||||
val overdueAmount: Double
|
||||
)
|
||||
|
||||
data class BmlQrPayInfo(
|
||||
val requestId: String, // base64-encoded full QR URL (trxn_hash)
|
||||
val merchantName: String, // narrative1
|
||||
val merchantAddress: String, // narrative2 + narrative3
|
||||
val amount: Double, // 0.0 for static QR
|
||||
val currency: String
|
||||
)
|
||||
|
||||
data class BmlQrPayResult(
|
||||
val success: Boolean,
|
||||
val merchant: String = "",
|
||||
val amount: String = "",
|
||||
val currency: String = "",
|
||||
val errorMessage: String = ""
|
||||
)
|
||||
|
||||
data class BmlForeignLimit(
|
||||
val type: String,
|
||||
val used: Double,
|
||||
|
||||
153
app/src/main/java/sh/sar/basedbank/api/bml/BmlQrPayClient.kt
Normal file
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
100
app/src/main/java/sh/sar/basedbank/api/bml/BmlTransferClient.kt
Normal file
@@ -0,0 +1,100 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
|
||||
class BmlTransferClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
/** Step 1: initiate the transfer (triggers OTP). Returns true if the server accepted it. */
|
||||
fun initiateTransfer(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
creditAccount: String,
|
||||
amount: Double,
|
||||
transferType: String,
|
||||
currency: String,
|
||||
bank: String? = null,
|
||||
channel: String = "token"
|
||||
): Boolean {
|
||||
val jo = JSONObject().apply {
|
||||
put("debitAccount", debitAccount)
|
||||
put("creditAccount", creditAccount)
|
||||
put("debitAmount", amount)
|
||||
put("transfertype", transferType)
|
||||
put("currency", currency)
|
||||
put("channel", channel)
|
||||
if (bank != null) put("bank", bank)
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$BML_BASE_URL/api/mobile/transfer")
|
||||
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val bodyStr = response.body?.string() ?: return@use false
|
||||
try {
|
||||
val json = JSONObject(bodyStr)
|
||||
json.optBoolean("success") && json.optInt("code") == 22
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
}
|
||||
|
||||
/** Step 2: confirm with OTP. Returns a [BmlTransferResult] with success/reference/error. */
|
||||
fun confirmTransfer(
|
||||
session: BmlSession,
|
||||
debitAccount: String,
|
||||
creditAccount: String,
|
||||
amount: Double,
|
||||
transferType: String,
|
||||
currency: String,
|
||||
otp: String,
|
||||
remarks: String = "",
|
||||
bank: String? = null,
|
||||
channel: String = "token"
|
||||
): BmlTransferResult {
|
||||
val jo = JSONObject().apply {
|
||||
put("debitAccount", debitAccount)
|
||||
put("creditAccount", creditAccount)
|
||||
put("debitAmount", amount)
|
||||
put("transfertype", transferType)
|
||||
put("currency", currency)
|
||||
put("channel", channel)
|
||||
put("otp", otp)
|
||||
if (remarks.isNotBlank()) put("remarks", remarks)
|
||||
if (bank != null) put("bank", bank)
|
||||
}
|
||||
val request = Request.Builder()
|
||||
.url("$BML_BASE_URL/api/mobile/transfer")
|
||||
.post(jo.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val bodyStr = response.body?.string()
|
||||
?: return@use BmlTransferResult(false, errorMessage = "No response")
|
||||
try {
|
||||
val json = JSONObject(bodyStr)
|
||||
if (!json.optBoolean("success")) {
|
||||
BmlTransferResult(false, errorMessage = json.optString("message").ifBlank { "Transfer failed" })
|
||||
} else {
|
||||
val payload = json.optJSONObject("payload")
|
||||
BmlTransferResult(
|
||||
success = true,
|
||||
reference = payload?.optString("reference") ?: "",
|
||||
timestamp = payload?.optString("timestamp") ?: "",
|
||||
message = json.optString("message")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { BmlTransferResult(false, errorMessage = "Parse error") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
|
||||
class BmlValidateClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
fun validateAccount(session: BmlSession, input: String): BmlAccountValidation? {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/validate/account/$input")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return null
|
||||
val payload = root.optJSONObject("payload") ?: return null
|
||||
val trnType = payload.optString("trnType", "")
|
||||
val validationType = payload.optString("validationType", "")
|
||||
if (validationType == "alias") {
|
||||
val cdtrAcct = payload.optJSONObject("CdtrAcct") ?: return null
|
||||
BmlAccountValidation(
|
||||
trnType = trnType,
|
||||
validationType = validationType,
|
||||
account = cdtrAcct.optString("Acct"),
|
||||
originalInput = input,
|
||||
name = payload.optString("contact_name").trim(),
|
||||
alias = null,
|
||||
currency = payload.optString("currency", "MVR"),
|
||||
agnt = cdtrAcct.optString("FinInstnId").takeIf { it.isNotBlank() }
|
||||
)
|
||||
} else {
|
||||
BmlAccountValidation(
|
||||
trnType = trnType,
|
||||
validationType = validationType,
|
||||
account = payload.optString("account"),
|
||||
originalInput = input,
|
||||
name = payload.optString("name"),
|
||||
alias = payload.optString("alias").takeIf { it.isNotBlank() && it != "null" },
|
||||
currency = payload.optString("currency", "MVR")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun verifyMibAccount(session: BmlSession, account: String): BmlAccountValidation? {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/favara/account-verification/$account/MIB")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return null
|
||||
BmlAccountValidation(
|
||||
trnType = "DOT",
|
||||
validationType = "MIB",
|
||||
account = root.optString("account"),
|
||||
originalInput = account,
|
||||
name = root.optString("name"),
|
||||
alias = null,
|
||||
currency = "MVR",
|
||||
agnt = root.optString("agnt").takeIf { it.isNotBlank() }
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package sh.sar.basedbank.api.fahipay
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FahipayAccountClient {
|
||||
|
||||
private val BASE_URL = "https://fahipay.mv"
|
||||
private val UA = "okhttp/4.12.0"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private fun Request.Builder.auth(session: FahipaySession): Request.Builder = this
|
||||
.header("authid", session.authId)
|
||||
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
|
||||
.header("content-type", "multipart/form-data")
|
||||
.header("User-Agent", UA)
|
||||
|
||||
fun fetchProfile(session: FahipaySession): FahipayUserProfile {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en")
|
||||
.auth(session).build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: throw Exception("Empty profile response")
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Fahipay")
|
||||
val obj = JSONObject(json)
|
||||
val props = obj.optJSONObject("props") ?: JSONObject()
|
||||
return FahipayUserProfile(
|
||||
fullName = obj.optString("fullname").trim(),
|
||||
email = obj.optString("email").trim(),
|
||||
mobile = obj.optString("mobile").trim(),
|
||||
nid = obj.optString("nid").trim(),
|
||||
profileId = obj.optString("profileID").trim(),
|
||||
walletAccount = props.optString("acc", ""),
|
||||
linkedAccounts = props.optJSONObject("accs")?.toString() ?: "{}"
|
||||
)
|
||||
}
|
||||
|
||||
fun fetchBalance(session: FahipaySession): Double {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en")
|
||||
.auth(session).build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return 0.0
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Fahipay")
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0)
|
||||
} catch (_: Exception) { 0.0 }
|
||||
}
|
||||
|
||||
fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): BankAccount =
|
||||
BankAccount(
|
||||
bank = "FAHIPAY",
|
||||
profileName = profile.fullName.ifBlank { "Fahipay" },
|
||||
profileType = "FAHIPAY",
|
||||
accountNumber = profile.walletAccount,
|
||||
accountBriefName = "Fahipay Wallet",
|
||||
currencyName = "MVR",
|
||||
accountTypeName = "Digital Wallet",
|
||||
availableBalance = "%.2f".format(balance),
|
||||
currentBalance = "%.2f".format(balance),
|
||||
blockedAmount = "0.00",
|
||||
mvrBalance = "%.2f".format(balance),
|
||||
statusDesc = "Active",
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
internalId = profile.profileId
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package sh.sar.basedbank.api.fahipay
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.util.AccountInputParser
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FahipayContactsClient {
|
||||
|
||||
private val BASE_URL = "https://fahipay.mv"
|
||||
private val UA = "okhttp/4.12.0"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
fun fetchContacts(session: FahipaySession): List<FahipayContactGroup> {
|
||||
val endpoints = listOf(
|
||||
Triple("FAHIPAY_RAASTAS", "Raastas", "ooredooraastas"),
|
||||
Triple("FAHIPAY_RELOAD", "Reload", "dhiraagureload"),
|
||||
Triple("FAHIPAY_OOREDOO_BILL", "Ooredoo Bill", "ooredoobillpay"),
|
||||
Triple("FAHIPAY_DHIRAAGU_BILL", "Dhiraagu Bill", "dhiraagubillpay")
|
||||
)
|
||||
val result = mutableListOf<FahipayContactGroup>()
|
||||
for ((catId, label, page) in endpoints) {
|
||||
try {
|
||||
val resp = client.newCall(
|
||||
Request.Builder()
|
||||
.url("$BASE_URL/api/app/favs/?page=$page&lang=en")
|
||||
.header("authid", session.authId)
|
||||
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
|
||||
.header("User-Agent", UA)
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: continue
|
||||
resp.close()
|
||||
val obj = JSONObject(json)
|
||||
val groupObj = obj.optJSONObject(page) ?: continue
|
||||
val contacts = mutableListOf<BankContact>()
|
||||
for (key in groupObj.keys()) {
|
||||
val entry = groupObj.getJSONObject(key)
|
||||
val number = entry.optString("number")
|
||||
val name = entry.optString("name").trim().ifBlank { number }
|
||||
if (AccountInputParser.detect(number) != AccountInputParser.InputType.PHONE) continue
|
||||
contacts.add(BankContact(
|
||||
benefNo = "fp_${page}_$number",
|
||||
benefName = "",
|
||||
benefNickName = name,
|
||||
benefAccount = number,
|
||||
benefType = "FAHIPAY",
|
||||
bankColor = "#FF6B00",
|
||||
benefBankName = label,
|
||||
bankCode = "",
|
||||
benefStatus = "",
|
||||
transferCyDesc = "",
|
||||
customerImgHash = null,
|
||||
benefCategoryId = catId,
|
||||
profileId = ""
|
||||
))
|
||||
}
|
||||
if (contacts.isNotEmpty()) result.add(FahipayContactGroup(catId, label, contacts))
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package sh.sar.basedbank.api.fahipay
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FahipayHistoryClient {
|
||||
|
||||
private val BASE_URL = "https://fahipay.mv"
|
||||
private val UA = "okhttp/4.12.0"
|
||||
private val PAGE_SIZE = 15
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
fun fetchHistory(
|
||||
session: FahipaySession,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String,
|
||||
start: Int
|
||||
): Pair<List<BankTransaction>, Int> {
|
||||
val resp = client.newCall(
|
||||
Request.Builder()
|
||||
.url("$BASE_URL/actions/activity/?s=$start&l=$PAGE_SIZE&lang=en")
|
||||
.header("authid", session.authId)
|
||||
.header("Cookie", "__Secure-sess=${session.sessionCookie}")
|
||||
.header("content-type", "multipart/form-data")
|
||||
.header("User-Agent", UA)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Fahipay")
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
val total = obj.optInt("total", 0)
|
||||
val entries = obj.optJSONArray("entries") ?: return Pair(emptyList(), total)
|
||||
val list = (0 until entries.length()).map { i ->
|
||||
val e = entries.getJSONObject(i)
|
||||
BankTransaction(
|
||||
id = e.optString("transaction"),
|
||||
date = e.optString("date"),
|
||||
description = e.optString("name").trim(),
|
||||
amount = e.optDouble("amount", 0.0),
|
||||
currency = "MVR",
|
||||
counterpartyName = e.optString("details").takeIf { it.isNotBlank() },
|
||||
reference = e.optString("transaction").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "FAHIPAY",
|
||||
iconUrl = e.optString("icon").takeIf { it.isNotBlank() }
|
||||
)
|
||||
}
|
||||
Pair(list, total)
|
||||
} catch (_: Exception) { Pair(emptyList(), 0) }
|
||||
}
|
||||
}
|
||||
@@ -10,19 +10,13 @@ import okhttp3.Request
|
||||
import okhttp3.RequestBody
|
||||
import okio.Buffer
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.util.AccountInputParser
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FahipayLoginFlow {
|
||||
|
||||
private val BASE_URL = "https://fahipay.mv"
|
||||
private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android 14; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
private val UA_OKHTTP = "okhttp/4.12.0"
|
||||
private val PAGE_SIZE = 15
|
||||
private val UA_WEBVIEW = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; ${Build.MODEL} Build/AP2A.240905.003; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
|
||||
private val cookieStore = mutableMapOf<String, MutableList<Cookie>>()
|
||||
private val cookieJar = object : CookieJar {
|
||||
@@ -144,164 +138,6 @@ class FahipayLoginFlow {
|
||||
?: throw Exception("No authID in OTP response")
|
||||
}
|
||||
|
||||
fun fetchProfile(session: FahipaySession): FahipayUserProfile {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/actions/getprofile/?lang=en")
|
||||
.header("authid", session.authId)
|
||||
.header("content-type", "multipart/form-data")
|
||||
.header("User-Agent", UA_OKHTTP)
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: throw Exception("Empty profile response")
|
||||
resp.close()
|
||||
|
||||
val obj = JSONObject(json)
|
||||
val props = obj.optJSONObject("props") ?: JSONObject()
|
||||
return FahipayUserProfile(
|
||||
fullName = obj.optString("fullname").trim(),
|
||||
email = obj.optString("email").trim(),
|
||||
mobile = obj.optString("mobile").trim(),
|
||||
nid = obj.optString("nid").trim(),
|
||||
profileId = obj.optString("profileID").trim(),
|
||||
walletAccount = props.optString("acc", ""),
|
||||
linkedAccounts = props.optJSONObject("accs")?.toString() ?: "{}"
|
||||
)
|
||||
}
|
||||
|
||||
fun fetchBalance(session: FahipaySession): Double {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/actions/getbalance/?lang=en")
|
||||
.header("authid", session.authId)
|
||||
.header("content-type", "multipart/form-data")
|
||||
.header("User-Agent", UA_OKHTTP)
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return 0.0
|
||||
resp.close()
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
if (obj.optBoolean("error")) 0.0 else obj.optDouble("balance", 0.0)
|
||||
} catch (_: Exception) { 0.0 }
|
||||
}
|
||||
|
||||
fun buildAccount(profile: FahipayUserProfile, balance: Double, loginTag: String): MibAccount =
|
||||
MibAccount(
|
||||
profileName = profile.fullName.ifBlank { "Fahipay" },
|
||||
profileType = "FAHIPAY",
|
||||
accountNumber = profile.walletAccount,
|
||||
accountBriefName = "Fahipay Wallet",
|
||||
currencyName = "MVR",
|
||||
accountTypeName = "Digital Wallet",
|
||||
availableBalance = "%.2f".format(balance),
|
||||
currentBalance = "%.2f".format(balance),
|
||||
blockedAmount = "0.00",
|
||||
mvrBalance = "%.2f".format(balance),
|
||||
statusDesc = "Active",
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
internalId = profile.profileId
|
||||
)
|
||||
|
||||
/**
|
||||
* Fetches paginated activity history.
|
||||
* @param start offset (0-based)
|
||||
* @return Pair of (transactions, total count)
|
||||
*/
|
||||
fun fetchHistory(
|
||||
session: FahipaySession,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String,
|
||||
start: Int
|
||||
): Pair<List<Transaction>, Int> {
|
||||
val resp = client.newCall(
|
||||
Request.Builder()
|
||||
.url("$BASE_URL/actions/activity/?s=$start&l=$PAGE_SIZE&lang=en")
|
||||
.header("authid", session.authId)
|
||||
.header("content-type", "multipart/form-data")
|
||||
.header("User-Agent", UA_OKHTTP)
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: return Pair(emptyList(), 0)
|
||||
resp.close()
|
||||
return try {
|
||||
val obj = JSONObject(json)
|
||||
val total = obj.optInt("total", 0)
|
||||
val entries = obj.optJSONArray("entries") ?: return Pair(emptyList(), total)
|
||||
val list = (0 until entries.length()).map { i ->
|
||||
val e = entries.getJSONObject(i)
|
||||
Transaction(
|
||||
id = e.optString("transaction"),
|
||||
date = e.optString("date"),
|
||||
description = e.optString("name").trim(),
|
||||
amount = e.optDouble("amount", 0.0),
|
||||
currency = "MVR",
|
||||
counterpartyName = e.optString("details").takeIf { it.isNotBlank() },
|
||||
reference = e.optString("transaction").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "FAHIPAY",
|
||||
iconUrl = e.optString("icon").takeIf { it.isNotBlank() }
|
||||
)
|
||||
}
|
||||
Pair(list, total)
|
||||
} catch (_: Exception) { Pair(emptyList(), 0) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches Fahipay saved favourites for the 4 service groups.
|
||||
* Only includes entries whose number is a valid 7-digit Maldivian phone number (starts with 7 or 9).
|
||||
* Groups with no valid entries are omitted.
|
||||
*/
|
||||
fun fetchContacts(session: FahipaySession): List<FahipayContactGroup> {
|
||||
val endpoints = listOf(
|
||||
Triple("FAHIPAY_RAASTAS", "Raastas", "ooredooraastas"),
|
||||
Triple("FAHIPAY_RELOAD", "Reload", "dhiraagureload"),
|
||||
Triple("FAHIPAY_OOREDOO_BILL", "Ooredoo Bill", "ooredoobillpay"),
|
||||
Triple("FAHIPAY_DHIRAAGU_BILL", "Dhiraagu Bill", "dhiraagubillpay")
|
||||
)
|
||||
val result = mutableListOf<FahipayContactGroup>()
|
||||
for ((catId, label, page) in endpoints) {
|
||||
try {
|
||||
val resp = client.newCall(
|
||||
Request.Builder()
|
||||
.url("$BASE_URL/api/app/favs/?page=$page&lang=en")
|
||||
.header("authid", session.authId)
|
||||
.header("User-Agent", UA_OKHTTP)
|
||||
.build()
|
||||
).execute()
|
||||
val json = resp.body?.string() ?: continue
|
||||
resp.close()
|
||||
val obj = JSONObject(json)
|
||||
// Empty group comes back as a JSON array [], not an object — optJSONObject returns null
|
||||
val groupObj = obj.optJSONObject(page) ?: continue
|
||||
val contacts = mutableListOf<MibBeneficiary>()
|
||||
for (key in groupObj.keys()) {
|
||||
val entry = groupObj.getJSONObject(key)
|
||||
val number = entry.optString("number")
|
||||
val name = entry.optString("name").trim().ifBlank { number }
|
||||
if (AccountInputParser.detect(number) != AccountInputParser.InputType.PHONE) continue
|
||||
contacts.add(MibBeneficiary(
|
||||
benefNo = "fp_${page}_$number",
|
||||
benefName = "",
|
||||
benefNickName = name,
|
||||
benefAccount = number,
|
||||
benefType = "FAHIPAY",
|
||||
bankColor = "#FF6B00",
|
||||
benefBankName = label,
|
||||
bankCode = "",
|
||||
benefStatus = "",
|
||||
transferCyDesc = "",
|
||||
customerImgHash = null,
|
||||
benefCategoryId = catId,
|
||||
profileId = ""
|
||||
))
|
||||
}
|
||||
if (contacts.isNotEmpty()) result.add(FahipayContactGroup(catId, label, contacts))
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun deviceParts(deviceUuid: String): Array<Pair<String, String>> = arrayOf(
|
||||
"device[available]" to "true",
|
||||
"device[platform]" to "Android",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package sh.sar.basedbank.api.fahipay
|
||||
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
|
||||
data class FahipaySession(
|
||||
val authId: String,
|
||||
val sessionCookie: String
|
||||
@@ -23,5 +25,5 @@ data class FahipayLoginStep(
|
||||
data class FahipayContactGroup(
|
||||
val categoryId: String,
|
||||
val label: String,
|
||||
val contacts: List<sh.sar.basedbank.api.mib.MibBeneficiary>
|
||||
val contacts: List<BankContact>
|
||||
)
|
||||
|
||||
63
app/src/main/java/sh/sar/basedbank/api/mib/MibCardsClient.kt
Normal file
@@ -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)
|
||||
|
||||
@@ -29,6 +29,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
var onSessionRefreshed: ((MibSession, List<MibProfile>) -> Unit)? = null
|
||||
|
||||
// Stored after login so the session can be silently recovered on 419
|
||||
@Volatile private var loginId: String = ""
|
||||
@Volatile private var storedUsername: String? = null
|
||||
@Volatile private var storedPasswordHash: String? = null
|
||||
@Volatile private var storedOtpSeed: String? = null
|
||||
@@ -38,7 +39,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")
|
||||
@@ -58,11 +59,12 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
* Returns list of accounts from all profiles on success.
|
||||
*/
|
||||
fun login(username: String, passwordHash: String, otpSeed: String): List<MibAccount> {
|
||||
loginId = username
|
||||
storedUsername = username
|
||||
storedPasswordHash = passwordHash
|
||||
storedOtpSeed = otpSeed
|
||||
val appId = getOrCreateAppId()
|
||||
val keys = credentialStore.loadMibKeys()
|
||||
val keys = credentialStore.loadMibKeys(loginId)
|
||||
|
||||
return if (keys != null) {
|
||||
regularLogin(username, passwordHash, appId, keys.first, keys.second)
|
||||
@@ -106,7 +108,7 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
val keyData = otpResp.getJSONArray("data").getJSONObject(0)
|
||||
val key1 = keyData.getString("key1")
|
||||
val key2 = keyData.getString("key2")
|
||||
credentialStore.saveMibKeys(key1, key2)
|
||||
credentialStore.saveMibKeys(loginId, key1, key2)
|
||||
|
||||
return regularLogin(username, passwordHash, appId, key1, key2)
|
||||
}
|
||||
@@ -136,10 +138,52 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
}
|
||||
|
||||
val profiles = parseProfiles(loginResp)
|
||||
|
||||
lastSession = session2
|
||||
lastProfiles = profiles
|
||||
return fetchAllProfiles(session2, profiles, "mib_$username")
|
||||
lastProfiles = profiles // keep ALL profiles so settings can show them all
|
||||
|
||||
val hidden = credentialStore.getHiddenMibProfileIds(loginId)
|
||||
|
||||
// When the server already selected the profile and returned balances in A41
|
||||
// (single-profile case: profileSelected=true), use those accounts directly
|
||||
// without making an extra P47 call (which the server ignores or rejects).
|
||||
if (loginResp.optBoolean("profileSelected", false)) {
|
||||
val a41Balances = loginResp.optJSONArray("accountBalance")
|
||||
if (a41Balances != null && a41Balances.length() > 0) {
|
||||
val selectedId = loginResp.optString("selectedProfileId")
|
||||
val profile = profiles.firstOrNull { it.profileId == selectedId }
|
||||
?: profiles.firstOrNull()
|
||||
if (profile != null && (hidden.isEmpty() || profile.profileId !in hidden)) {
|
||||
val allAccounts = mutableListOf<MibAccount>()
|
||||
for (i in 0 until a41Balances.length()) {
|
||||
val a = a41Balances.getJSONObject(i)
|
||||
allAccounts.add(
|
||||
MibAccount(
|
||||
bank = "MIB",
|
||||
profileName = profile.name,
|
||||
profileType = profile.profileType,
|
||||
productCode = profile.cifType,
|
||||
accountNumber = a.optString("accountNumber"),
|
||||
accountBriefName = a.optString("accountBriefName"),
|
||||
currencyName = a.optString("currencyName"),
|
||||
accountTypeName = a.optString("accountTypeName"),
|
||||
availableBalance = a.optString("availableBalance"),
|
||||
currentBalance = a.optString("currentBalance"),
|
||||
blockedAmount = a.optString("blockedAmount"),
|
||||
mvrBalance = a.optString("mvrBalance"),
|
||||
statusDesc = a.optString("statusDesc"),
|
||||
profileImageHash = profile.customerImage,
|
||||
loginTag = "mib_$username",
|
||||
profileId = profile.profileId
|
||||
)
|
||||
)
|
||||
}
|
||||
return allAccounts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val visibleProfiles = if (hidden.isEmpty()) profiles else profiles.filter { it.profileId !in hidden }
|
||||
return fetchAllProfiles(session2, visibleProfiles, "mib_$username")
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
@@ -271,8 +315,10 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
val a = accountBalances.getJSONObject(i)
|
||||
allAccounts.add(
|
||||
MibAccount(
|
||||
bank = "MIB",
|
||||
profileName = profile.name,
|
||||
profileType = profile.profileType,
|
||||
productCode = profile.cifType,
|
||||
accountNumber = a.optString("accountNumber"),
|
||||
accountBriefName = a.optString("accountBriefName"),
|
||||
currencyName = a.optString("currencyName"),
|
||||
@@ -310,44 +356,6 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
}
|
||||
}
|
||||
|
||||
data class MibPersonalProfile(
|
||||
val fullName: String,
|
||||
val username: String,
|
||||
val email: String,
|
||||
val mobile: String,
|
||||
val enrolled: String
|
||||
)
|
||||
|
||||
/** Fetches the customer's profile info from the Faisanet personal profile page. */
|
||||
fun fetchPersonalProfile(session: MibSession): MibPersonalProfile? {
|
||||
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
|
||||
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
|
||||
val request = Request.Builder()
|
||||
.url("https://faisamobilex-wv.mib.com.mv/personalProfile")
|
||||
.get()
|
||||
.header("Cookie", cookieHeader)
|
||||
.build()
|
||||
return try {
|
||||
val resp = client.newCall(request).execute()
|
||||
val html = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
fun scrape(label: String): String {
|
||||
val r = Regex("""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
|
||||
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE))
|
||||
return r.find(html)?.groupValues?.get(1)?.trim() ?: ""
|
||||
}
|
||||
val nameRegex = Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
|
||||
val fullName = nameRegex.find(html)?.groupValues?.get(1)?.trim() ?: return null
|
||||
MibPersonalProfile(
|
||||
fullName = fullName,
|
||||
username = scrape("Username:"),
|
||||
email = scrape("Email:"),
|
||||
mobile = scrape("Mobile no:"),
|
||||
enrolled = scrape("Enrolled:")
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
/** Fetches a profile image via P41. Returns base64 JPEG string, or null if not found. */
|
||||
fun fetchProfileImage(session: MibSession, imageHash: String): String? {
|
||||
val payload = baseData(session, "P41").apply {
|
||||
@@ -358,6 +366,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)
|
||||
@@ -365,6 +401,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")
|
||||
}
|
||||
|
||||
@@ -389,11 +426,11 @@ class MibLoginFlow(private val credentialStore: CredentialStore) {
|
||||
private fun generateOtp(seed: String): String = Totp.generate(seed)
|
||||
|
||||
private fun getOrCreateAppId(): String {
|
||||
var id = credentialStore.loadMibAppId()
|
||||
var id = credentialStore.loadMibAppId(loginId)
|
||||
if (id == null) {
|
||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
id = "IOS17.2-" + (1..15).map { chars[Random.nextInt(chars.length)] }.joinToString("")
|
||||
credentialStore.saveMibAppId(id)
|
||||
credentialStore.saveMibAppId(loginId, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.api.models.BankContactCategory
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
|
||||
// Kept for source compatibility within the mib package
|
||||
typealias MibAccount = BankAccount
|
||||
typealias MibBeneficiary = BankContact
|
||||
typealias Transaction = BankTransaction
|
||||
typealias MibBeneficiaryCategory = BankContactCategory
|
||||
|
||||
data class MibSession(
|
||||
val appId: String,
|
||||
val xxid: String,
|
||||
@@ -19,23 +30,6 @@ data class MibProfile(
|
||||
val customerImage: String?
|
||||
)
|
||||
|
||||
data class MibAccount(
|
||||
val profileName: String,
|
||||
val profileType: String,
|
||||
val accountNumber: String,
|
||||
val accountBriefName: String,
|
||||
val currencyName: String,
|
||||
val accountTypeName: String,
|
||||
val availableBalance: String,
|
||||
val currentBalance: String,
|
||||
val blockedAmount: String,
|
||||
val mvrBalance: String,
|
||||
val statusDesc: String,
|
||||
val profileImageHash: String?,
|
||||
val loginTag: String = "",
|
||||
val profileId: String = "", // MIB profile ID; empty for BML accounts
|
||||
val internalId: String = "" // BML internal UUID; empty for MIB accounts
|
||||
)
|
||||
|
||||
data class MibTransferResult(
|
||||
val success: Boolean,
|
||||
@@ -44,27 +38,6 @@ data class MibTransferResult(
|
||||
val errorMessage: String = ""
|
||||
)
|
||||
|
||||
data class MibBeneficiaryCategory(
|
||||
val id: String,
|
||||
val categoryName: String,
|
||||
val numBenef: Int
|
||||
)
|
||||
|
||||
data class MibBeneficiary(
|
||||
val benefNo: String,
|
||||
val benefName: String,
|
||||
val benefNickName: String,
|
||||
val benefAccount: String,
|
||||
val benefType: String, // L=Local, I=Internal(MIB), S=Swift
|
||||
val bankColor: String,
|
||||
val benefBankName: String,
|
||||
val bankCode: String,
|
||||
val benefStatus: String,
|
||||
val transferCyDesc: String,
|
||||
val customerImgHash: String?,
|
||||
val benefCategoryId: String, // "0" = uncategorized
|
||||
val profileId: String = "" // MIB profile ID; empty for BML contacts
|
||||
)
|
||||
|
||||
data class MibIpsAccountInfo(
|
||||
val accountName: String,
|
||||
@@ -72,18 +45,17 @@ data class MibIpsAccountInfo(
|
||||
val bankId: String
|
||||
)
|
||||
|
||||
data class Transaction(
|
||||
val id: String,
|
||||
val date: String, // "YYYY-MM-DD HH:mm:ss" for MIB, ISO8601 for BML
|
||||
val description: String,
|
||||
val amount: Double, // negative = debit, positive = credit
|
||||
val currency: String,
|
||||
val counterpartyName: String?,
|
||||
val reference: String?,
|
||||
val accountNumber: String,
|
||||
val accountDisplayName: String,
|
||||
val source: String, // "MIB", "BML", "BML_CARD", "FAHIPAY"
|
||||
val iconUrl: String? = null // merchant icon URL (Fahipay only)
|
||||
|
||||
data class MibCard(
|
||||
val cardId: String,
|
||||
val maskedCardNumber: String,
|
||||
val cardStatus: String,
|
||||
val cardType: String,
|
||||
val cardTypeDesc: String,
|
||||
val customerId: String,
|
||||
val phoneNumber: String,
|
||||
val cardHolderName: String,
|
||||
val loginTag: String
|
||||
)
|
||||
|
||||
data class MibFinanceDeal(
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class MibPersonalProfile(
|
||||
val fullName: String,
|
||||
val username: String,
|
||||
val email: String,
|
||||
val mobile: String,
|
||||
val enrolled: String
|
||||
)
|
||||
|
||||
class MibProfileClient {
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
fun fetchPersonalProfile(session: MibSession): MibPersonalProfile? {
|
||||
val cookieHeader = "mbmodel=IOS-1.0; xxid=${session.xxid}; " +
|
||||
"IBSID=${session.xxid}; mbnonce=${session.nonceGenerator}; time-tracker=597"
|
||||
val request = Request.Builder()
|
||||
.url("https://faisamobilex-wv.mib.com.mv/personalProfile")
|
||||
.get()
|
||||
.header("Cookie", cookieHeader)
|
||||
.build()
|
||||
return try {
|
||||
val resp = client.newCall(request).execute()
|
||||
val html = resp.body?.string() ?: return null
|
||||
resp.close()
|
||||
fun scrape(label: String): String {
|
||||
val r = Regex("""<span[^>]*>\s*<b[^>]*>\s*$label\s*</b[^>]*>.*?<span[^>]*>([^<]+)</span>""",
|
||||
setOf(RegexOption.DOT_MATCHES_ALL, RegexOption.IGNORE_CASE))
|
||||
return r.find(html)?.groupValues?.get(1)?.trim() ?: ""
|
||||
}
|
||||
val nameRegex = Regex("""<h5 class="mb-1 text-dark fw-semibold">\s*([^<]+)\s*</h5>""")
|
||||
val fullName = nameRegex.find(html)?.groupValues?.get(1)?.trim() ?: return null
|
||||
MibPersonalProfile(
|
||||
fullName = fullName,
|
||||
username = scrape("Username:"),
|
||||
email = scrape("Email:"),
|
||||
mobile = scrape("Mobile no:"),
|
||||
enrolled = scrape("Enrolled:")
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import android.os.Build
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@@ -26,7 +27,7 @@ class MibTransferClient {
|
||||
.header("Cookie", cookieHeader(session))
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Linux; Android 14; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
"Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
)
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("Accept", "*/*")
|
||||
@@ -68,6 +69,7 @@ class MibTransferClient {
|
||||
.withWvHeaders(session)
|
||||
.build()
|
||||
return client.newCall(request).execute().use { response ->
|
||||
if (response.code == 419) throw SessionExpiredException()
|
||||
val bodyStr = response.body?.string() ?: ""
|
||||
val json = try { JSONObject(bodyStr) } catch (_: Exception) { null }
|
||||
if (json == null || !json.optBoolean("success")) {
|
||||
|
||||
75
app/src/main/java/sh/sar/basedbank/api/models/BankModels.kt
Normal file
@@ -0,0 +1,75 @@
|
||||
package sh.sar.basedbank.api.models
|
||||
|
||||
/** Thrown by a bank API client when the server returns an HTTP 5xx response. */
|
||||
class BankServerException(val bankName: String) : Exception("Server error from $bankName")
|
||||
|
||||
/**
|
||||
* Unified account model used across all banks (MIB, BML, Fahipay, ...).
|
||||
* The [bank] field identifies which bank owns this account.
|
||||
*/
|
||||
data class BankAccount(
|
||||
val bank: String, // "MIB", "BML", "FAHIPAY" — set by the login flow
|
||||
val profileName: String,
|
||||
val profileType: String,
|
||||
val productCode: String = "", // bank-specific product/subtype code: MIB: CIF type label ("Individual", "Sole Propr"); BML: card product code ("C8201", "C1007")
|
||||
val accountNumber: String,
|
||||
val accountBriefName: String,
|
||||
val currencyName: String,
|
||||
val accountTypeName: String,
|
||||
val availableBalance: String,
|
||||
val currentBalance: String,
|
||||
val blockedAmount: String,
|
||||
val mvrBalance: String,
|
||||
val statusDesc: String,
|
||||
val profileImageHash: String?,
|
||||
val loginTag: String = "",
|
||||
val profileId: String = "", // profile ID used by the bank; empty if not applicable
|
||||
val internalId: String = "" // bank-internal UUID or ID; empty if not applicable
|
||||
)
|
||||
|
||||
/**
|
||||
* Unified contact/beneficiary model used across all banks.
|
||||
* Each bank may interpret fields differently; see per-bank notes below.
|
||||
*/
|
||||
data class BankContact(
|
||||
val benefNo: String,
|
||||
val benefName: String,
|
||||
val benefNickName: String,
|
||||
val benefAccount: String,
|
||||
val benefType: String, // MIB: L=Local, I=Internal, S=Swift; BML: "I"; Fahipay: "FAHIPAY"
|
||||
val bankColor: String,
|
||||
val benefBankName: String,
|
||||
val bankCode: String,
|
||||
val benefStatus: String,
|
||||
val transferCyDesc: String,
|
||||
val customerImgHash: String?,
|
||||
val benefCategoryId: String, // MIB: numeric category ID or "0"; BML: "BML"; Fahipay: "FAHIPAY"
|
||||
val profileId: String = "" // owning profile ID; empty where not applicable
|
||||
)
|
||||
|
||||
/**
|
||||
* Contact category (group) used across MIB and Fahipay.
|
||||
*/
|
||||
data class BankContactCategory(
|
||||
val id: String,
|
||||
val categoryName: String,
|
||||
val numBenef: Int
|
||||
)
|
||||
|
||||
/**
|
||||
* Unified transaction model used across all banks.
|
||||
* [source] identifies the originating bank/account type.
|
||||
*/
|
||||
data class BankTransaction(
|
||||
val id: String,
|
||||
val date: String, // "YYYY-MM-DD HH:mm:ss" (MIB/BML normalised) or ISO8601
|
||||
val description: String,
|
||||
val amount: Double, // negative = debit, positive = credit
|
||||
val currency: String,
|
||||
val counterpartyName: String?,
|
||||
val reference: String?,
|
||||
val accountNumber: String,
|
||||
val accountDisplayName: String,
|
||||
val source: String, // "MIB", "BML", "BML_CARD", "FAHIPAY"
|
||||
val iconUrl: String? = null // merchant icon URL (Fahipay only)
|
||||
)
|
||||
@@ -9,8 +9,9 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import sh.sar.basedbank.util.AccountHistoryDisplay
|
||||
import sh.sar.basedbank.databinding.ItemAccountHistoryHeaderBinding
|
||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
|
||||
@@ -20,12 +21,13 @@ import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class AccountHistoryAdapter(
|
||||
private val account: MibAccount
|
||||
private val account: BankAccount,
|
||||
private val display: AccountHistoryDisplay
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private sealed class Item {
|
||||
data class DateHeader(val label: String) : Item()
|
||||
data class Trx(val transaction: Transaction) : Item()
|
||||
data class Trx(val transaction: BankTransaction) : Item()
|
||||
}
|
||||
|
||||
private val displayItems = mutableListOf<Item>()
|
||||
@@ -34,7 +36,18 @@ class AccountHistoryAdapter(
|
||||
private val iconUrlCache = mutableMapOf<String, Bitmap>()
|
||||
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
||||
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
||||
var onTransferClick: ((MibAccount) -> Unit)? = null
|
||||
var onTransferClick: ((BankAccount) -> Unit)? = null
|
||||
private var hideAmounts: Boolean = false
|
||||
|
||||
fun setHideAmounts(hide: Boolean) {
|
||||
if (hideAmounts == hide) return
|
||||
hideAmounts = hide
|
||||
notifyItemChanged(0) // refresh header card
|
||||
// refresh all transaction rows
|
||||
for (i in displayItems.indices) {
|
||||
if (displayItems[i] is Item.Trx) notifyItemChanged(i + 1)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
||||
imageCache[counterpartyName] = bitmap
|
||||
@@ -71,7 +84,7 @@ class AccountHistoryAdapter(
|
||||
* Display the given (already sorted + filtered) list with date group headers.
|
||||
* Silently resets the loading footer so notifyDataSetChanged covers everything.
|
||||
*/
|
||||
fun setTransactions(transactions: List<Transaction>) {
|
||||
fun setTransactions(transactions: List<BankTransaction>) {
|
||||
_showLoadingFooter = false
|
||||
displayItems.clear()
|
||||
lastInsertedDateKey = ""
|
||||
@@ -92,7 +105,7 @@ class AccountHistoryAdapter(
|
||||
* Appends [newTransactions] (assumed to be older than all existing items) using incremental
|
||||
* notifications, so the RecyclerView doesn't reset scroll position.
|
||||
*/
|
||||
fun appendTransactions(newTransactions: List<Transaction>) {
|
||||
fun appendTransactions(newTransactions: List<BankTransaction>) {
|
||||
if (newTransactions.isEmpty()) return
|
||||
if (_showLoadingFooter) {
|
||||
val pos = itemCount - 1
|
||||
@@ -138,7 +151,7 @@ class AccountHistoryAdapter(
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is HeaderVH -> holder.bind(account)
|
||||
is HeaderVH -> holder.bind(display)
|
||||
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label)
|
||||
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction)
|
||||
else -> Unit
|
||||
@@ -147,37 +160,20 @@ class AccountHistoryAdapter(
|
||||
|
||||
inner class HeaderVH(private val b: ItemAccountHistoryHeaderBinding) :
|
||||
RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(acc: MibAccount) {
|
||||
b.tvHeaderAccountName.text = acc.accountBriefName
|
||||
b.tvHeaderAccountNumber.text = acc.accountNumber
|
||||
b.tvHeaderPillBank.text = when {
|
||||
acc.profileType.startsWith("BML") -> "BML"
|
||||
acc.profileType == "FAHIPAY" -> "FP"
|
||||
else -> null
|
||||
}
|
||||
b.tvHeaderPillType.text = friendlyType(acc.accountTypeName)
|
||||
b.tvHeaderAvailable.text = "${acc.currencyName} ${acc.availableBalance}"
|
||||
b.tvHeaderBalance.text = "${acc.currencyName} ${acc.currentBalance}"
|
||||
val blocked = acc.blockedAmount.toDoubleOrNull() ?: 0.0
|
||||
if (blocked > 0.0) {
|
||||
b.tvHeaderBlocked.text = "${acc.currencyName} ${acc.blockedAmount}"
|
||||
fun bind(d: AccountHistoryDisplay) {
|
||||
b.tvHeaderAccountName.text = d.name
|
||||
b.tvHeaderAccountNumber.text = d.number
|
||||
b.tvHeaderPillBank.text = d.bankPill
|
||||
b.tvHeaderPillType.text = d.typeLabel
|
||||
b.tvHeaderAvailable.text = if (hideAmounts) maskAmount(d.availableBalance) else d.availableBalance
|
||||
b.tvHeaderBalance.text = if (hideAmounts) maskAmount(d.workingBalance) else d.workingBalance
|
||||
if (d.blockedBalance != null) {
|
||||
b.tvHeaderBlocked.text = if (hideAmounts) maskAmount(d.blockedBalance) else d.blockedBalance
|
||||
b.llHeaderBlocked.visibility = View.VISIBLE
|
||||
} else {
|
||||
b.llHeaderBlocked.visibility = View.GONE
|
||||
}
|
||||
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(acc) }
|
||||
}
|
||||
|
||||
private fun friendlyType(raw: String): String {
|
||||
val u = raw.trim().uppercase()
|
||||
return when {
|
||||
u.contains("SAVING") -> "Savings"
|
||||
u.contains("CURRENT") -> "Current"
|
||||
u.contains("WADIAH") -> "Islamic"
|
||||
u.contains("VISA") || u.contains("MASTERCARD") || u.contains("AMEX") -> "Card"
|
||||
u.contains("PREPAID") -> "Prepaid"
|
||||
else -> raw.trim().take(12)
|
||||
}
|
||||
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +184,7 @@ class AccountHistoryAdapter(
|
||||
|
||||
inner class TransactionVH(private val b: ItemTransactionBinding) :
|
||||
RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(trx: Transaction) {
|
||||
fun bind(trx: BankTransaction) {
|
||||
val isCredit = trx.amount >= 0
|
||||
val color = sourceColor(trx.source)
|
||||
val name = trx.counterpartyName ?: trx.description
|
||||
@@ -226,17 +222,22 @@ class AccountHistoryAdapter(
|
||||
|
||||
b.tvDate.text = formatTime(trx.date)
|
||||
|
||||
val sign = if (isCredit) "+" else "-"
|
||||
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
||||
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
||||
b.tvAmount.setTextColor(
|
||||
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
||||
)
|
||||
if (hideAmounts) {
|
||||
b.tvAmount.text = "${trx.currency} ••••••"
|
||||
b.tvAmount.setTextColor(Color.parseColor("#888888"))
|
||||
} else {
|
||||
val sign = if (isCredit) "+" else "-"
|
||||
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
||||
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
||||
b.tvAmount.setTextColor(
|
||||
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
||||
)
|
||||
}
|
||||
|
||||
b.root.setOnClickListener { showDetail(trx) }
|
||||
}
|
||||
|
||||
private fun showDetail(trx: Transaction) {
|
||||
private fun showDetail(trx: BankTransaction) {
|
||||
val ctx = b.root.context
|
||||
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
|
||||
val details = buildString {
|
||||
@@ -297,6 +298,11 @@ class AccountHistoryAdapter(
|
||||
return FULL_DATE_FMT.format(date)
|
||||
}
|
||||
|
||||
fun maskAmount(formatted: String): String {
|
||||
val currency = formatted.substringBefore(' ', formatted)
|
||||
return "$currency ••••••"
|
||||
}
|
||||
|
||||
fun sourceColor(source: String) = when (source) {
|
||||
"MIB" -> "#FE860E"
|
||||
"BML", "BML_CARD" -> "#0066A1"
|
||||
|
||||
@@ -10,6 +10,8 @@ import android.util.Base64
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
@@ -17,23 +19,19 @@ import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import sh.sar.basedbank.api.mib.TransactionCache
|
||||
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
|
||||
import sh.sar.basedbank.util.AccountHistoryParser
|
||||
import sh.sar.basedbank.util.ContactImageCache
|
||||
import sh.sar.basedbank.util.HistoryFetcher
|
||||
import sh.sar.basedbank.util.MerchantIconCache
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
|
||||
class AccountHistoryFragment : Fragment() {
|
||||
|
||||
@@ -42,29 +40,21 @@ class AccountHistoryFragment : Fragment() {
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
private lateinit var adapter: AccountHistoryAdapter
|
||||
private lateinit var account: MibAccount
|
||||
private lateinit var account: BankAccount
|
||||
private lateinit var fetcher: HistoryFetcher
|
||||
|
||||
private val allTransactions = mutableListOf<Transaction>()
|
||||
private val allTransactions = mutableListOf<BankTransaction>()
|
||||
private var searchQuery = ""
|
||||
private var firstPageDone = false
|
||||
private val pendingImageNames = mutableSetOf<String>()
|
||||
private val pendingIconUrls = mutableSetOf<String>()
|
||||
|
||||
// Pagination state
|
||||
private var mibNextStart = 1
|
||||
private var mibTotalCount = -1 // -1 = unknown; loaded on first fetch
|
||||
private var bmlNextPage = 1
|
||||
private var bmlTotalPages = -1
|
||||
private var cardMonthOffset = 0 // 0 = current month, 1 = prev, etc.
|
||||
private var fahipayNextStart = 0
|
||||
private var fahipayTotal = -1
|
||||
private var isLoading = false
|
||||
private val pageSize = 10
|
||||
|
||||
companion object {
|
||||
private const val ARG_ACCOUNT_NUMBER = "account_number"
|
||||
|
||||
fun newInstance(account: MibAccount) = AccountHistoryFragment().apply {
|
||||
fun newInstance(account: BankAccount) = AccountHistoryFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putString(ARG_ACCOUNT_NUMBER, account.accountNumber)
|
||||
}
|
||||
@@ -79,15 +69,29 @@ class AccountHistoryFragment : Fragment() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val accountNumber = requireArguments().getString(ARG_ACCOUNT_NUMBER) ?: return
|
||||
account = viewModel.accounts.value?.find { it.accountNumber == accountNumber } ?: return
|
||||
fetcher = HistoryFetcher(account)
|
||||
|
||||
adapter = AccountHistoryAdapter(account)
|
||||
val historyDisplay = AccountHistoryParser.from(account) ?: return
|
||||
adapter = AccountHistoryAdapter(account, historyDisplay)
|
||||
adapter.onImageNeeded = { name -> loadContactImage(name) }
|
||||
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
|
||||
adapter.onTransferClick = { acc ->
|
||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFrom(acc))
|
||||
}
|
||||
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||
insets
|
||||
}
|
||||
|
||||
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
|
||||
if (dy <= 0 || isLoading) return
|
||||
@@ -104,11 +108,10 @@ class AccountHistoryFragment : Fragment() {
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
searchQuery = s?.toString()?.trim() ?: ""
|
||||
filterAndDisplay()
|
||||
if (searchQuery.isNotBlank() && hasMore() && !isLoading) loadNextPage()
|
||||
if (searchQuery.isNotBlank() && fetcher.hasMore() && !isLoading) loadNextPage()
|
||||
}
|
||||
})
|
||||
|
||||
// Load cache immediately, then fetch fresh data in background
|
||||
val cached = TransactionCache.load(requireContext(), account.accountNumber)
|
||||
if (cached.isNotEmpty()) {
|
||||
allTransactions.addAll(cached)
|
||||
@@ -116,6 +119,14 @@ class AccountHistoryFragment : Fragment() {
|
||||
}
|
||||
(activity as? HomeActivity)?.setRefreshing(true)
|
||||
loadNextPage()
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
if (isLoading) {
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
} else {
|
||||
resetAndReload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -133,19 +144,26 @@ class AccountHistoryFragment : Fragment() {
|
||||
binding.emptyView.visibility = if (filtered.isEmpty() && !isLoading) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun isMib() = !account.profileType.startsWith("BML") && account.profileType != "FAHIPAY"
|
||||
private fun isBmlCard() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT"
|
||||
private fun isFahipay() = account.profileType == "FAHIPAY"
|
||||
|
||||
private fun hasMore(): Boolean = when {
|
||||
isFahipay() -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||
isMib() -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||
isBmlCard() -> cardMonthOffset < 3 // load up to 3 months
|
||||
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||
private fun resetAndReload() {
|
||||
allTransactions.clear()
|
||||
pendingImageNames.clear()
|
||||
pendingIconUrls.clear()
|
||||
firstPageDone = false
|
||||
fetcher = HistoryFetcher(account)
|
||||
// Restore cache immediately so data stays visible while refreshing
|
||||
val cached = TransactionCache.load(requireContext(), account.accountNumber)
|
||||
if (cached.isNotEmpty()) {
|
||||
allTransactions.addAll(cached)
|
||||
filterAndDisplay()
|
||||
} else {
|
||||
adapter.setTransactions(emptyList())
|
||||
binding.emptyView.visibility = View.GONE
|
||||
}
|
||||
loadNextPage()
|
||||
}
|
||||
|
||||
private fun loadNextPage() {
|
||||
if (isLoading || !hasMore()) return
|
||||
if (isLoading || !fetcher.hasMore()) return
|
||||
isLoading = true
|
||||
|
||||
if (firstPageDone && allTransactions.isNotEmpty()) {
|
||||
@@ -155,76 +173,34 @@ class AccountHistoryFragment : Fragment() {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
|
||||
lifecycleScope.launch {
|
||||
val transactions: List<Transaction> = withContext(Dispatchers.IO) {
|
||||
when {
|
||||
isFahipay() -> {
|
||||
val session = app.fahipaySession ?: return@withContext emptyList()
|
||||
val flow = FahipayLoginFlow()
|
||||
flow.setSessionCookie(session.sessionCookie)
|
||||
val (list, total) = flow.fetchHistory(
|
||||
session = session,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
accountNumber = account.accountNumber,
|
||||
start = fahipayNextStart
|
||||
)
|
||||
if (total > 0) fahipayTotal = total
|
||||
fahipayNextStart += list.size
|
||||
list
|
||||
}
|
||||
isMib() -> {
|
||||
val session = app.mibSession ?: return@withContext emptyList()
|
||||
app.mibMutex.withLock {
|
||||
val profile = app.mibProfiles.firstOrNull { it.profileId == account.profileId }
|
||||
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
|
||||
val (list, total) = MibHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountNo = account.accountNumber,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
start = mibNextStart,
|
||||
pageSize = pageSize
|
||||
)
|
||||
if (total > 0) mibTotalCount = total
|
||||
mibNextStart += list.size.coerceAtLeast(pageSize)
|
||||
list
|
||||
}
|
||||
}
|
||||
isBmlCard() -> {
|
||||
val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.MONTH, -cardMonthOffset)
|
||||
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
||||
cardMonthOffset++
|
||||
BmlLoginFlow().fetchCardHistory(
|
||||
session = session,
|
||||
cardId = account.internalId,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
accountNumber = account.accountNumber,
|
||||
month = month
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
val session = app.bmlSessionFor(account) ?: return@withContext emptyList()
|
||||
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
|
||||
session = session,
|
||||
accountId = account.internalId,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
accountNumber = account.accountNumber,
|
||||
page = bmlNextPage
|
||||
)
|
||||
if (totalPages > 0) bmlTotalPages = totalPages
|
||||
bmlNextPage++
|
||||
list
|
||||
}
|
||||
}
|
||||
val transactions = try {
|
||||
fetcher.fetchNextPage(app, pageSize)
|
||||
} catch (e: java.io.IOException) {
|
||||
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
|
||||
null
|
||||
} catch (e: BankServerException) {
|
||||
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_server_error, e.bankName))
|
||||
null
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
if (_binding == null) return@launch
|
||||
|
||||
if (!firstPageDone) {
|
||||
firstPageDone = true
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
|
||||
if (transactions == null) {
|
||||
adapter.showLoadingFooter = false
|
||||
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
|
||||
return@launch
|
||||
}
|
||||
(activity as? HomeActivity)?.hideConnectivityBanner()
|
||||
|
||||
if (transactions.isNotEmpty()) {
|
||||
val existingIds = allTransactions.map { it.id }.toHashSet()
|
||||
val newOnes = transactions.filter { it.id !in existingIds }
|
||||
@@ -233,7 +209,6 @@ class AccountHistoryFragment : Fragment() {
|
||||
allTransactions.sortByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
||||
TransactionCache.save(requireContext(), account.accountNumber, allTransactions)
|
||||
if (searchQuery.isBlank()) {
|
||||
// Append incrementally to preserve scroll position
|
||||
val sorted = newOnes.sortedByDescending { AccountHistoryAdapter.parseDateMillis(it.date) }
|
||||
adapter.appendTransactions(sorted)
|
||||
binding.emptyView.visibility = View.GONE
|
||||
@@ -243,7 +218,7 @@ class AccountHistoryFragment : Fragment() {
|
||||
} else {
|
||||
adapter.showLoadingFooter = false
|
||||
}
|
||||
if (searchQuery.isNotBlank() && hasMore()) loadNextPage()
|
||||
if (searchQuery.isNotBlank() && fetcher.hasMore()) loadNextPage()
|
||||
} else {
|
||||
adapter.showLoadingFooter = false
|
||||
if (allTransactions.isEmpty()) binding.emptyView.visibility = View.VISIBLE
|
||||
@@ -261,7 +236,7 @@ class AccountHistoryFragment : Fragment() {
|
||||
return
|
||||
}
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val sess = app.mibSession ?: return
|
||||
val sess = app.anyMibSession() ?: return
|
||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
|
||||
@@ -288,8 +263,7 @@ class AccountHistoryFragment : Fragment() {
|
||||
val response = client.newCall(Request.Builder().url(url).build()).execute()
|
||||
val bytes = response.body?.bytes() ?: return@launch
|
||||
response.close()
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
?: return@launch
|
||||
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return@launch
|
||||
MerchantIconCache.save(requireContext(), url, bitmap)
|
||||
withContext(Dispatchers.Main) { adapter.updateIconUrl(url, bitmap) }
|
||||
} catch (_: Exception) {
|
||||
|
||||
@@ -3,70 +3,85 @@ package sh.sar.basedbank.ui.home
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.graphics.Bitmap
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.databinding.ItemAccountBinding
|
||||
import sh.sar.basedbank.databinding.ItemCardBinding
|
||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||
import sh.sar.basedbank.util.AccountListDisplay
|
||||
import sh.sar.basedbank.util.AccountListParser
|
||||
|
||||
class AccountsAdapter(
|
||||
accounts: List<MibAccount>,
|
||||
private val onAccountClick: (MibAccount) -> Unit = {}
|
||||
accounts: List<BankAccount>,
|
||||
private val onAccountClick: (BankAccount) -> Unit = {},
|
||||
/** Optional loader for MIB per-profile images: (hash, onLoaded) */
|
||||
private val profileImageLoader: ((String, (Bitmap) -> Unit) -> Unit)? = null,
|
||||
/** Optional loader for local (BML/Fahipay) profile images: (loginTag, profileId, onLoaded) */
|
||||
private val localProfileImageLoader: ((String, String, (Bitmap) -> Unit) -> Unit)? = null
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
var onTransferClick: ((BankAccount) -> Unit)? = null
|
||||
private var hideAmounts: Boolean = false
|
||||
|
||||
private sealed class Item {
|
||||
data class SectionTitle(val label: String) : Item()
|
||||
data class Account(val account: MibAccount) : Item()
|
||||
data class Card(val account: MibAccount) : Item()
|
||||
data class Account(val account: BankAccount, val display: AccountListDisplay) : Item()
|
||||
data class Card(val account: BankAccount, val display: AccountListDisplay) : Item()
|
||||
}
|
||||
|
||||
private val items: MutableList<Item> = buildItems(accounts).toMutableList()
|
||||
|
||||
fun updateAccounts(accounts: List<MibAccount>) {
|
||||
fun updateAccounts(accounts: List<BankAccount>) {
|
||||
items.clear()
|
||||
items.addAll(buildItems(accounts))
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private fun buildItems(accounts: List<MibAccount>): List<Item> = buildList {
|
||||
val nonPrepaid = accounts.filter { it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" }
|
||||
val prepaid = accounts.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" }
|
||||
fun setHideAmounts(hide: Boolean) {
|
||||
if (hideAmounts == hide) return
|
||||
hideAmounts = hide
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
// Group non-prepaid accounts by their derived section title, preserving order
|
||||
val groups = LinkedHashMap<String, MutableList<MibAccount>>()
|
||||
for (acc in nonPrepaid) {
|
||||
private fun buildItems(accounts: List<BankAccount>): List<Item> = buildList {
|
||||
val displayed = accounts.mapNotNull { acc -> AccountListParser.from(acc)?.let { acc to it } }
|
||||
val nonCards = displayed.filter { !it.second.isCard }
|
||||
val cards = displayed.filter { it.second.isCard }
|
||||
|
||||
val groups = LinkedHashMap<String, MutableList<Pair<BankAccount, AccountListDisplay>>>()
|
||||
for ((acc, display) in nonCards) {
|
||||
val title = sectionTitle(acc)
|
||||
groups.getOrPut(title) { mutableListOf() }.add(acc)
|
||||
groups.getOrPut(title) { mutableListOf() }.add(acc to display)
|
||||
}
|
||||
for ((title, group) in groups) {
|
||||
add(Item.SectionTitle(title))
|
||||
group.forEach { add(Item.Account(it)) }
|
||||
group.forEach { (acc, display) -> add(Item.Account(acc, display)) }
|
||||
}
|
||||
|
||||
if (prepaid.isNotEmpty()) {
|
||||
if (cards.isNotEmpty()) {
|
||||
add(Item.SectionTitle("Cards · Bank of Maldives"))
|
||||
prepaid.forEach { add(Item.Card(it)) }
|
||||
cards.forEach { (acc, display) -> add(Item.Card(acc, display)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun sectionTitle(account: MibAccount): String {
|
||||
val profileLabel = when (account.profileType) {
|
||||
"0" -> "Personal"
|
||||
"1" -> "Business"
|
||||
else -> account.profileName
|
||||
private fun sectionTitle(account: BankAccount): String {
|
||||
val bankName = when (account.bank) {
|
||||
"BML" -> "Bank of Maldives"
|
||||
"FAHIPAY" -> "Fahipay"
|
||||
"MIB" -> "Maldives Islamic Bank"
|
||||
else -> account.bank
|
||||
}
|
||||
val bank = when {
|
||||
account.profileType.startsWith("BML") -> "Bank of Maldives"
|
||||
account.profileType == "FAHIPAY" -> "Fahipay"
|
||||
else -> "Maldives Islamic Bank"
|
||||
val profileLabel = when (account.bank) {
|
||||
"MIB" -> account.productCode.ifBlank { account.profileName }
|
||||
else -> account.profileName
|
||||
}
|
||||
return if (profileLabel.isNotBlank()) "$profileLabel · $bank" else bank
|
||||
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int) = when (items[position]) {
|
||||
@@ -78,17 +93,17 @@ class AccountsAdapter(
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
return when (viewType) {
|
||||
TYPE_HEADER -> SectionViewHolder(ItemDateHeaderBinding.inflate(inflater, parent, false))
|
||||
TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false))
|
||||
else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
|
||||
TYPE_HEADER -> SectionViewHolder(ItemDateHeaderBinding.inflate(inflater, parent, false))
|
||||
TYPE_CARD -> CardViewHolder(ItemCardBinding.inflate(inflater, parent, false))
|
||||
else -> AccountViewHolder(ItemAccountBinding.inflate(inflater, parent, false))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (val item = items[position]) {
|
||||
is Item.SectionTitle -> (holder as SectionViewHolder).bind(item)
|
||||
is Item.Account -> (holder as AccountViewHolder).bind(item.account)
|
||||
is Item.Card -> (holder as CardViewHolder).bind(item.account)
|
||||
is Item.Account -> (holder as AccountViewHolder).bind(item.account, item.display)
|
||||
is Item.Card -> (holder as CardViewHolder).bind(item.account, item.display)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,43 +118,64 @@ class AccountsAdapter(
|
||||
|
||||
private inner class AccountViewHolder(private val binding: ItemAccountBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(account: MibAccount) {
|
||||
binding.tvAccountName.text = account.accountBriefName
|
||||
binding.tvAccountNumber.text = account.accountNumber
|
||||
binding.tvPillType.text = friendlyAccountType(account.accountTypeName)
|
||||
binding.tvBalance.text = "${account.currencyName} ${account.availableBalance}"
|
||||
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
|
||||
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||
binding.root.setOnClickListener { onAccountClick(account) }
|
||||
binding.root.setOnLongClickListener {
|
||||
copyToClipboard(it.context, account.accountNumber)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class CardViewHolder(private val binding: ItemCardBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
fun bind(account: MibAccount) {
|
||||
val brand = cardBrand(account.accountTypeName)
|
||||
binding.tvCardBrand.text = brand.label
|
||||
binding.tvCardBrand.background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = 100f
|
||||
setColor(Color.parseColor(brand.color))
|
||||
}
|
||||
binding.tvCardName.text = account.accountBriefName
|
||||
binding.tvCardNumber.text = account.accountNumber
|
||||
fun bind(account: BankAccount, display: AccountListDisplay) {
|
||||
binding.ivCardBrand.setImageResource(display.cardBrandIcon)
|
||||
binding.tvCardName.text = display.name
|
||||
binding.tvCardNumber.text = display.number
|
||||
binding.tvCardProduct.text = display.typeLabel
|
||||
binding.layoutCardBalance.visibility = View.VISIBLE
|
||||
binding.tvCardBalance.text = "${account.currencyName} ${account.availableBalance}"
|
||||
|
||||
val isActive = account.statusDesc.equals("Active", ignoreCase = true)
|
||||
if (isActive) {
|
||||
binding.tvCardStatus.visibility = View.GONE
|
||||
binding.root.alpha = 1f
|
||||
} else {
|
||||
binding.tvCardStatus.text = account.statusDesc
|
||||
binding.tvCardBalance.text = if (hideAmounts) maskAmount(display.balance) else display.balance
|
||||
if (display.statusLabel != null) {
|
||||
binding.tvCardStatus.text = display.statusLabel
|
||||
binding.tvCardStatus.visibility = View.VISIBLE
|
||||
binding.root.alpha = 0.45f
|
||||
} else {
|
||||
binding.tvCardStatus.visibility = View.GONE
|
||||
binding.root.alpha = 1f
|
||||
}
|
||||
binding.btnTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||
binding.root.setOnClickListener { onAccountClick(account) }
|
||||
}
|
||||
}
|
||||
@@ -149,35 +185,15 @@ class AccountsAdapter(
|
||||
private const val TYPE_ACCOUNT = 1
|
||||
private const val TYPE_CARD = 2
|
||||
|
||||
fun maskAmount(formatted: String): String {
|
||||
val currency = formatted.substringBefore(' ', formatted)
|
||||
return "$currency ••••••"
|
||||
}
|
||||
|
||||
private fun copyToClipboard(context: Context, accountNumber: String) {
|
||||
val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
cm.setPrimaryClip(ClipData.newPlainText("Account Number", accountNumber))
|
||||
Toast.makeText(context, "Account number copied", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
|
||||
private fun friendlyAccountType(raw: String): String {
|
||||
val u = raw.trim().uppercase()
|
||||
return when {
|
||||
u == "SAVINGS ACCOUNT" ||
|
||||
u == "SAVING ACCOUNT" -> "Savings"
|
||||
u == "CURRENT ACCOUNT" ||
|
||||
u == "CURRENT ACCOUNT(PERSONAL)" ||
|
||||
u == "CURRENT ACCOUNT(BUSINESS)" -> "Current"
|
||||
u == "WADIAH RETAIL CURRENT ACCOUNT" ||
|
||||
u == "WADIAH BUSINESS CURRENT ACCOUNT" -> "Islamic Current"
|
||||
u == "BML ISLAMIC SAVINGS ACCOUNT" -> "Islamic Savings"
|
||||
else -> raw.trim()
|
||||
}
|
||||
}
|
||||
|
||||
private data class Brand(val label: String, val color: String)
|
||||
|
||||
private fun cardBrand(productName: String): Brand = when {
|
||||
productName.contains("AMEX", ignoreCase = true) ||
|
||||
productName.contains("AMERICAN EXPRESS", ignoreCase = true) -> Brand("AMEX", "#016FD0")
|
||||
productName.contains("VISA", ignoreCase = true) -> Brand("VISA", "#1A1F71")
|
||||
productName.contains("MASTERCARD", ignoreCase = true) -> Brand("MC", "#FF5F00")
|
||||
else -> Brand("CARD", "#555555")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
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
|
||||
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() {
|
||||
|
||||
@@ -16,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)
|
||||
@@ -23,13 +35,74 @@ 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))
|
||||
}
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { adapter.updateAccounts(it) }
|
||||
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||
insets
|
||||
}
|
||||
|
||||
viewModel.accounts.observe(viewLifecycleOwner) {
|
||||
adapter.updateAccounts(it)
|
||||
binding.emptyView.visibility = if (it.isEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
||||
108
app/src/main/java/sh/sar/basedbank/ui/home/ActivitiesAdapter.kt
Normal file
@@ -0,0 +1,108 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||
import sh.sar.basedbank.databinding.ItemTransactionBinding
|
||||
import sh.sar.basedbank.util.ReceiptStore
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
class ActivitiesAdapter(
|
||||
private val onItemClick: (ReceiptStore.Entry) -> Unit
|
||||
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private sealed class Item {
|
||||
data class DateHeader(val label: String) : Item()
|
||||
data class ReceiptItem(val entry: ReceiptStore.Entry) : Item()
|
||||
}
|
||||
|
||||
private val displayItems = mutableListOf<Item>()
|
||||
|
||||
fun setEntries(entries: List<ReceiptStore.Entry>) {
|
||||
displayItems.clear()
|
||||
var lastDateKey = ""
|
||||
for (entry in entries) {
|
||||
val dateKey = SimpleDateFormat("yyyy-MM-dd", Locale.US).format(Date(entry.savedAt))
|
||||
if (dateKey != lastDateKey) {
|
||||
displayItems.add(Item.DateHeader(formatDateHeader(entry.savedAt)))
|
||||
lastDateKey = dateKey
|
||||
}
|
||||
displayItems.add(Item.ReceiptItem(entry))
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getItemCount() = displayItems.size
|
||||
|
||||
override fun getItemViewType(position: Int) =
|
||||
if (displayItems[position] is Item.DateHeader) TYPE_DATE_HEADER else TYPE_RECEIPT
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
return if (viewType == TYPE_DATE_HEADER)
|
||||
DateHeaderVH(ItemDateHeaderBinding.inflate(inflater, parent, false))
|
||||
else
|
||||
ReceiptVH(ItemTransactionBinding.inflate(inflater, parent, false))
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is DateHeaderVH -> holder.bind((displayItems[position] as Item.DateHeader).label)
|
||||
is ReceiptVH -> holder.bind((displayItems[position] as Item.ReceiptItem).entry)
|
||||
}
|
||||
}
|
||||
|
||||
inner class DateHeaderVH(private val b: ItemDateHeaderBinding) :
|
||||
RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(label: String) { b.tvDateHeader.text = label }
|
||||
}
|
||||
|
||||
inner class ReceiptVH(private val b: ItemTransactionBinding) :
|
||||
RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(entry: ReceiptStore.Entry) {
|
||||
val d = entry.data
|
||||
val colorHex = d.fromColorHex.takeIf { it.isNotBlank() } ?: "#607D8B"
|
||||
val initial = d.toLabel.firstOrNull()?.uppercaseChar()?.toString() ?: "?"
|
||||
|
||||
b.fvAvatar.background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.OVAL
|
||||
setColor(try { Color.parseColor(colorHex) } catch (_: Exception) { Color.GRAY })
|
||||
}
|
||||
b.tvInitial.visibility = android.view.View.VISIBLE
|
||||
b.tvInitial.text = initial
|
||||
|
||||
b.tvCounterparty.text = d.toLabel
|
||||
b.tvCounterparty.visibility = android.view.View.VISIBLE
|
||||
b.tvDescription.text = buildString {
|
||||
append(d.fromLabel)
|
||||
if (d.toBank.isNotBlank()) append(" · ${d.toBank}")
|
||||
}
|
||||
b.tvDate.text = formatTime(entry.savedAt)
|
||||
|
||||
b.tvAmount.text = "- ${d.currency} ${d.amount}"
|
||||
b.tvAmount.setTextColor(Color.parseColor("#FF7043"))
|
||||
|
||||
b.root.setOnClickListener { onItemClick(entry) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDateHeader(millis: Long): String {
|
||||
val sdf = SimpleDateFormat("EEEE, d MMMM yyyy", Locale.US)
|
||||
return sdf.format(Date(millis))
|
||||
}
|
||||
|
||||
private fun formatTime(millis: Long): String {
|
||||
val sdf = SimpleDateFormat("HH:mm", Locale.US)
|
||||
return sdf.format(Date(millis))
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TYPE_DATE_HEADER = 0
|
||||
private const val TYPE_RECEIPT = 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentActivitiesBinding
|
||||
import sh.sar.basedbank.util.ReceiptStore
|
||||
|
||||
class ActivitiesFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentActivitiesBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private lateinit var adapter: ActivitiesAdapter
|
||||
private val allEntries = mutableListOf<ReceiptStore.Entry>()
|
||||
private var searchQuery = ""
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentActivitiesBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
adapter = ActivitiesAdapter { entry ->
|
||||
(activity as? HomeActivity)?.showWithBackStack(
|
||||
TransferReceiptFragment.newInstance(entry.data, null)
|
||||
)
|
||||
}
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||
val isBottomNav = requireContext()
|
||||
.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
.getBoolean("bottom_nav", false)
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||
insets
|
||||
}
|
||||
|
||||
binding.etSearch.addTextChangedListener(object : TextWatcher {
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
searchQuery = s?.toString()?.trim() ?: ""
|
||||
filterAndDisplay()
|
||||
}
|
||||
})
|
||||
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = getString(R.string.nav_activities)
|
||||
// Reload in case a new receipt was added while we were away
|
||||
loadEntries()
|
||||
}
|
||||
|
||||
private fun loadEntries() {
|
||||
allEntries.clear()
|
||||
allEntries.addAll(ReceiptStore.loadAll(requireContext()))
|
||||
filterAndDisplay()
|
||||
}
|
||||
|
||||
private fun filterAndDisplay() {
|
||||
val filtered = if (searchQuery.isBlank()) allEntries
|
||||
else allEntries.filter { entry ->
|
||||
entry.data.toLabel.contains(searchQuery, ignoreCase = true) ||
|
||||
entry.data.fromLabel.contains(searchQuery, ignoreCase = true) ||
|
||||
entry.data.toAccount.contains(searchQuery, ignoreCase = true) ||
|
||||
entry.data.toBank.contains(searchQuery, ignoreCase = true) ||
|
||||
entry.data.mibReferenceNo.contains(searchQuery, ignoreCase = true) ||
|
||||
entry.data.bmlReference.contains(searchQuery, ignoreCase = true)
|
||||
}
|
||||
adapter.setEntries(filtered)
|
||||
binding.emptyView.visibility = if (filtered.isEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -12,20 +12,27 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Filter
|
||||
import android.widget.Filterable
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.swiperefreshlayout.widget.CircularProgressDrawable
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import sh.sar.basedbank.util.ContactsCache
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
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.BmlAccountValidation
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
|
||||
import sh.sar.basedbank.api.bml.BmlContactsClient
|
||||
import sh.sar.basedbank.api.bml.BmlValidateClient
|
||||
import sh.sar.basedbank.api.models.BankContactCategory
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.MibProfile
|
||||
import sh.sar.basedbank.api.mib.MibTransferClient
|
||||
@@ -43,7 +50,10 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
private data class DestinationOption(
|
||||
val label: String,
|
||||
val isBml: Boolean,
|
||||
val mibProfile: MibProfile? = null
|
||||
val mibProfile: MibProfile? = null,
|
||||
val mibLoginId: String? = null,
|
||||
val bmlLoginId: String? = null,
|
||||
val subtitle: String = ""
|
||||
)
|
||||
|
||||
private var destinations: List<DestinationOption> = emptyList()
|
||||
@@ -55,7 +65,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
private var selectedImageBase64: String = ""
|
||||
private var selectedCategoryId: String = "0"
|
||||
private var categories: List<MibBeneficiaryCategory> = emptyList()
|
||||
private var categories: List<BankContactCategory> = emptyList()
|
||||
|
||||
private val imagePicker = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
|
||||
uri ?: return@registerForActivityResult
|
||||
@@ -83,21 +93,52 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
private fun buildDestinations(): List<DestinationOption> {
|
||||
val list = mutableListOf<DestinationOption>()
|
||||
for (profile in app.mibProfiles) {
|
||||
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile))
|
||||
for ((loginId, profiles) in app.mibProfilesMap) {
|
||||
for (profile in profiles) {
|
||||
list.add(DestinationOption("MIB · ${profile.name}", isBml = false, mibProfile = profile, mibLoginId = loginId, subtitle = profile.cifType))
|
||||
}
|
||||
}
|
||||
if (app.anyBmlSession() != null) {
|
||||
list.add(DestinationOption("BML · Personal", isBml = true))
|
||||
val store = CredentialStore(requireContext())
|
||||
for ((loginId, profiles) in app.bmlProfilesMap) {
|
||||
val fullName = store.loadBmlUserProfile(loginId)?.fullName?.takeIf { it.isNotBlank() }
|
||||
for (profile in profiles) {
|
||||
if (app.bmlSessions.containsKey(profile.profileId)) {
|
||||
list.add(DestinationOption("BML · ${fullName ?: profile.name}", isBml = true, bmlLoginId = profile.profileId, subtitle = profile.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
private fun setupDestinationDropdown() {
|
||||
val labels = destinations.map { it.label }
|
||||
val adapter = ArrayAdapter(requireContext(), android.R.layout.simple_dropdown_item_1line, labels)
|
||||
val adapter = object : ArrayAdapter<DestinationOption>(requireContext(), android.R.layout.simple_list_item_2, destinations) {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup) =
|
||||
getDropDownView(position, convertView, parent)
|
||||
|
||||
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val view = convertView ?: LayoutInflater.from(context).inflate(android.R.layout.simple_list_item_2, parent, false)
|
||||
val opt = destinations[position]
|
||||
view.findViewById<TextView>(android.R.id.text1).text = opt.label
|
||||
val text2 = view.findViewById<TextView>(android.R.id.text2)
|
||||
if (opt.subtitle.isNotBlank()) {
|
||||
text2.text = opt.subtitle
|
||||
text2.visibility = View.VISIBLE
|
||||
} else {
|
||||
text2.visibility = View.GONE
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
override fun getFilter() = object : Filter() {
|
||||
override fun performFiltering(c: CharSequence?) = FilterResults().apply { values = destinations; count = destinations.size }
|
||||
override fun publishResults(c: CharSequence?, r: FilterResults?) = notifyDataSetChanged()
|
||||
override fun convertResultToString(r: Any?) = ""
|
||||
}
|
||||
}
|
||||
binding.actvDestination.setAdapter(adapter)
|
||||
binding.actvDestination.setOnItemClickListener { _, _, position, _ ->
|
||||
selectedDest = destinations[position]
|
||||
binding.actvDestination.setText(destinations[position].label, false)
|
||||
clearLookupResult()
|
||||
updateMibOnlyVisibility()
|
||||
binding.btnSave.isEnabled = false
|
||||
@@ -129,6 +170,22 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun startLookupLoading() {
|
||||
val spinner = CircularProgressDrawable(requireContext()).apply {
|
||||
setStyle(CircularProgressDrawable.DEFAULT)
|
||||
setColorSchemeColors(com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorPrimary, Color.GRAY))
|
||||
start()
|
||||
}
|
||||
binding.tilAccount.endIconDrawable = spinner
|
||||
binding.tilAccount.isEnabled = false
|
||||
}
|
||||
|
||||
private fun stopLookupLoading() {
|
||||
binding.tilAccount.isEnabled = true
|
||||
binding.tilAccount.endIconDrawable = ContextCompat.getDrawable(requireContext(), android.R.drawable.ic_menu_search)
|
||||
}
|
||||
|
||||
private fun setupAccountSearch() {
|
||||
binding.tilAccount.setEndIconOnClickListener { performLookup() }
|
||||
}
|
||||
@@ -167,7 +224,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
return
|
||||
}
|
||||
|
||||
binding.tilAccount.isEnabled = false
|
||||
startLookupLoading()
|
||||
binding.tilDestination.isEnabled = false
|
||||
binding.btnSave.isEnabled = false
|
||||
|
||||
@@ -175,7 +232,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
if (dest.isBml) lookupForBml(input) else lookupForMib(dest, input)
|
||||
}
|
||||
binding.tilAccount.isEnabled = true
|
||||
stopLookupLoading()
|
||||
binding.tilDestination.isEnabled = true
|
||||
if (result != null) {
|
||||
showLookupResult(result, input)
|
||||
@@ -186,19 +243,18 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
}
|
||||
|
||||
private fun lookupForBml(input: String): BmlAccountValidation? {
|
||||
val bmlSess = app.anyBmlSession() ?: return null
|
||||
val bmlFlow = BmlLoginFlow()
|
||||
|
||||
val loginId = selectedDest?.bmlLoginId ?: return null
|
||||
val bmlSess = app.bmlSessions[loginId] ?: return null
|
||||
// 1) Try BML validate
|
||||
val validated = try { bmlFlow.validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||
val validated = try { BmlValidateClient().validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||
if (validated != null) return validated
|
||||
|
||||
// 2) Try BML MIB verify
|
||||
val mibVerified = try { bmlFlow.verifyMibAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||
val mibVerified = try { BmlValidateClient().verifyMibAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||
if (mibVerified != null) return mibVerified
|
||||
|
||||
// 3) Fall back to MIB IPS lookup (for USD MIB accounts not reachable via BML)
|
||||
val mibSess = app.mibSession ?: return null
|
||||
val mibSess = app.anyMibSession() ?: return null
|
||||
return try {
|
||||
val info = MibTransferClient().lookup(mibSess, input)
|
||||
BmlAccountValidation(
|
||||
@@ -215,11 +271,12 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
}
|
||||
|
||||
private fun lookupForMib(dest: DestinationOption, input: String): BmlAccountValidation? {
|
||||
val mibSess = app.mibSession ?: return null
|
||||
val loginId = dest.mibLoginId ?: return null
|
||||
val mibSess = app.mibSessions[loginId] ?: return null
|
||||
val profile = dest.mibProfile ?: return null
|
||||
|
||||
val mibResult = try {
|
||||
app.mibLoginFlow.switchProfile(mibSess, profile)
|
||||
app.mibFlowFor(loginId).switchProfile(mibSess, profile)
|
||||
val info = MibTransferClient().lookup(mibSess, input)
|
||||
BmlAccountValidation(
|
||||
trnType = if (info.bankId == "MADVMVMV") "MIB_INTERNAL" else "MIB_LOCAL",
|
||||
@@ -237,7 +294,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
// MIB lookup failed (e.g. BML USD account) — fall back to BML validate
|
||||
val bmlSess = app.anyBmlSession() ?: return null
|
||||
return try { BmlLoginFlow().validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||
return try { BmlValidateClient().validateAccount(bmlSess, input) } catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
private fun showLookupResult(validation: BmlAccountValidation, input: String) {
|
||||
@@ -341,6 +398,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
binding.tilAlias.error = null
|
||||
|
||||
binding.btnSave.isEnabled = false
|
||||
binding.btnSave.text = "Saving..."
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
@@ -352,31 +410,33 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
dismiss()
|
||||
} else {
|
||||
binding.btnSave.isEnabled = true
|
||||
binding.btnSave.text = "Save"
|
||||
Toast.makeText(requireContext(), R.string.contact_save_failed, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToBml(alias: String): Boolean {
|
||||
val bmlSess = app.anyBmlSession() ?: return false
|
||||
val loginId = selectedDest?.bmlLoginId ?: return false
|
||||
val bmlSess = app.bmlSessions[loginId] ?: return false
|
||||
val lookup = bmlLookup ?: return false
|
||||
val bmlFlow = BmlLoginFlow()
|
||||
val account = lookup.account
|
||||
return when {
|
||||
account.matches(Regex("^7\\d{12}$")) ->
|
||||
// BML account → IAT
|
||||
bmlFlow.saveContact(bmlSess, "IAT", account, alias)
|
||||
BmlContactsClient().saveContact(bmlSess, "IAT", account, alias)
|
||||
account.matches(Regex("^9\\d{16}$")) ->
|
||||
// MIB internal → DOT; swift is BML's internal UUID for MIB bank
|
||||
bmlFlow.saveContact(bmlSess, "DOT", account, alias,
|
||||
BmlContactsClient().saveContact(bmlSess, "DOT", account, alias,
|
||||
currency = lookup.currency, name = lookup.name, swift = MIB_SWIFT_ON_BML)
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveToMib(alias: String): Boolean {
|
||||
val mibSess = app.mibSession ?: return false
|
||||
val dest = selectedDest ?: return false
|
||||
val loginId = dest.mibLoginId ?: return false
|
||||
val mibSess = app.mibSessions[loginId] ?: return false
|
||||
val profile = dest.mibProfile ?: return false
|
||||
val account = mibLookupAccount ?: return false
|
||||
val currency = binding.etCurrency.text?.toString()?.trim() ?: "MVR"
|
||||
@@ -388,7 +448,7 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
val name = bmlLookup?.name ?: ""
|
||||
|
||||
return try {
|
||||
app.mibLoginFlow.switchProfile(mibSess, profile)
|
||||
app.mibFlowFor(loginId).switchProfile(mibSess, profile)
|
||||
MibContactsClient().createContact(
|
||||
session = mibSess,
|
||||
benefType = benefType,
|
||||
@@ -425,17 +485,18 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
requireActivity().lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
if (dest.isBml) {
|
||||
val bmlSess = app.anyBmlSession() ?: return@launch
|
||||
val loginId = app.bmlSessions.entries.firstOrNull { it.value == bmlSess }?.key ?: ""
|
||||
val fresh = BmlLoginFlow().fetchContacts(bmlSess, loginId)
|
||||
val loginId = dest.bmlLoginId ?: return@launch
|
||||
val bmlSess = app.bmlSessions[loginId] ?: return@launch
|
||||
val fresh = BmlContactsClient().fetchContacts(bmlSess, loginId)
|
||||
val existing = viewModel.contacts.value ?: emptyList()
|
||||
val merged = existing.filter { it.benefCategoryId != "BML" } + fresh
|
||||
viewModel.contacts.postValue(merged)
|
||||
if (loginId.isNotBlank()) ContactsCache.saveBml(requireContext(), loginId, fresh)
|
||||
} else {
|
||||
val profile = dest.mibProfile ?: return@launch
|
||||
val mibSess = app.mibSession ?: return@launch
|
||||
app.mibLoginFlow.switchProfile(mibSess, profile)
|
||||
val mibLoginId = dest.mibLoginId ?: return@launch
|
||||
val mibSess = app.mibSessions[mibLoginId] ?: return@launch
|
||||
app.mibFlowFor(mibLoginId).switchProfile(mibSess, profile)
|
||||
val fresh = MibContactsClient().fetchContacts(mibSess)
|
||||
.map { it.copy(profileId = profile.profileId) }
|
||||
val existing = viewModel.contacts.value ?: emptyList()
|
||||
|
||||
388
app/src/main/java/sh/sar/basedbank/ui/home/BmlQrPayFragment.kt
Normal file
@@ -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,139 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentCardSettingsBinding
|
||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||
|
||||
class CardSettingsFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentCardSettingsBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentCardSettingsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val adapter = CardSettingsAdapter(emptyList(), requireContext())
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||
insets
|
||||
}
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
}
|
||||
|
||||
val updateCardList = {
|
||||
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
|
||||
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
||||
.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
|
||||
.map { CardItem.Bml(it) }
|
||||
val all = mibItems + bmlItems
|
||||
adapter.update(all)
|
||||
binding.loadingView.visibility = View.GONE
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
binding.emptyView.visibility = if (all.isEmpty()) View.VISIBLE else View.GONE
|
||||
binding.recyclerView.visibility = if (all.isEmpty()) View.GONE else View.VISIBLE
|
||||
}
|
||||
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
|
||||
|
||||
if (viewModel.mibCards.value == null) {
|
||||
binding.loadingView.visibility = View.VISIBLE
|
||||
(activity as? HomeActivity)?.triggerRefreshCards()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = getString(R.string.nav_card_settings)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private inner class CardSettingsAdapter(
|
||||
private var cards: List<CardItem>,
|
||||
private val context: Context
|
||||
) : RecyclerView.Adapter<CardSettingsAdapter.VH>() {
|
||||
|
||||
fun update(newCards: List<CardItem>) {
|
||||
cards = newCards
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
|
||||
VH(LayoutInflater.from(context).inflate(R.layout.item_card_settings_entry, parent, false))
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position])
|
||||
override fun getItemCount() = cards.size
|
||||
|
||||
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
|
||||
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
|
||||
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
|
||||
private val tvCardType: TextView = view.findViewById(R.id.tvCardType)
|
||||
private val tvCardStatus: TextView = view.findViewById(R.id.tvCardStatus)
|
||||
private val btnChangePin: View = view.findViewById(R.id.btnChangePin)
|
||||
private val btnFreeze: View = view.findViewById(R.id.btnFreeze)
|
||||
private val btnBlock: View = view.findViewById(R.id.btnBlock)
|
||||
|
||||
fun bind(item: CardItem) {
|
||||
when (item) {
|
||||
is CardItem.Mib -> {
|
||||
tvCardOwner.text = item.card.cardHolderName
|
||||
tvCardNumber.text = PayWithCardFragment.formatMasked(item.card.maskedCardNumber)
|
||||
tvCardType.text = item.card.cardTypeDesc
|
||||
val assetPath = PayWithCardFragment.cardImageAsset(item.card)
|
||||
if (assetPath != null) PayWithCardFragment.loadCardImage(ivCardImage, assetPath)
|
||||
else ivCardImage.setImageDrawable(null)
|
||||
PayWithCardFragment.bindCardStatus(tvCardStatus, PayWithCardFragment.mibCardStatusLabel(item.card.cardStatus))
|
||||
itemView.alpha = 1f
|
||||
}
|
||||
is CardItem.Bml -> {
|
||||
tvCardOwner.text = item.account.accountBriefName
|
||||
tvCardNumber.text = PayWithCardFragment.formatMasked(item.account.accountNumber)
|
||||
tvCardType.text = item.account.accountTypeName
|
||||
PayWithCardFragment.loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
|
||||
val isActive = item.account.statusDesc.equals("Active", ignoreCase = true)
|
||||
val bmlStatus = item.account.statusDesc.takeUnless { isActive }
|
||||
PayWithCardFragment.bindCardStatus(tvCardStatus, bmlStatus)
|
||||
itemView.alpha = if (isActive) 1f else 0.45f
|
||||
}
|
||||
}
|
||||
val wip = View.OnClickListener {
|
||||
Toast.makeText(context, R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
btnChangePin.setOnClickListener(wip)
|
||||
btnFreeze.setOnClickListener(wip)
|
||||
btnBlock.setOnClickListener(wip)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -36,7 +40,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
private val sharedImageCache = mutableMapOf<String, Bitmap>()
|
||||
private val profileImageHashes = mutableSetOf<String>()
|
||||
private val app get() = requireActivity().application as BasedBankApp
|
||||
private val session get() = app.mibSession
|
||||
private val session get() = app.anyMibSession()
|
||||
|
||||
private var fromAccountNumber: String = ""
|
||||
private var mediator: TabLayoutMediator? = null
|
||||
@@ -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,11 +315,22 @@ 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 {
|
||||
val base64 = if (hash in profileImageHashes) {
|
||||
app.mibLoginFlow.fetchProfileImage(sess, hash)
|
||||
app.anyMibFlow()?.fetchProfileImage(sess, hash)
|
||||
} else {
|
||||
MibContactsClient().fetchProfileImageBase64(sess, hash)
|
||||
} ?: return@launch
|
||||
@@ -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
|
||||
|
||||
@@ -12,23 +12,23 @@ import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||
import sh.sar.basedbank.databinding.ItemContactBinding
|
||||
import sh.sar.basedbank.util.ContactDisplay
|
||||
|
||||
class ContactsAdapter(
|
||||
private val imageCache: MutableMap<String, Bitmap>,
|
||||
private val onImageNeeded: (hash: String) -> Unit,
|
||||
private val onDeleteClick: (MibBeneficiary) -> Unit,
|
||||
private val onTransferClick: (MibBeneficiary) -> Unit
|
||||
private val onDeleteClick: (ContactDisplay) -> Unit,
|
||||
private val onTransferClick: (ContactDisplay) -> Unit
|
||||
) : RecyclerView.Adapter<ContactsAdapter.ViewHolder>() {
|
||||
|
||||
private var allContacts: List<MibBeneficiary> = emptyList()
|
||||
private var displayed: List<MibBeneficiary> = emptyList()
|
||||
private var allContacts: List<ContactDisplay> = emptyList()
|
||||
private var displayed: List<ContactDisplay> = emptyList()
|
||||
|
||||
private var activeCategoryId: String? = null
|
||||
private var searchQuery: String = ""
|
||||
|
||||
fun updateContacts(contacts: List<MibBeneficiary>) {
|
||||
fun updateContacts(contacts: List<ContactDisplay>) {
|
||||
allContacts = contacts
|
||||
applyFilter()
|
||||
}
|
||||
@@ -36,7 +36,7 @@ class ContactsAdapter(
|
||||
fun updateImage(hash: String, bitmap: Bitmap) {
|
||||
imageCache[hash] = bitmap
|
||||
displayed.forEachIndexed { index, contact ->
|
||||
if (contact.customerImgHash == hash) notifyItemChanged(index)
|
||||
if (contact.imageHash == hash) notifyItemChanged(index)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,11 +48,11 @@ class ContactsAdapter(
|
||||
|
||||
private fun applyFilter() {
|
||||
displayed = allContacts.filter { contact ->
|
||||
val matchesCategory = activeCategoryId == null || contact.benefCategoryId == activeCategoryId
|
||||
val matchesCategory = activeCategoryId == null || contact.categoryId == activeCategoryId
|
||||
val matchesSearch = searchQuery.isBlank() ||
|
||||
contact.benefNickName.contains(searchQuery, ignoreCase = true) ||
|
||||
contact.benefName.contains(searchQuery, ignoreCase = true) ||
|
||||
contact.benefAccount.contains(searchQuery)
|
||||
contact.name.contains(searchQuery, ignoreCase = true) ||
|
||||
contact.realName.contains(searchQuery, ignoreCase = true) ||
|
||||
contact.accountNumber.contains(searchQuery)
|
||||
matchesCategory && matchesSearch
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
@@ -76,7 +76,7 @@ class ContactsAdapter(
|
||||
binding.root.setOnLongClickListener {
|
||||
val pos = holder.bindingAdapterPosition
|
||||
if (pos == RecyclerView.NO_POSITION) return@setOnLongClickListener false
|
||||
val account = displayed[pos].benefAccount
|
||||
val account = displayed[pos].accountNumber
|
||||
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("account", account))
|
||||
Toast.makeText(it.context, account, Toast.LENGTH_SHORT).show()
|
||||
@@ -88,7 +88,7 @@ class ContactsAdapter(
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val contact = displayed[position]
|
||||
val cachedImage = contact.customerImgHash?.let { hash ->
|
||||
val cachedImage = contact.imageHash?.let { hash ->
|
||||
imageCache[hash] ?: run { onImageNeeded(hash); null }
|
||||
}
|
||||
holder.bind(contact, cachedImage)
|
||||
@@ -99,21 +99,24 @@ class ContactsAdapter(
|
||||
inner class ViewHolder(val binding: ItemContactBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(contact: MibBeneficiary, photo: Bitmap?) {
|
||||
val isFahipay = contact.benefType == "FAHIPAY"
|
||||
binding.tvContactName.text = contact.benefNickName
|
||||
binding.tvContactAccount.text = contact.benefAccount
|
||||
binding.tvRealName.text = if (isFahipay) "" else "${contact.benefName} · ${contact.transferCyDesc} · ${contact.benefBankName}"
|
||||
binding.tvRealName.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
|
||||
binding.btnTransferContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
|
||||
binding.btnEditContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
|
||||
binding.btnDeleteContact.visibility = if (isFahipay) android.view.View.GONE else android.view.View.VISIBLE
|
||||
fun bind(contact: ContactDisplay, photo: Bitmap?) {
|
||||
binding.tvContactName.text = contact.name
|
||||
binding.tvContactAccount.text = contact.accountNumber
|
||||
binding.tvRealName.text = contact.detail ?: ""
|
||||
binding.tvRealName.visibility =
|
||||
if (contact.detail != null) android.view.View.VISIBLE else android.view.View.GONE
|
||||
binding.btnTransferContact.visibility =
|
||||
if (contact.canTransfer) android.view.View.VISIBLE else android.view.View.GONE
|
||||
binding.btnEditContact.visibility =
|
||||
if (contact.canEdit) android.view.View.VISIBLE else android.view.View.GONE
|
||||
binding.btnDeleteContact.visibility =
|
||||
if (contact.canDelete) android.view.View.VISIBLE else android.view.View.GONE
|
||||
|
||||
if (photo != null) {
|
||||
binding.ivContactPhoto.setImageBitmap(photo)
|
||||
} else {
|
||||
binding.ivContactPhoto.setImageBitmap(
|
||||
makeInitialsBitmap(contact.benefNickName, contact.bankColor)
|
||||
makeInitialsBitmap(contact.name, contact.bankColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
@@ -21,13 +23,15 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
|
||||
import sh.sar.basedbank.api.models.BankContactCategory
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.databinding.FragmentContactsBinding
|
||||
import sh.sar.basedbank.util.ContactDisplay
|
||||
import sh.sar.basedbank.util.ContactImageCache
|
||||
import sh.sar.basedbank.util.ContactListParser
|
||||
import sh.sar.basedbank.util.ContactManager
|
||||
import sh.sar.basedbank.util.ContactsCache
|
||||
import sh.sar.basedbank.util.TransferNetwork
|
||||
|
||||
class ContactsFragment : Fragment() {
|
||||
|
||||
@@ -38,9 +42,9 @@ class ContactsFragment : Fragment() {
|
||||
private val pendingHashes = mutableSetOf<String>()
|
||||
private val sharedImageCache = mutableMapOf<String, Bitmap>()
|
||||
private val app get() = requireActivity().application as BasedBankApp
|
||||
private val session get() = app.mibSession
|
||||
private val session get() = app.anyMibSession()
|
||||
|
||||
private var allContacts: List<MibBeneficiary> = emptyList()
|
||||
private var allContacts: List<ContactDisplay> = emptyList()
|
||||
private var currentSearch: String = ""
|
||||
private var mediator: TabLayoutMediator? = null
|
||||
private lateinit var pagerAdapter: ContactsPagerAdapter
|
||||
@@ -53,9 +57,9 @@ class ContactsFragment : Fragment() {
|
||||
private val density get() = resources.displayMetrics.density
|
||||
val contactAdapters: List<ContactsAdapter> = pages.map { page ->
|
||||
ContactsAdapter(
|
||||
imageCache = sharedImageCache,
|
||||
onImageNeeded = { hash -> fetchImage(hash) },
|
||||
onDeleteClick = { contact -> confirmDelete(contact) },
|
||||
imageCache = sharedImageCache,
|
||||
onImageNeeded = { hash -> fetchImage(hash) },
|
||||
onDeleteClick = { contact -> confirmDelete(contact) },
|
||||
onTransferClick = { contact -> openTransfer(contact) }
|
||||
).also { a ->
|
||||
a.setFilter(page.categoryId, currentSearch)
|
||||
@@ -63,7 +67,7 @@ class ContactsFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
fun updateContacts(contacts: List<MibBeneficiary>) =
|
||||
fun updateContacts(contacts: List<ContactDisplay>) =
|
||||
contactAdapters.forEach { it.updateContacts(contacts) }
|
||||
|
||||
fun updateSearch(query: String) =
|
||||
@@ -86,7 +90,7 @@ class ContactsFragment : Fragment() {
|
||||
)
|
||||
clipToPadding = false
|
||||
val p4 = (4 * density).toInt()
|
||||
val p80 = (80 * density).toInt()
|
||||
val p80 = (65 * density).toInt()
|
||||
setPadding(0, p4, 0, p80)
|
||||
adapter = contactAdapters[viewType]
|
||||
}
|
||||
@@ -113,19 +117,35 @@ class ContactsFragment : Fragment() {
|
||||
pagerAdapter.updateSearch(currentSearch)
|
||||
}
|
||||
|
||||
val fabMarginBase = (16 * resources.displayMetrics.density).toInt()
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.fabAddContact) { v, insets ->
|
||||
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||
val lp = v.layoutParams as androidx.coordinatorlayout.widget.CoordinatorLayout.LayoutParams
|
||||
lp.bottomMargin = fabMarginBase + extraBottom
|
||||
v.layoutParams = lp
|
||||
insets
|
||||
}
|
||||
|
||||
binding.fabAddContact.setOnClickListener {
|
||||
AddContactSheetFragment().show(childFragmentManager, "add_contact")
|
||||
}
|
||||
|
||||
(activity as? HomeActivity)?.loadAllContacts()
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
|
||||
viewModel.contactCategories.observe(viewLifecycleOwner) { cats ->
|
||||
rebuildPager(cats)
|
||||
}
|
||||
|
||||
viewModel.contacts.observe(viewLifecycleOwner) { contacts ->
|
||||
allContacts = contacts
|
||||
pagerAdapter.updateContacts(contacts)
|
||||
allContacts = ContactListParser.fromList(contacts)
|
||||
pagerAdapter.updateContacts(allContacts)
|
||||
binding.emptyView.visibility = if (contacts.isEmpty()) View.VISIBLE else View.GONE
|
||||
binding.loadingView.visibility = View.GONE
|
||||
}
|
||||
@@ -138,7 +158,7 @@ class ContactsFragment : Fragment() {
|
||||
}.also { it.attach() }
|
||||
}
|
||||
|
||||
private fun rebuildPager(cats: List<MibBeneficiaryCategory>) {
|
||||
private fun rebuildPager(cats: List<BankContactCategory>) {
|
||||
val pages = buildList {
|
||||
add(TabPage(null, getString(R.string.contacts_tab_all)))
|
||||
cats.forEach { add(TabPage(it.id, it.categoryName)) }
|
||||
@@ -150,31 +170,29 @@ class ContactsFragment : Fragment() {
|
||||
binding.viewPager.setCurrentItem(savedPosition.coerceIn(0, pages.size - 1), false)
|
||||
}
|
||||
|
||||
private fun openTransfer(contact: MibBeneficiary) {
|
||||
private fun openTransfer(contact: ContactDisplay) {
|
||||
val fragment = TransferFragment.newInstance(
|
||||
accountNumber = contact.benefAccount,
|
||||
displayName = contact.benefNickName,
|
||||
subtitle = "${contact.benefBankName} · ${contact.benefAccount}",
|
||||
accountNumber = contact.accountNumber,
|
||||
displayName = contact.name,
|
||||
subtitle = contact.transferSubtitle,
|
||||
colorHex = contact.bankColor,
|
||||
imageHash = contact.customerImgHash
|
||||
imageHash = contact.imageHash
|
||||
)
|
||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
|
||||
}
|
||||
|
||||
private fun confirmDelete(contact: MibBeneficiary) {
|
||||
AlertDialog.Builder(requireContext())
|
||||
private fun confirmDelete(contact: ContactDisplay) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.contact_delete_title)
|
||||
.setMessage(getString(R.string.contact_delete_message, contact.benefNickName))
|
||||
.setMessage(getString(R.string.contact_delete_message, contact.name))
|
||||
.setPositiveButton(R.string.contact_delete) { _, _ -> deleteContact(contact) }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun deleteContact(contact: MibBeneficiary) {
|
||||
private fun deleteContact(contact: ContactDisplay) {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
if (contact.benefCategoryId == "BML") deleteBml(contact) else deleteMib(contact)
|
||||
}
|
||||
val success = withContext(Dispatchers.IO) { ContactManager.delete(contact, app) }
|
||||
if (success) {
|
||||
Toast.makeText(requireContext(), R.string.contact_deleted, Toast.LENGTH_SHORT).show()
|
||||
removeFromViewModel(contact)
|
||||
@@ -184,27 +202,10 @@ class ContactsFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteBml(contact: MibBeneficiary): Boolean {
|
||||
val sess = app.bmlSessions[contact.profileId] ?: app.anyBmlSession() ?: return false
|
||||
val contactId = contact.benefNo.removePrefix("bml_")
|
||||
return try { BmlLoginFlow().deleteContact(sess, contactId) } catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
private fun deleteMib(contact: MibBeneficiary): Boolean {
|
||||
val sess = session ?: return false
|
||||
return try {
|
||||
if (contact.profileId.isNotBlank()) {
|
||||
val profile = app.mibProfiles.firstOrNull { it.profileId == contact.profileId }
|
||||
if (profile != null) app.mibLoginFlow.switchProfile(sess, profile)
|
||||
}
|
||||
MibContactsClient().deleteContact(sess, contact.benefNo)
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
private fun removeFromViewModel(contact: MibBeneficiary) {
|
||||
val updated = viewModel.contacts.value?.filter { it.benefNo != contact.benefNo } ?: return
|
||||
private fun removeFromViewModel(contact: ContactDisplay) {
|
||||
val updated = viewModel.contacts.value?.filter { it.benefNo != contact.id } ?: return
|
||||
viewModel.contacts.value = updated
|
||||
if (contact.benefCategoryId == "BML") {
|
||||
if (contact.network == TransferNetwork.BML) {
|
||||
updated.filter { it.benefCategoryId == "BML" }
|
||||
.groupBy { it.profileId }
|
||||
.forEach { (loginId, contacts) ->
|
||||
@@ -221,7 +222,6 @@ class ContactsFragment : Fragment() {
|
||||
|
||||
private fun fetchImage(hash: String) {
|
||||
if (!pendingHashes.add(hash)) return
|
||||
// Check disk cache first — if hash matches we already have the image
|
||||
val cached = ContactImageCache.load(requireContext(), hash)
|
||||
if (cached != null) {
|
||||
view?.post { pagerAdapter.updateImage(hash, cached) }
|
||||
|
||||
@@ -1,16 +1,31 @@
|
||||
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.mib.MibAccount
|
||||
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 kotlin.math.abs
|
||||
import sh.sar.basedbank.databinding.FragmentDashboardBinding
|
||||
import sh.sar.basedbank.databinding.ItemForeignLimitBinding
|
||||
|
||||
@@ -20,6 +35,22 @@ 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
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
|
||||
raw.startsWith("https://pay.bml.com.mv/app/")) {
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, 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
|
||||
@@ -27,64 +58,265 @@ class DashboardFragment : Fragment() {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { updateBalances(it) }
|
||||
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances(it) }
|
||||
viewModel.financing.observe(viewLifecycleOwner) { updatePendingFinances() }
|
||||
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) { updatePendingFinances() }
|
||||
viewModel.bmlLimits.observe(viewLifecycleOwner) { updateForeignLimits(it) }
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) {
|
||||
updateBalances(viewModel.accounts.value ?: emptyList())
|
||||
updatePendingFinances()
|
||||
updateForeignLimits(viewModel.bmlLimits.value ?: emptyList())
|
||||
}
|
||||
|
||||
binding.btnTransfer.setOnClickListener {
|
||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer)
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
binding.btnPayMvQr.setOnClickListener {
|
||||
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||
|
||||
binding.cardPendingFinances.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 mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
|
||||
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
||||
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) }
|
||||
.map { CardItem.Bml(it) }
|
||||
val all = mibItems + bmlItems
|
||||
cardAdapter.update(all)
|
||||
binding.sectionCards.visibility = if (all.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)
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||
insets
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = getString(R.string.nav_dashboard)
|
||||
refreshQuickActions()
|
||||
}
|
||||
|
||||
private fun updateBalances(accounts: List<MibAccount>) {
|
||||
val mvrTotal = accounts
|
||||
private fun refreshQuickActions() {
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
val ids = NavCustomization.getQuickActions(prefs)
|
||||
listOf(binding.btnQuickAction1, binding.btnQuickAction2).forEachIndexed { i, btn ->
|
||||
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == ids[i] }
|
||||
if (def != null) {
|
||||
btn.setText(def.titleRes)
|
||||
btn.icon = ContextCompat.getDrawable(requireContext(), def.iconRes)
|
||||
}
|
||||
btn.setOnClickListener { (requireActivity() as HomeActivity).navigateTo(ids[i]) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateBalances(accounts: List<BankAccount>) {
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
|
||||
val nonCreditAccounts = accounts.filter { it.profileType != "BML_CREDIT" && it.profileType != "BML_LOAN" }
|
||||
val creditAccounts = accounts.filter { it.profileType == "BML_CREDIT" }
|
||||
|
||||
if (hide) {
|
||||
binding.tvMvrBalance.text = "MVR ••••••"
|
||||
binding.tvUsdBalance.text = "USD ••••••"
|
||||
if (creditAccounts.isNotEmpty()) {
|
||||
binding.rowCreditCards.visibility = View.VISIBLE
|
||||
val hasMvrCredit = creditAccounts.any { it.currencyName.equals("MVR", ignoreCase = true) }
|
||||
val hasUsdCredit = creditAccounts.any { it.currencyName.equals("USD", ignoreCase = true) }
|
||||
binding.cardMvrCredit.visibility = if (hasMvrCredit) View.VISIBLE else View.GONE
|
||||
binding.cardUsdCredit.visibility = if (hasUsdCredit) View.VISIBLE else View.GONE
|
||||
binding.tvMvrCredit.text = "MVR ••••••"
|
||||
binding.tvUsdCredit.text = "USD ••••••"
|
||||
} else {
|
||||
binding.rowCreditCards.visibility = View.GONE
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val mvrTotal = nonCreditAccounts
|
||||
.filter { it.currencyName.equals("MVR", ignoreCase = true) }
|
||||
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
||||
val usdTotal = accounts
|
||||
val usdTotal = nonCreditAccounts
|
||||
.filter { it.currencyName.equals("USD", ignoreCase = true) }
|
||||
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
||||
|
||||
binding.tvMvrBalance.text = "MVR %,.2f".format(mvrTotal)
|
||||
binding.tvUsdBalance.text = "USD %,.2f".format(usdTotal)
|
||||
|
||||
val mvrCredit = creditAccounts
|
||||
.filter { it.currencyName.equals("MVR", ignoreCase = true) }
|
||||
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
||||
val usdCredit = creditAccounts
|
||||
.filter { it.currencyName.equals("USD", ignoreCase = true) }
|
||||
.sumOf { it.availableBalance.replace(",", "").toDoubleOrNull() ?: 0.0 }
|
||||
|
||||
if (creditAccounts.isNotEmpty()) {
|
||||
binding.rowCreditCards.visibility = View.VISIBLE
|
||||
binding.cardMvrCredit.visibility = if (mvrCredit > 0) View.VISIBLE else View.GONE
|
||||
binding.cardUsdCredit.visibility = if (usdCredit > 0) View.VISIBLE else View.GONE
|
||||
binding.tvMvrCredit.text = "MVR %,.2f".format(mvrCredit)
|
||||
binding.tvUsdCredit.text = "USD %,.2f".format(usdCredit)
|
||||
} else {
|
||||
binding.rowCreditCards.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private val expandedLimits = mutableSetOf<Int>()
|
||||
|
||||
private fun updateForeignLimits(entries: List<HomeViewModel.BmlLimitsData>) {
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
binding.containerForeignLimits.removeAllViews()
|
||||
var cardIndex = 0
|
||||
for (entry in entries) {
|
||||
for (limit in entry.limits) {
|
||||
val idx = cardIndex++
|
||||
val card = ItemForeignLimitBinding.inflate(layoutInflater, binding.containerForeignLimits, false)
|
||||
card.tvLimitUserName.text = entry.userName.ifBlank { "BML" }
|
||||
card.tvLimitType.text = limit.type
|
||||
card.tvLimitGeneral.text = "USD %,.0f / %,.0f".format(limit.generalRemaining, limit.generalCap)
|
||||
card.tvLimitMedical.text = "USD %,.0f".format(limit.medicalRemaining)
|
||||
card.tvLimitAtm.text = if (!limit.isAtmEnabled)
|
||||
"USD %,.0f / %,.0f · Disabled".format(limit.atmRemaining, limit.atmLimit)
|
||||
else
|
||||
"USD %,.0f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
|
||||
card.tvLimitEcom.text = "USD %,.0f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
|
||||
card.tvLimitPos.text = if (!limit.isPosEnabled)
|
||||
"USD %,.0f / %,.0f · Disabled".format(limit.posRemaining, limit.posLimit)
|
||||
else
|
||||
"USD %,.0f / %,.0f".format(limit.posRemaining, limit.posLimit)
|
||||
bindLimitCard(card, entry.userName, limit, hide, idx in expandedLimits)
|
||||
card.root.setOnClickListener {
|
||||
if (idx in expandedLimits) expandedLimits.remove(idx) else expandedLimits.add(idx)
|
||||
updateForeignLimits(entries)
|
||||
}
|
||||
binding.containerForeignLimits.addView(card.root)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePendingFinances(deals: List<MibFinanceDeal>) {
|
||||
val total = deals.sumOf { it.outstandingAmount }
|
||||
binding.tvPendingFinances.text = "MVR %,.2f".format(total)
|
||||
private fun bindLimitCard(
|
||||
card: ItemForeignLimitBinding,
|
||||
userName: String,
|
||||
limit: BmlForeignLimit,
|
||||
hide: Boolean,
|
||||
expanded: Boolean
|
||||
) {
|
||||
card.tvLimitUserName.text = userName.ifBlank { "BML" }
|
||||
card.tvLimitType.text = limit.type
|
||||
|
||||
// ECOM (always visible)
|
||||
card.tvLimitEcom.text = if (hide) "USD ••••••"
|
||||
else "USD %,.2f / %,.0f".format(limit.ecomRemaining, limit.ecomLimit)
|
||||
card.progressEcom.progress = if (hide || limit.ecomLimit <= 0) 0
|
||||
else ((limit.ecomRemaining / limit.ecomLimit) * 100).toInt().coerceIn(0, 100)
|
||||
|
||||
// General (always visible)
|
||||
card.tvLimitGeneral.text = if (hide) "USD ••••••"
|
||||
else "USD %,.2f / %,.0f".format(limit.generalRemaining, limit.generalCap)
|
||||
card.progressGeneral.progress = if (hide || limit.generalCap <= 0) 0
|
||||
else ((limit.generalRemaining / limit.generalCap) * 100).toInt().coerceIn(0, 100)
|
||||
|
||||
// Expanded section
|
||||
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
|
||||
card.dividerLimitDetails.visibility = detailsVisible
|
||||
card.detailsGroup.visibility = detailsVisible
|
||||
|
||||
if (expanded) {
|
||||
// ATM
|
||||
if (!limit.isAtmEnabled) card.tvAtmLabel.append(" (Disabled)")
|
||||
card.tvLimitAtm.text = if (hide) "USD ••••••"
|
||||
else "USD %,.2f / %,.0f".format(limit.atmRemaining, limit.atmLimit)
|
||||
card.progressAtm.progress = if (hide || limit.atmLimit <= 0) 0
|
||||
else ((limit.atmRemaining / limit.atmLimit) * 100).toInt().coerceIn(0, 100)
|
||||
|
||||
// POS
|
||||
if (!limit.isPosEnabled) card.tvPosLabel.append(" (Disabled)")
|
||||
card.tvLimitPos.text = if (hide) "USD ••••••"
|
||||
else "USD %,.2f / %,.0f".format(limit.posRemaining, limit.posLimit)
|
||||
card.progressPos.progress = if (hide || limit.posLimit <= 0) 0
|
||||
else ((limit.posRemaining / limit.posLimit) * 100).toInt().coerceIn(0, 100)
|
||||
|
||||
// Medical
|
||||
card.tvLimitMedical.text = if (hide) "USD ••••••"
|
||||
else "USD %,.2f / %,.0f".format(limit.medicalRemaining, limit.totalLimit)
|
||||
card.progressMedical.progress = if (hide || limit.totalLimit <= 0) 0
|
||||
else ((limit.medicalRemaining / limit.totalLimit) * 100).toInt().coerceIn(0, 100)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePendingFinances() {
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val mibTotal = (viewModel.financing.value ?: emptyList()).sumOf { it.outstandingAmount }
|
||||
val bmlLoanDetails = viewModel.bmlLoanDetails.value ?: emptyMap()
|
||||
val bmlTotal = bmlLoanDetails.values.sumOf { abs(it.outstandingAmt) }
|
||||
val total = mibTotal + bmlTotal
|
||||
binding.tvPendingFinances.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(total)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
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 = PayWithCardFragment.formatMasked(item.card.maskedCardNumber)
|
||||
val assetPath = PayWithCardFragment.cardImageAsset(item.card)
|
||||
if (assetPath != null) PayWithCardFragment.loadCardImage(ivCardImage, assetPath)
|
||||
else ivCardImage.setImageDrawable(null)
|
||||
PayWithCardFragment.bindCardStatus(tvCardStatus, PayWithCardFragment.mibCardStatusLabel(item.card.cardStatus))
|
||||
}
|
||||
is CardItem.Bml -> {
|
||||
tvCardOwner.text = item.account.accountBriefName
|
||||
tvCardNumber.text = PayWithCardFragment.formatMasked(item.account.accountNumber)
|
||||
PayWithCardFragment.loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
|
||||
PayWithCardFragment.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 {
|
||||
val msg = if (isMib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
|
||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,92 +5,139 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||
import sh.sar.basedbank.api.mib.MibFinancingClient
|
||||
import sh.sar.basedbank.databinding.ItemBmlLoanBinding
|
||||
import sh.sar.basedbank.databinding.ItemFinanceDealBinding
|
||||
import java.text.NumberFormat
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
import java.util.Locale
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
|
||||
class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
||||
RecyclerView.Adapter<FinancingAdapter.ViewHolder>() {
|
||||
class FinancingAdapter(mibDeals: List<MibFinanceDeal>) :
|
||||
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private sealed class Item {
|
||||
data class Mib(val deal: MibFinanceDeal) : Item()
|
||||
data class Bml(val account: BankAccount, val detail: BmlLoanDetail?) : Item()
|
||||
}
|
||||
|
||||
private var items: List<Item> = mibDeals.map { Item.Mib(it) }
|
||||
private var hideAmounts: Boolean = false
|
||||
|
||||
private val expandedPositions = mutableSetOf<Int>()
|
||||
private val amountFmt = NumberFormat.getNumberInstance(Locale.US).apply {
|
||||
minimumFractionDigits = 2
|
||||
maximumFractionDigits = 2
|
||||
}
|
||||
private val inputDateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
private val mibDateFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
private val isoDateFmt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US)
|
||||
private val outputDateFmt = SimpleDateFormat("d MMM yyyy", Locale.US)
|
||||
|
||||
fun updateDeals(newDeals: List<MibFinanceDeal>) {
|
||||
deals = newDeals
|
||||
expandedPositions.clear()
|
||||
fun setHideAmounts(hide: Boolean) {
|
||||
if (hideAmounts == hide) return
|
||||
hideAmounts = hide
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val binding = ItemFinanceDealBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return ViewHolder(binding)
|
||||
fun update(mibDeals: List<MibFinanceDeal>, bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>>) {
|
||||
expandedPositions.clear()
|
||||
items = mibDeals.map { Item.Mib(it) } + bmlLoans.map { (acc, detail) -> Item.Bml(acc, detail) }
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
holder.bind(deals[position], position in expandedPositions)
|
||||
holder.binding.root.setOnClickListener {
|
||||
// Legacy compatibility — used on initial empty construction
|
||||
fun updateDeals(newDeals: List<MibFinanceDeal>) {
|
||||
expandedPositions.clear()
|
||||
val bmlItems = items.filterIsInstance<Item.Bml>()
|
||||
items = newDeals.map { Item.Mib(it) } + bmlItems
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun updateBmlLoans(loans: List<Pair<BankAccount, BmlLoanDetail?>>) {
|
||||
expandedPositions.clear()
|
||||
val mibItems = items.filterIsInstance<Item.Mib>()
|
||||
items = mibItems + loans.map { (acc, detail) -> Item.Bml(acc, detail) }
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int) = when (items[position]) {
|
||||
is Item.Mib -> TYPE_MIB
|
||||
is Item.Bml -> TYPE_BML
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
|
||||
val inflater = LayoutInflater.from(parent.context)
|
||||
return when (viewType) {
|
||||
TYPE_BML -> BmlViewHolder(ItemBmlLoanBinding.inflate(inflater, parent, false))
|
||||
else -> MibViewHolder(ItemFinanceDealBinding.inflate(inflater, parent, false))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
val expanded = position in expandedPositions
|
||||
when (val item = items[position]) {
|
||||
is Item.Mib -> (holder as MibViewHolder).bind(item.deal, expanded)
|
||||
is Item.Bml -> (holder as BmlViewHolder).bind(item.account, item.detail, expanded)
|
||||
}
|
||||
holder.itemView.setOnClickListener {
|
||||
val pos = holder.bindingAdapterPosition
|
||||
if (pos in expandedPositions) expandedPositions.remove(pos) else expandedPositions.add(pos)
|
||||
notifyItemChanged(pos)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = deals.size
|
||||
override fun getItemCount() = items.size
|
||||
|
||||
inner class ViewHolder(val binding: ItemFinanceDealBinding) :
|
||||
// ── MIB ViewHolder ────────────────────────────────────────────────────────
|
||||
|
||||
inner class MibViewHolder(val binding: ItemFinanceDealBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(deal: MibFinanceDeal, expanded: Boolean) {
|
||||
val ctx = binding.root.context
|
||||
val currency = deal.currency
|
||||
val hide = hideAmounts
|
||||
|
||||
binding.tvProductName.text = deal.productDesc
|
||||
binding.tvDealNo.text = ctx.getString(R.string.financing_deal_no_fmt, deal.dealNo)
|
||||
binding.tvStatus.text = deal.statusDesc
|
||||
binding.tvTotal.text = "$currency ${amountFmt.format(deal.dealAmount)}"
|
||||
binding.tvPaid.text = "$currency ${amountFmt.format(deal.paidAmount)}"
|
||||
binding.tvUnpaid.text = "$currency ${amountFmt.format(deal.outstandingAmount)}"
|
||||
binding.tvTotal.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.dealAmount)}"
|
||||
binding.tvPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.paidAmount)}"
|
||||
binding.tvUnpaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.outstandingAmount)}"
|
||||
|
||||
// Progress bar
|
||||
val progress = if (deal.dealAmount > 0)
|
||||
((deal.paidAmount / deal.dealAmount) * 100).toInt().coerceIn(0, 100)
|
||||
else 0
|
||||
binding.progressBar.progress = progress
|
||||
binding.progressBar.progress = if (hide) 0 else progress
|
||||
|
||||
// Completion estimate
|
||||
binding.tvCompletion.text = completionText(deal, ctx)
|
||||
binding.tvCompletion.text = mibCompletionText(deal, ctx)
|
||||
|
||||
// Expanded details
|
||||
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
|
||||
binding.dividerDetails.visibility = detailsVisible
|
||||
binding.detailsGroup.visibility = detailsVisible
|
||||
|
||||
if (expanded) {
|
||||
binding.tvDealDate.text = formatDate(deal.dealDate)
|
||||
binding.tvInstallment.text = "$currency ${amountFmt.format(deal.installmentAmount)}"
|
||||
binding.tvDealDate.text = formatMibDate(deal.dealDate)
|
||||
binding.tvInstallment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.installmentAmount)}"
|
||||
binding.tvNumInstallments.text = deal.noOfInstallments.toString()
|
||||
binding.tvLastPaidDate.text = formatDate(deal.lastPaidDate)
|
||||
binding.tvLastPayAmount.text = "$currency ${amountFmt.format(deal.lastPayAmount)}"
|
||||
binding.tvLastPaidDate.text = formatMibDate(deal.lastPaidDate)
|
||||
binding.tvLastPayAmount.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.lastPayAmount)}"
|
||||
|
||||
if (deal.overdueAmount > 0) {
|
||||
binding.rowOverdue.visibility = View.VISIBLE
|
||||
binding.tvOverdue.text = "$currency ${amountFmt.format(deal.overdueAmount)}"
|
||||
binding.tvOverdue.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(deal.overdueAmount)}"
|
||||
} else {
|
||||
binding.rowOverdue.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun completionText(deal: MibFinanceDeal, ctx: android.content.Context): String {
|
||||
private fun mibCompletionText(deal: MibFinanceDeal, ctx: android.content.Context): String {
|
||||
if (deal.outstandingAmount <= 0.0) return ctx.getString(R.string.financing_completion_done)
|
||||
val remaining = MibFinancingClient.remainingMonths(deal)
|
||||
if (remaining <= 0) return ctx.getString(R.string.financing_completion_done)
|
||||
@@ -100,12 +147,84 @@ class FinancingAdapter(private var deals: List<MibFinanceDeal>) :
|
||||
return ctx.getString(R.string.financing_completion_fmt, month)
|
||||
}
|
||||
|
||||
private fun formatDate(raw: String): String {
|
||||
private fun formatMibDate(raw: String): String {
|
||||
return try {
|
||||
outputDateFmt.format(inputDateFmt.parse(raw)!!)
|
||||
} catch (_: Exception) {
|
||||
raw.take(10)
|
||||
}
|
||||
outputDateFmt.format(mibDateFmt.parse(raw)!!)
|
||||
} catch (_: Exception) { raw.take(10) }
|
||||
}
|
||||
}
|
||||
|
||||
// ── BML ViewHolder ────────────────────────────────────────────────────────
|
||||
|
||||
inner class BmlViewHolder(val binding: ItemBmlLoanBinding) :
|
||||
RecyclerView.ViewHolder(binding.root) {
|
||||
|
||||
fun bind(account: BankAccount, detail: BmlLoanDetail?, expanded: Boolean) {
|
||||
val ctx = binding.root.context
|
||||
val currency = account.currencyName
|
||||
val hide = hideAmounts
|
||||
|
||||
binding.tvLoanProduct.text = account.accountTypeName
|
||||
.trim().lowercase().split(" ")
|
||||
.joinToString(" ") { it.replaceFirstChar { c -> c.uppercaseChar() } }
|
||||
binding.tvLoanAccount.text = account.accountNumber
|
||||
binding.tvLoanStatus.text = detail?.loanStatus?.ifBlank { account.statusDesc } ?: account.statusDesc
|
||||
|
||||
val loanAmt = detail?.loanAmount ?: 0.0
|
||||
val outstanding = if (detail != null) abs(detail.outstandingAmt) else account.availableBalance.toDoubleOrNull() ?: 0.0
|
||||
val paid = (loanAmt - outstanding).coerceAtLeast(0.0)
|
||||
|
||||
binding.tvLoanTotal.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(loanAmt)}"
|
||||
binding.tvLoanPaid.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(paid)}"
|
||||
binding.tvLoanOutstanding.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(outstanding)}"
|
||||
|
||||
val progress = if (loanAmt > 0) ((paid / loanAmt) * 100).toInt().coerceIn(0, 100) else 0
|
||||
binding.loanProgressBar.progress = if (hide) 0 else progress
|
||||
|
||||
binding.tvLoanCompletion.text = bmlCompletionText(detail, ctx)
|
||||
|
||||
val detailsVisible = if (expanded) View.VISIBLE else View.GONE
|
||||
binding.loanDividerDetails.visibility = detailsVisible
|
||||
binding.loanDetailsGroup.visibility = detailsVisible
|
||||
|
||||
if (expanded && detail != null) {
|
||||
binding.tvLoanRepayment.text = if (hide) "$currency ••••••" else "$currency ${amountFmt.format(detail.repayAmount)}"
|
||||
binding.tvLoanIntRate.text = ctx.getString(R.string.loan_rate_fmt, detail.intRate)
|
||||
binding.tvLoanStartDate.text = formatIsoDate(detail.startDate)
|
||||
binding.tvLoanEndDate.text = formatIsoDate(detail.endDate)
|
||||
|
||||
if (detail.overdueAmount > 0) {
|
||||
binding.loanRowOverdue.visibility = View.VISIBLE
|
||||
binding.tvLoanOverdue.text = if (hide) "$currency ••••••"
|
||||
else "$currency ${amountFmt.format(detail.overdueAmount)} (${detail.noOfRepayOverdue})"
|
||||
} else {
|
||||
binding.loanRowOverdue.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bmlCompletionText(detail: BmlLoanDetail?, ctx: android.content.Context): String {
|
||||
if (detail == null) return ""
|
||||
val outstanding = abs(detail.outstandingAmt)
|
||||
if (outstanding <= 0.0 || detail.repayAmount <= 0.0)
|
||||
return ctx.getString(R.string.financing_completion_done)
|
||||
val remaining = ceil(outstanding / detail.repayAmount).toInt()
|
||||
if (remaining <= 0) return ctx.getString(R.string.financing_completion_done)
|
||||
val cal = Calendar.getInstance()
|
||||
cal.add(Calendar.MONTH, remaining)
|
||||
val month = SimpleDateFormat("MMMM yyyy", Locale.getDefault()).format(cal.time)
|
||||
return ctx.getString(R.string.financing_completion_fmt, month)
|
||||
}
|
||||
|
||||
private fun formatIsoDate(raw: String): String {
|
||||
return try {
|
||||
outputDateFmt.format(isoDateFmt.parse(raw)!!)
|
||||
} catch (_: Exception) { raw.take(10) }
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TYPE_MIB = 0
|
||||
private const val TYPE_BML = 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,15 @@ import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlLoanDetail
|
||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.databinding.FragmentFinancingBinding
|
||||
|
||||
class FinancingFragment : Fragment() {
|
||||
@@ -17,6 +22,9 @@ class FinancingFragment : Fragment() {
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
private lateinit var adapter: FinancingAdapter
|
||||
|
||||
private var latestMibDeals: List<MibFinanceDeal> = emptyList()
|
||||
private var latestBmlLoanDetails: Map<String, BmlLoanDetail> = emptyMap()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentFinancingBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
@@ -27,12 +35,44 @@ class FinancingFragment : Fragment() {
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
viewModel.financing.observe(viewLifecycleOwner) { deals ->
|
||||
adapter.updateDeals(deals)
|
||||
binding.recyclerView.visibility = if (deals.isEmpty()) View.GONE else View.VISIBLE
|
||||
binding.emptyView.visibility = if (deals.isEmpty()) View.VISIBLE else View.GONE
|
||||
binding.loadingView.visibility = View.GONE
|
||||
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||
insets
|
||||
}
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { rebuildAdapter() }
|
||||
viewModel.financing.observe(viewLifecycleOwner) { deals ->
|
||||
latestMibDeals = deals
|
||||
rebuildAdapter()
|
||||
}
|
||||
viewModel.bmlLoanDetails.observe(viewLifecycleOwner) { details ->
|
||||
latestBmlLoanDetails = details
|
||||
rebuildAdapter()
|
||||
}
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||
}
|
||||
|
||||
private fun rebuildAdapter() {
|
||||
val accounts = viewModel.accounts.value ?: emptyList()
|
||||
val loanAccounts = accounts.filter { it.profileType == "BML_LOAN" }
|
||||
val bmlLoans: List<Pair<BankAccount, BmlLoanDetail?>> =
|
||||
loanAccounts.map { acc -> acc to latestBmlLoanDetails[acc.internalId] }
|
||||
|
||||
adapter.update(latestMibDeals, bmlLoans)
|
||||
|
||||
val isEmpty = latestMibDeals.isEmpty() && bmlLoans.isEmpty()
|
||||
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
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
|
||||
@@ -3,17 +3,37 @@ package sh.sar.basedbank.ui.home
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import sh.sar.basedbank.api.bml.BmlForeignLimit
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiary
|
||||
import sh.sar.basedbank.api.mib.MibBeneficiaryCategory
|
||||
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<MibAccount>>(emptyList())
|
||||
val accounts = MutableLiveData<List<BankAccount>>(emptyList())
|
||||
val financing = MutableLiveData<List<MibFinanceDeal>>(emptyList())
|
||||
val contacts = MutableLiveData<List<MibBeneficiary>>(emptyList())
|
||||
val contactCategories = MutableLiveData<List<MibBeneficiaryCategory>>(emptyList())
|
||||
/** BML loan details keyed by account internalId. */
|
||||
val bmlLoanDetails = MutableLiveData<Map<String, BmlLoanDetail>>(emptyMap())
|
||||
val contacts = MutableLiveData<List<BankContact>>(emptyList())
|
||||
val contactCategories = MutableLiveData<List<BankContactCategory>>(emptyList())
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
@@ -7,35 +8,24 @@ import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.fragment.app.Fragment
|
||||
import sh.sar.basedbank.R
|
||||
|
||||
class MoreFragment : Fragment() {
|
||||
|
||||
private data class NavItem(val id: Int, @DrawableRes val icon: Int, @StringRes val title: Int)
|
||||
|
||||
private val items = listOf(
|
||||
NavItem(R.id.nav_pay_mv_qr, R.drawable.ic_qr_scan, R.string.pay_mv_qr),
|
||||
NavItem(R.id.nav_activities, R.drawable.ic_nav_activities, R.string.nav_activities),
|
||||
NavItem(R.id.nav_transfer_history, R.drawable.ic_nav_transfer_history, R.string.nav_transfer_history),
|
||||
NavItem(R.id.nav_finances, R.drawable.ic_nav_finances, R.string.nav_finances),
|
||||
NavItem(R.id.nav_card_settings, R.drawable.ic_nav_card, R.string.nav_card_settings),
|
||||
NavItem(R.id.nav_otp, R.drawable.ic_nav_otp, R.string.nav_otp),
|
||||
NavItem(R.id.nav_settings, R.drawable.ic_nav_settings, R.string.nav_settings),
|
||||
)
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
||||
inflater.inflate(R.layout.fragment_more, container, false)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
val items = NavCustomization.getMoreItems(prefs)
|
||||
val list = view.findViewById<LinearLayout>(R.id.moreList)
|
||||
val inflater = LayoutInflater.from(requireContext())
|
||||
for (item in items) {
|
||||
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<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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import sh.sar.basedbank.R
|
||||
|
||||
object NavCustomization {
|
||||
|
||||
data class NavItemDef(
|
||||
val id: Int,
|
||||
val key: String,
|
||||
@DrawableRes val iconRes: 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, "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_card_settings, "nav_card_settings", R.drawable.ic_nav_card, R.string.nav_card_settings, R.string.nav_desc_card_settings),
|
||||
NavItemDef(R.id.nav_otp, "nav_otp", R.drawable.ic_nav_otp, R.string.nav_otp, R.string.nav_desc_otp),
|
||||
NavItemDef(R.id.nav_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(
|
||||
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()
|
||||
.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(
|
||||
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()
|
||||
.putString("quick_action_1_key", idToKey(ids[0]) ?: "nav_transfer")
|
||||
.putString("quick_action_2_key", idToKey(ids[1]) ?: "nav_pay_mv_qr")
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** Items that belong in the "More" screen — those not occupying a bottom nav slot. */
|
||||
fun getMoreItems(prefs: SharedPreferences): List<NavItemDef> {
|
||||
val slots = getSlots(prefs).toSet()
|
||||
return ALL_SWAPPABLE.filter { it.id !in slots }
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.Menu
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
@@ -16,7 +18,14 @@ class NavMoreSheetFragment : BottomSheetDialogFragment() {
|
||||
inflater.inflate(R.layout.sheet_nav_more, container, false)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
view.findViewById<NavigationView>(R.id.navMoreView).setNavigationItemSelectedListener { item ->
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
val items = NavCustomization.getMoreItems(prefs)
|
||||
val navView = view.findViewById<NavigationView>(R.id.navMoreView)
|
||||
navView.menu.clear()
|
||||
items.forEachIndexed { i, item ->
|
||||
navView.menu.add(Menu.NONE, item.id, i, item.titleRes).setIcon(item.iconRes)
|
||||
}
|
||||
navView.setNavigationItemSelectedListener { item ->
|
||||
dismiss()
|
||||
onNavigate?.invoke(item.itemId)
|
||||
true
|
||||
|
||||
@@ -14,7 +14,8 @@ import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||
import sh.sar.basedbank.api.mib.MibProfileClient
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
import sh.sar.basedbank.databinding.FragmentOtpBinding
|
||||
import sh.sar.basedbank.databinding.ItemOtpCardBinding
|
||||
@@ -71,8 +72,9 @@ class OtpFragment : Fragment() {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
|
||||
val entries = mutableListOf<OtpEntry>()
|
||||
store.loadMibCredentials()?.let { creds ->
|
||||
val name = store.loadMibFullName()
|
||||
for (loginId in store.getMibLoginIds()) {
|
||||
val creds = store.loadMibCredentials(loginId) ?: continue
|
||||
val name = store.loadMibFullName(loginId)
|
||||
entries.add(OtpEntry(if (name != null) "MIB · $name" else "MIB", creds.otpSeed))
|
||||
}
|
||||
for (loginId in store.getBmlLoginIds()) {
|
||||
@@ -88,20 +90,23 @@ class OtpFragment : Fragment() {
|
||||
// Fetch real names in background if not yet cached, then refresh labels
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
var changed = false
|
||||
if (store.loadMibFullName() == null) {
|
||||
app.mibSession?.let { session ->
|
||||
for (loginId in store.getMibLoginIds()) {
|
||||
if (store.loadMibFullName(loginId) == null) {
|
||||
val session = app.mibSessions[loginId] ?: continue
|
||||
val flow = app.mibFlowFor(loginId)
|
||||
val profile = withContext(Dispatchers.IO) {
|
||||
try { app.mibLoginFlow.fetchPersonalProfile(session) } catch (_: Exception) { null }
|
||||
try { MibProfileClient().fetchPersonalProfile(session) } catch (_: Exception) { null }
|
||||
}
|
||||
if (profile != null) {
|
||||
store.saveMibUserProfile(CredentialStore.MibUserProfile(
|
||||
store.saveMibUserProfile(loginId, CredentialStore.MibUserProfile(
|
||||
fullName = profile.fullName,
|
||||
username = profile.username,
|
||||
email = profile.email,
|
||||
mobile = profile.mobile,
|
||||
enrolled = profile.enrolled
|
||||
))
|
||||
val idx = entries.indexOfFirst { it.seed == store.loadMibCredentials()?.otpSeed }
|
||||
val seed = store.loadMibCredentials(loginId)?.otpSeed
|
||||
val idx = entries.indexOfFirst { it.seed == seed }
|
||||
if (idx >= 0) { entries[idx] = entries[idx].copy(label = "MIB · ${profile.fullName}"); changed = true }
|
||||
}
|
||||
}
|
||||
@@ -110,7 +115,7 @@ class OtpFragment : Fragment() {
|
||||
if (store.loadBmlUserProfile(loginId)?.fullName.isNullOrBlank()) {
|
||||
val session = app.bmlSessions[loginId] ?: continue
|
||||
val info = withContext(Dispatchers.IO) {
|
||||
try { BmlLoginFlow().fetchUserInfo(session) } catch (_: Exception) { null }
|
||||
try { BmlAccountClient().fetchUserInfo(session) } catch (_: Exception) { null }
|
||||
}
|
||||
if (info != null) {
|
||||
store.saveBmlUserProfile(loginId, CredentialStore.BmlUserProfile(
|
||||
|
||||
531
app/src/main/java/sh/sar/basedbank/ui/home/PayMvQrFragment.kt
Normal file
@@ -0,0 +1,531 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.*
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.updatePadding
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.zxing.BarcodeFormat
|
||||
import com.google.zxing.EncodeHintType
|
||||
import com.google.zxing.qrcode.QRCodeWriter
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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.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
|
||||
|
||||
class PayMvQrFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentPayMvQrBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
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
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
|
||||
raw.startsWith("https://pay.bml.com.mv/app/")) {
|
||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw))
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
val qr = PaymvQrParser.parse(raw)
|
||||
if (qr == null || qr.accountNumber == null) {
|
||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||
return@registerForActivityResult
|
||||
}
|
||||
val activity = requireActivity() as HomeActivity
|
||||
activity.navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromQr(
|
||||
accountNumber = qr.accountNumber,
|
||||
displayName = qr.merchantName ?: qr.accountNumber,
|
||||
amount = qr.amount,
|
||||
remarks = qr.purpose
|
||||
))
|
||||
}
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = FragmentPayMvQrBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val basePaddingBottom = view.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
||||
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.btnShare.isEnabled = false
|
||||
binding.btnSave.isEnabled = false
|
||||
binding.btnShare.setOnClickListener { shareQr() }
|
||||
binding.btnSave.setOnClickListener { saveQr() }
|
||||
binding.btnScanQr.setOnClickListener {
|
||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupDropdown() {
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||
val eligible = accounts.filter {
|
||||
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN"
|
||||
}
|
||||
val adapter = QrAccountAdapter(requireContext(), eligible)
|
||||
binding.actvAccount.setAdapter(adapter)
|
||||
binding.actvAccount.setOnItemClickListener { _, _, position, _ ->
|
||||
val picked = adapter.getAccount(position) ?: return@setOnItemClickListener
|
||||
selectedAccount = picked
|
||||
scheduleGenerate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleGenerate() {
|
||||
generateJob?.cancel()
|
||||
generateJob = viewLifecycleOwner.lifecycleScope.launch {
|
||||
delay(300)
|
||||
generateQr()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun generateQr() {
|
||||
val account = selectedAccount ?: return
|
||||
val acquirer = when (account.bank) {
|
||||
"BML" -> "MALBMVMV"
|
||||
"MIB" -> "MADVMVMV"
|
||||
"FAHIPAY" -> "FAHIMVMV"
|
||||
else -> "MADVMVMV"
|
||||
}
|
||||
val amountFormatted = binding.etAmount.text?.toString()?.trim()
|
||||
?.replace(",", "")
|
||||
?.toDoubleOrNull()
|
||||
?.takeIf { it > 0 }
|
||||
?.let { "%.2f".format(it) }
|
||||
|
||||
val ctx = requireContext()
|
||||
val bmp = withContext(Dispatchers.Default) {
|
||||
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted)
|
||||
renderQrCard(ctx, account, payload, amountFormatted)
|
||||
}
|
||||
if (_binding == null) return
|
||||
generatedBitmap = bmp
|
||||
binding.tvQrPlaceholder.visibility = View.GONE
|
||||
binding.ivQrCard.setImageBitmap(bmp)
|
||||
binding.ivQrCard.visibility = View.VISIBLE
|
||||
binding.btnShare.isEnabled = true
|
||||
binding.btnSave.isEnabled = true
|
||||
}
|
||||
|
||||
// ── EMV MPQR payload ──────────────────────────────────────────────────────
|
||||
|
||||
private fun buildQrPayload(
|
||||
accountNumber: String,
|
||||
accountName: String,
|
||||
acquirer: String,
|
||||
amountStr: String?
|
||||
): String {
|
||||
fun tlv(tag: String, value: String): String {
|
||||
val len = value.length
|
||||
return tag + (if (len < 10) "0$len" else "$len") + value
|
||||
}
|
||||
val format = tlv("00", "01")
|
||||
val poi = tlv("01", "11")
|
||||
val sub00 = tlv("00", "mv.favara.mpqr")
|
||||
val sub01 = tlv("01", acquirer)
|
||||
val sub03 = tlv("03", accountNumber)
|
||||
val sub10 = tlv("10", "IPAY")
|
||||
val merchantAcct = tlv("26", sub00 + sub01 + sub03 + sub10)
|
||||
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"
|
||||
return prefix + crc16(prefix)
|
||||
}
|
||||
|
||||
private fun crc16(data: String): String {
|
||||
var crc = 0xFFFF
|
||||
for (c in data) {
|
||||
crc = crc xor ((c.code and 0xFF) shl 8)
|
||||
repeat(8) {
|
||||
crc = if (crc and 0x8000 != 0) ((crc shl 1) and 0xFFFF) xor 0x1021
|
||||
else (crc shl 1) and 0xFFFF
|
||||
}
|
||||
}
|
||||
return crc.toString(16).uppercase().padStart(4, '0')
|
||||
}
|
||||
|
||||
// ── QR card rendering ────────────────────────────────────────────────────
|
||||
|
||||
private fun renderQrCard(
|
||||
ctx: Context,
|
||||
account: BankAccount,
|
||||
qrPayload: String,
|
||||
amountStr: String?
|
||||
): Bitmap {
|
||||
val W = 900
|
||||
val H = 1080
|
||||
val outerCorner = 48f
|
||||
val boxBlue = Color.parseColor("#2272B7")
|
||||
val footerBlue = Color.parseColor("#1A5799")
|
||||
val boxL = 24f; val boxT = 110f; val boxR = 876f; val boxB = 962f
|
||||
|
||||
val bm = Bitmap.createBitmap(W, H, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bm)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
// Clip to outer rounded card shape
|
||||
val outerPath = Path()
|
||||
outerPath.addRoundRect(RectF(0f, 0f, W.toFloat(), H.toFloat()), outerCorner, outerCorner, Path.Direction.CW)
|
||||
canvas.clipPath(outerPath)
|
||||
canvas.drawColor(Color.WHITE)
|
||||
|
||||
// --- Bank logo top-left ---
|
||||
val logoRes = when (account.bank) {
|
||||
"BML" -> R.drawable.bml_logo_vector
|
||||
"MIB" -> R.drawable.mib_faisanet_logo
|
||||
else -> R.drawable.fahipay_logo_long
|
||||
}
|
||||
AppCompatResources.getDrawable(ctx, logoRes)?.let { d ->
|
||||
val nW = d.intrinsicWidth.coerceAtLeast(1)
|
||||
val nH = d.intrinsicHeight.coerceAtLeast(1)
|
||||
val maxW = 180f; val maxH = 76f
|
||||
val scale = minOf(maxW / nW, maxH / nH)
|
||||
val lW = (nW * scale).toInt()
|
||||
val lH = (nH * scale).toInt()
|
||||
val lTop = ((boxT - lH) / 2).toInt().coerceAtLeast(10)
|
||||
d.setBounds(24, lTop, 24 + lW, lTop + lH)
|
||||
d.draw(canvas)
|
||||
}
|
||||
|
||||
// --- "PayMV QR" top-right ---
|
||||
paint.color = Color.parseColor("#1A1A2E")
|
||||
paint.textSize = 36f
|
||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
paint.textAlign = Paint.Align.RIGHT
|
||||
canvas.drawText("PayMV QR", W - 28f, 66f, paint)
|
||||
|
||||
// --- Blue rounded box ---
|
||||
paint.color = boxBlue
|
||||
paint.textAlign = Paint.Align.LEFT
|
||||
canvas.drawRoundRect(RectF(boxL, boxT, boxR, boxB), 36f, 36f, paint)
|
||||
|
||||
// Account name (white, bold, uppercase, auto-scaled to fit)
|
||||
paint.color = Color.WHITE
|
||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
paint.textAlign = Paint.Align.CENTER
|
||||
val nameText = account.accountBriefName.uppercase()
|
||||
paint.textSize = 36f
|
||||
val maxNameW = boxR - boxL - 48f
|
||||
if (paint.measureText(nameText) > maxNameW) {
|
||||
paint.textSize = 36f * maxNameW / paint.measureText(nameText)
|
||||
}
|
||||
val nameBaseline = boxT + 68f
|
||||
canvas.drawText(nameText, W / 2f, nameBaseline, paint)
|
||||
|
||||
// Optional amount below name
|
||||
val qrTopY: Float
|
||||
if (!amountStr.isNullOrBlank()) {
|
||||
paint.textSize = 28f
|
||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.NORMAL)
|
||||
val amtBaseline = nameBaseline + 42f
|
||||
canvas.drawText("MVR $amountStr", W / 2f, amtBaseline, paint)
|
||||
qrTopY = amtBaseline + 20f
|
||||
} else {
|
||||
qrTopY = nameBaseline + 26f
|
||||
}
|
||||
|
||||
// QR code — white modules on the same blue as the box background
|
||||
val availH = boxB - qrTopY - 24f
|
||||
val qrPx = minOf(availH, boxR - boxL - 48f).toInt().coerceAtMost(700).coerceAtLeast(200)
|
||||
val qrLeft = ((W - qrPx) / 2).toFloat()
|
||||
try {
|
||||
val hints = mapOf(
|
||||
EncodeHintType.MARGIN to 0,
|
||||
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M
|
||||
)
|
||||
val matrix = QRCodeWriter().encode(qrPayload, BarcodeFormat.QR_CODE, qrPx, qrPx, hints)
|
||||
val pixels = IntArray(qrPx * qrPx)
|
||||
for (y in 0 until qrPx) {
|
||||
for (x in 0 until qrPx) {
|
||||
pixels[y * qrPx + x] = if (matrix[x, y]) Color.WHITE else boxBlue
|
||||
}
|
||||
}
|
||||
val qrBm = Bitmap.createBitmap(pixels, qrPx, qrPx, Bitmap.Config.ARGB_8888)
|
||||
canvas.drawBitmap(qrBm, qrLeft, qrTopY, null)
|
||||
qrBm.recycle()
|
||||
} catch (_: Exception) { /* skip if encoding fails */ }
|
||||
|
||||
// --- Dark blue footer ---
|
||||
paint.color = footerBlue
|
||||
paint.textAlign = Paint.Align.LEFT
|
||||
canvas.drawRect(RectF(0f, 970f, W.toFloat(), H.toFloat()), paint)
|
||||
paint.color = Color.WHITE
|
||||
paint.textSize = 32f
|
||||
paint.typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
|
||||
paint.textAlign = Paint.Align.CENTER
|
||||
canvas.drawText("MALDIVES NATIONAL QR", W / 2f, 1038f, paint)
|
||||
|
||||
return bm
|
||||
}
|
||||
|
||||
// ── Share / Save ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun shareQr() {
|
||||
val bmp = generatedBitmap ?: return
|
||||
val account = selectedAccount ?: return
|
||||
lifecycleScope.launch {
|
||||
val uri = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val dir = File(requireContext().cacheDir, "qr")
|
||||
dir.mkdirs()
|
||||
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
|
||||
val file = File(dir, "${safeName}_paymv_qr.png")
|
||||
FileOutputStream(file).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
FileProvider.getUriForFile(
|
||||
requireContext(),
|
||||
"${requireContext().packageName}.fileprovider",
|
||||
file
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
if (uri == null || _binding == null) return@launch
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
|
||||
type = "image/png"
|
||||
putExtra(android.content.Intent.EXTRA_STREAM, uri)
|
||||
addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
startActivity(android.content.Intent.createChooser(intent, getString(R.string.paymvqr_share)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveQr() {
|
||||
val bmp = generatedBitmap ?: return
|
||||
val account = selectedAccount ?: return
|
||||
lifecycleScope.launch {
|
||||
val saved = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val safeName = account.accountBriefName.replace(Regex("[^A-Za-z0-9_]"), "_")
|
||||
val filename = "${safeName}_PayMV_QR.png"
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Images.Media.DISPLAY_NAME, filename)
|
||||
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
|
||||
put(MediaStore.Images.Media.RELATIVE_PATH, Environment.DIRECTORY_PICTURES)
|
||||
}
|
||||
val uri = requireContext().contentResolver.insert(
|
||||
MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values
|
||||
) ?: return@withContext false
|
||||
requireContext().contentResolver.openOutputStream(uri)?.use {
|
||||
bmp.compress(Bitmap.CompressFormat.PNG, 100, it)
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
|
||||
dir.mkdirs()
|
||||
FileOutputStream(File(dir, filename)).use { bmp.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||
}
|
||||
true
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
if (_binding == null) return@launch
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
if (saved) R.string.paymvqr_saved else R.string.paymvqr_save_failed,
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = getString(R.string.pay_mv_qr)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
// ── Account dropdown adapter ──────────────────────────────────────────────
|
||||
|
||||
private inner class QrAccountAdapter(
|
||||
private val context: Context,
|
||||
private val accounts: List<BankAccount>
|
||||
) : BaseAdapter(), Filterable {
|
||||
|
||||
fun getAccount(position: Int): BankAccount? = accounts.getOrNull(position)
|
||||
|
||||
override fun getCount() = accounts.size
|
||||
override fun getItem(position: Int) = accounts.getOrNull(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.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
|
||||
if (typeLabel.isNotBlank()) {
|
||||
b.tvDropdownAccountType.text = typeLabel
|
||||
b.tvDropdownAccountType.visibility = View.VISIBLE
|
||||
} else {
|
||||
b.tvDropdownAccountType.visibility = View.GONE
|
||||
}
|
||||
b.tvDropdownBalance.text = displayData?.balance ?: ""
|
||||
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
|
||||
}
|
||||
|
||||
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.bank == "BML" && it.profileName.isNotBlank()) "${it.profileName} · " else ""
|
||||
"$prefix${it.accountBriefName}"
|
||||
} ?: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
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.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibCard
|
||||
import sh.sar.basedbank.databinding.FragmentPayWithCardBinding
|
||||
import sh.sar.basedbank.util.CardsCache
|
||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||
|
||||
class PayWithCardFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentPayWithCardBinding? = null
|
||||
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
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
|
||||
raw.startsWith("https://pay.bml.com.mv/app/")) {
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, 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 = FragmentPayWithCardBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val adapter = CardWalletAdapter(emptyList(), requireContext())
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||
insets
|
||||
}
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
}
|
||||
|
||||
val updateCardList = {
|
||||
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
|
||||
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
||||
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) }
|
||||
.map { CardItem.Bml(it) }
|
||||
val all = mibItems + bmlItems
|
||||
adapter.update(all)
|
||||
binding.loadingView.visibility = View.GONE
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
binding.emptyView.visibility = if (all.isEmpty()) View.VISIBLE else View.GONE
|
||||
binding.recyclerView.visibility = if (all.isEmpty()) View.GONE else View.VISIBLE
|
||||
}
|
||||
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
|
||||
|
||||
val cached = CardsCache.load(requireContext())
|
||||
if (cached.isNotEmpty()) {
|
||||
viewModel.mibCards.value = cached
|
||||
} else {
|
||||
binding.loadingView.visibility = View.VISIBLE
|
||||
}
|
||||
(activity as? HomeActivity)?.triggerRefreshCards()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = getString(R.string.nav_pay_with_card)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private inner class CardWalletAdapter(
|
||||
private var cards: List<CardItem>,
|
||||
private val context: Context
|
||||
) : RecyclerView.Adapter<CardWalletAdapter.VH>() {
|
||||
|
||||
fun update(newCards: List<CardItem>) {
|
||||
cards = newCards
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH =
|
||||
VH(LayoutInflater.from(context).inflate(R.layout.item_card_wallet, parent, false))
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) = holder.bind(cards[position])
|
||||
override fun getItemCount() = cards.size
|
||||
|
||||
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||
private val ivCardImage: ImageView = view.findViewById(R.id.ivCardImage)
|
||||
private val tvCardOwner: TextView = view.findViewById(R.id.tvCardOwner)
|
||||
private val tvCardNumber: TextView = view.findViewById(R.id.tvCardNumber)
|
||||
private val tvCardType: TextView = view.findViewById(R.id.tvCardType)
|
||||
private val tvCardStatus: TextView = view.findViewById(R.id.tvCardStatus)
|
||||
private val 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 = formatMasked(item.card.maskedCardNumber)
|
||||
tvCardType.text = item.card.cardTypeDesc
|
||||
val assetPath = cardImageAsset(item.card)
|
||||
if (assetPath != null) loadCardImage(ivCardImage, assetPath)
|
||||
else ivCardImage.setImageDrawable(null)
|
||||
bindCardStatus(tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
|
||||
}
|
||||
is CardItem.Bml -> {
|
||||
tvCardOwner.text = item.account.accountBriefName
|
||||
tvCardNumber.text = formatMasked(item.account.accountNumber)
|
||||
tvCardType.text = item.account.accountTypeName
|
||||
loadCardImage(ivCardImage, BmlCardParser.cardImageAsset(item.account))
|
||||
val bmlStatus = item.account.statusDesc.takeUnless { it.equals("Active", ignoreCase = true) }
|
||||
bindCardStatus(tvCardStatus, bmlStatus)
|
||||
}
|
||||
}
|
||||
val isMib = item is CardItem.Mib
|
||||
btnPayQr.setOnClickListener {
|
||||
if (isMib) {
|
||||
Toast.makeText(context, 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(context)
|
||||
val nfcSupported = nfcAdapter != null
|
||||
btnPayNfc.isEnabled = nfcSupported
|
||||
btnPayNfc.setOnClickListener {
|
||||
val msg = if (isMib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
|
||||
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun cardImageAsset(card: MibCard): String? = when (card.cardType) {
|
||||
"53" -> "cards/mib/visa_black_platinum.png"
|
||||
"57" -> "cards/mib/visa_blue_everyday.png"
|
||||
"70" -> "cards/mib/visa_business.png"
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun loadCardImage(imageView: ImageView, assetPath: String) {
|
||||
try {
|
||||
val bitmap = imageView.context.assets.open(assetPath).use {
|
||||
BitmapFactory.decodeStream(it)
|
||||
}
|
||||
imageView.setImageBitmap(bitmap)
|
||||
} catch (_: Exception) {
|
||||
imageView.setImageDrawable(null)
|
||||
}
|
||||
}
|
||||
|
||||
fun mibCardStatusLabel(cardStatus: String): String? = when (cardStatus) {
|
||||
"CHST0" -> null // Active — no badge
|
||||
else -> cardStatus
|
||||
}
|
||||
|
||||
fun bindCardStatus(tv: TextView, statusLabel: String?) {
|
||||
if (statusLabel == null) { tv.visibility = View.GONE; return }
|
||||
tv.visibility = View.VISIBLE
|
||||
tv.text = statusLabel
|
||||
val dp = tv.context.resources.displayMetrics.density
|
||||
tv.background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.RECTANGLE
|
||||
cornerRadius = 12 * dp
|
||||
setColor(0xCC212121.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
fun formatMasked(masked: String): String {
|
||||
if (masked.length < 4) return masked
|
||||
return "\u2022\u2022\u2022\u2022 ${masked.takeLast(4)}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,16 @@ 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
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.camera.core.ImageAnalysis
|
||||
import androidx.camera.core.Preview
|
||||
import androidx.camera.core.resolutionselector.AspectRatioStrategy
|
||||
@@ -49,6 +58,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(
|
||||
@@ -84,10 +95,53 @@ class QrScannerActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
binding = ActivityQrScannerBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
binding.btnCancel.setOnClickListener { finish() }
|
||||
// Black camera background — always use light (white) system bar icons
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
isAppearanceLightStatusBars = false
|
||||
isAppearanceLightNavigationBars = false
|
||||
}
|
||||
val originalBtnMarginBottom = (48 * resources.displayMetrics.density).toInt()
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.btnContainer) { view, insets ->
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
(view.layoutParams as android.widget.FrameLayout.LayoutParams).also {
|
||||
it.bottomMargin = originalBtnMarginBottom + bars.bottom
|
||||
view.layoutParams = it
|
||||
}
|
||||
insets
|
||||
}
|
||||
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
|
||||
@@ -161,9 +215,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()
|
||||
}
|
||||
|
||||
@@ -1,29 +1,45 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentSettingsAppearanceBinding
|
||||
import java.util.Collections
|
||||
|
||||
class SettingsAppearanceFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentSettingsAppearanceBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private lateinit var prefs: SharedPreferences
|
||||
private val slots = mutableListOf<Int>()
|
||||
private val quickActions = mutableListOf<Int>()
|
||||
private lateinit var slotAdapter: NavItemAdapter
|
||||
private lateinit var quickActionAdapter: NavItemAdapter
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
|
||||
// Navigation mode
|
||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||
@@ -32,8 +48,35 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
if (!isChecked) return@addOnButtonCheckedListener
|
||||
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
|
||||
(activity as? HomeActivity)?.applyNavMode()
|
||||
updateShortcutsVisibility()
|
||||
}
|
||||
|
||||
// Quick actions
|
||||
quickActions.clear()
|
||||
quickActions.addAll(NavCustomization.getQuickActions(prefs))
|
||||
quickActionAdapter = NavItemAdapter(quickActions) {
|
||||
NavCustomization.saveQuickActions(prefs, quickActions)
|
||||
}
|
||||
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)
|
||||
}
|
||||
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots)
|
||||
// Show labels toggle
|
||||
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
|
||||
binding.switchShowLabels.isChecked = showLabels
|
||||
binding.switchShowLabels.setOnCheckedChangeListener { _, checked ->
|
||||
prefs.edit().putBoolean("bottom_nav_show_labels", checked).apply()
|
||||
(activity as? HomeActivity)?.applyNavLabelVisibility()
|
||||
}
|
||||
|
||||
updateShortcutsVisibility()
|
||||
|
||||
// Theme
|
||||
val saved = prefs.getString("theme", "system")
|
||||
binding.themeToggle.check(when (saved) {
|
||||
@@ -63,6 +106,95 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupNavItemRecyclerView(
|
||||
rv: RecyclerView,
|
||||
adapter: NavItemAdapter,
|
||||
items: MutableList<Int>
|
||||
) {
|
||||
rv.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
||||
rv.adapter = adapter
|
||||
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
|
||||
ItemTouchHelper.START or ItemTouchHelper.END, 0
|
||||
) {
|
||||
override fun onMove(
|
||||
rv: RecyclerView,
|
||||
from: RecyclerView.ViewHolder,
|
||||
to: RecyclerView.ViewHolder
|
||||
): Boolean {
|
||||
val fromPos = from.adapterPosition
|
||||
val toPos = to.adapterPosition
|
||||
Collections.swap(items, fromPos, toPos)
|
||||
adapter.notifyItemMoved(fromPos, toPos)
|
||||
adapter.onSave()
|
||||
return true
|
||||
}
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {}
|
||||
}).attachToRecyclerView(rv)
|
||||
}
|
||||
|
||||
private fun updateShortcutsVisibility() {
|
||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||
binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
|
||||
}
|
||||
|
||||
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
|
||||
if (items === slots && !prefs.getBoolean("bottom_nav", false)) return
|
||||
val ctx = requireContext()
|
||||
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
|
||||
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
|
||||
val listLayout = LinearLayout(ctx).apply { orientation = LinearLayout.VERTICAL }
|
||||
val rows = available.map { item ->
|
||||
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)
|
||||
listLayout.addView(row)
|
||||
}
|
||||
}
|
||||
val scroll = ScrollView(ctx).apply { addView(listLayout) }
|
||||
var dialog: androidx.appcompat.app.AlertDialog? = null
|
||||
dialog = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(R.string.settings_bottom_bar_select)
|
||||
.setView(scroll)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
available.forEachIndexed { i, item ->
|
||||
rows[i].setOnClickListener {
|
||||
items[slotIndex] = item.id
|
||||
adapter.onSave()
|
||||
adapter.notifyItemChanged(slotIndex)
|
||||
dialog?.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class NavItemAdapter(
|
||||
val items: MutableList<Int>,
|
||||
val onSave: () -> Unit
|
||||
) : RecyclerView.Adapter<NavItemAdapter.VH>() {
|
||||
|
||||
inner class VH(view: View) : RecyclerView.ViewHolder(view) {
|
||||
val ivNavIcon: ImageView = view.findViewById(R.id.ivNavIcon)
|
||||
val tvNavLabel: TextView = view.findViewById(R.id.tvNavLabel)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val view = LayoutInflater.from(parent.context).inflate(R.layout.item_nav_slot, parent, false)
|
||||
val itemWidth = if (parent.measuredWidth > 0) parent.measuredWidth / items.size
|
||||
else RecyclerView.LayoutParams.WRAP_CONTENT
|
||||
view.layoutParams = RecyclerView.LayoutParams(itemWidth, RecyclerView.LayoutParams.WRAP_CONTENT)
|
||||
return VH(view)
|
||||
}
|
||||
|
||||
override fun getItemCount() = items.size
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = getString(R.string.settings_appearance)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -31,12 +31,19 @@ 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
|
||||
if (canUseBiometrics) {
|
||||
binding.rowBiometrics.visibility = View.VISIBLE
|
||||
|
||||
val unlockEnabled = prefs.getBoolean("biometrics_enabled", false)
|
||||
binding.switchBiometrics.isChecked = unlockEnabled
|
||||
binding.switchBiometricsTransfer.isChecked = prefs.getBoolean("biometrics_transfer_confirm", false)
|
||||
@@ -54,11 +61,14 @@ class SettingsSecurityFragment : Fragment() {
|
||||
binding.switchBiometricsTransfer.setOnCheckedChangeListener { _, isChecked ->
|
||||
prefs.edit().putBoolean("biometrics_transfer_confirm", isChecked).apply()
|
||||
}
|
||||
} else {
|
||||
binding.tvBiometricsHint.visibility = View.VISIBLE
|
||||
binding.switchBiometrics.isEnabled = false
|
||||
binding.switchBiometricsTransfer.isEnabled = false
|
||||
}
|
||||
|
||||
// Auto-lock
|
||||
binding.autolockToggle.check(when (prefs.getLong("autolock_timeout", 60_000L)) {
|
||||
0L -> R.id.btnAutolockOff
|
||||
30_000L -> R.id.btnAutolock30s
|
||||
180_000L -> R.id.btnAutolock3m
|
||||
300_000L -> R.id.btnAutolock5m
|
||||
@@ -67,7 +77,6 @@ class SettingsSecurityFragment : Fragment() {
|
||||
binding.autolockToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||
if (!isChecked) return@addOnButtonCheckedListener
|
||||
val timeout = when (checkedId) {
|
||||
R.id.btnAutolockOff -> 0L
|
||||
R.id.btnAutolock30s -> 30_000L
|
||||
R.id.btnAutolock3m -> 180_000L
|
||||
R.id.btnAutolock5m -> 300_000L
|
||||
@@ -77,6 +86,17 @@ 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,17 @@ import android.view.ViewGroup
|
||||
import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import sh.sar.basedbank.api.mib.Transaction
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import sh.sar.basedbank.databinding.ItemDateHeaderBinding
|
||||
import sh.sar.basedbank.databinding.ItemLoadingFooterBinding
|
||||
import sh.sar.basedbank.databinding.ItemTransactionBinding
|
||||
|
||||
/** Adapter for Transfer History — date-grouped, shows account name in secondary line. */
|
||||
/** Adapter for BankTransaction History — date-grouped, shows account name in secondary line. */
|
||||
class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private sealed class Item {
|
||||
data class DateHeader(val label: String) : Item()
|
||||
data class Trx(val transaction: Transaction) : Item()
|
||||
data class Trx(val transaction: BankTransaction) : Item()
|
||||
}
|
||||
|
||||
private val displayItems = mutableListOf<Item>()
|
||||
@@ -27,6 +27,13 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
private val iconUrlCache = mutableMapOf<String, Bitmap>()
|
||||
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
||||
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
||||
private var hideAmounts: Boolean = false
|
||||
|
||||
fun setHideAmounts(hide: Boolean) {
|
||||
if (hideAmounts == hide) return
|
||||
hideAmounts = hide
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun updateImage(counterpartyName: String, bitmap: Bitmap) {
|
||||
imageCache[counterpartyName] = bitmap
|
||||
@@ -60,7 +67,7 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
}
|
||||
|
||||
/** Replace the full sorted transaction list and rebuild date groups. */
|
||||
fun setTransactions(transactions: List<Transaction>) {
|
||||
fun setTransactions(transactions: List<BankTransaction>) {
|
||||
_showLoadingFooter = false
|
||||
displayItems.clear()
|
||||
var lastDateKey = ""
|
||||
@@ -107,7 +114,7 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
inner class TransactionVH(private val b: ItemTransactionBinding) :
|
||||
RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(trx: Transaction) {
|
||||
fun bind(trx: BankTransaction) {
|
||||
val isCredit = trx.amount >= 0
|
||||
val color = AccountHistoryAdapter.sourceColor(trx.source)
|
||||
val name = trx.counterpartyName ?: trx.description
|
||||
@@ -134,23 +141,28 @@ class TransactionAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
}
|
||||
b.tvDescription.text = trx.description
|
||||
|
||||
// Show account name in secondary line for Transfer History
|
||||
// Show account name in secondary line for BankTransaction History
|
||||
b.tvCounterparty.text = trx.accountDisplayName
|
||||
b.tvCounterparty.visibility = View.VISIBLE
|
||||
|
||||
b.tvDate.text = AccountHistoryAdapter.formatTime(trx.date)
|
||||
|
||||
val sign = if (isCredit) "+" else "-"
|
||||
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
||||
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
||||
b.tvAmount.setTextColor(
|
||||
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
||||
)
|
||||
if (hideAmounts) {
|
||||
b.tvAmount.text = "${trx.currency} ••••••"
|
||||
b.tvAmount.setTextColor(Color.parseColor("#888888"))
|
||||
} else {
|
||||
val sign = if (isCredit) "+" else "-"
|
||||
val absAmt = "%.2f".format(kotlin.math.abs(trx.amount))
|
||||
b.tvAmount.text = "$sign ${trx.currency} $absAmt"
|
||||
b.tvAmount.setTextColor(
|
||||
if (isCredit) Color.parseColor("#4CAF50") else Color.parseColor("#FF7043")
|
||||
)
|
||||
}
|
||||
|
||||
b.root.setOnClickListener { showDetail(trx) }
|
||||
}
|
||||
|
||||
private fun showDetail(trx: Transaction) {
|
||||
private fun showDetail(trx: BankTransaction) {
|
||||
val ctx = b.root.context
|
||||
val title = trx.counterpartyName?.takeIf { it.isNotBlank() } ?: trx.description
|
||||
val details = buildString {
|
||||
|
||||
@@ -10,6 +10,8 @@ import android.util.Base64
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
@@ -23,12 +25,13 @@ import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibAccount
|
||||
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
||||
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.mib.Transaction
|
||||
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
|
||||
import sh.sar.basedbank.util.ContactImageCache
|
||||
@@ -46,12 +49,12 @@ class TransferHistoryFragment : Fragment() {
|
||||
private lateinit var adapter: TransactionAdapter
|
||||
|
||||
// All merged transactions sorted by date desc
|
||||
private val allTransactions = mutableListOf<Transaction>()
|
||||
private val allTransactions = mutableListOf<BankTransaction>()
|
||||
private var searchQuery = ""
|
||||
|
||||
// Per-account pagination state
|
||||
private data class AccountState(
|
||||
val account: MibAccount,
|
||||
val account: BankAccount,
|
||||
var mibNextStart: Int = 1,
|
||||
var mibTotalCount: Int = -1,
|
||||
var bmlNextPage: Int = 1,
|
||||
@@ -61,10 +64,10 @@ class TransferHistoryFragment : Fragment() {
|
||||
var fahipayTotal: Int = -1
|
||||
) {
|
||||
fun hasMore(): Boolean = when {
|
||||
account.profileType == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" -> cardMonthOffset < 2
|
||||
account.profileType.startsWith("BML") -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||
account.bank == "FAHIPAY" -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,9 +87,20 @@ class TransferHistoryFragment : Fragment() {
|
||||
adapter = TransactionAdapter()
|
||||
adapter.onImageNeeded = { name -> loadContactImage(name) }
|
||||
adapter.onIconUrlNeeded = { url -> loadMerchantIcon(url) }
|
||||
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.recyclerView) { v, insets ->
|
||||
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||
insets
|
||||
}
|
||||
|
||||
binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
|
||||
if (dy <= 0 || isLoading) return
|
||||
@@ -125,6 +139,14 @@ class TransferHistoryFragment : Fragment() {
|
||||
}
|
||||
(activity as? HomeActivity)?.setRefreshing(true)
|
||||
loadNextPages()
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
if (isLoading) {
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
} else {
|
||||
resetAndReload()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -132,6 +154,26 @@ class TransferHistoryFragment : Fragment() {
|
||||
requireActivity().title = getString(R.string.nav_transfer_history)
|
||||
}
|
||||
|
||||
private fun resetAndReload() {
|
||||
allTransactions.clear()
|
||||
pendingImageNames.clear()
|
||||
pendingIconUrls.clear()
|
||||
firstBatchDone = false
|
||||
val accounts = accountStates.map { it.account }
|
||||
accountStates.clear()
|
||||
accounts.forEach { accountStates.add(AccountState(it)) }
|
||||
// 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()
|
||||
}
|
||||
|
||||
private fun loadNextPages() {
|
||||
val activeStates = accountStates.filter { it.hasMore() }
|
||||
if (isLoading || activeStates.isEmpty()) return
|
||||
@@ -142,25 +184,32 @@ class TransferHistoryFragment : Fragment() {
|
||||
}
|
||||
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val mibSession = app.mibSession
|
||||
|
||||
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<Transaction>()
|
||||
val results = mutableListOf<BankTransaction>()
|
||||
|
||||
// BML accounts: fetch in parallel
|
||||
val bmlStates = activeStates.filter { it.account.profileType.startsWith("BML") }
|
||||
val bmlStates = activeStates.filter { it.account.bank == "BML" }
|
||||
results.addAll(bmlStates.map { state ->
|
||||
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)
|
||||
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
||||
state.cardMonthOffset++
|
||||
BmlLoginFlow().fetchCardHistory(
|
||||
BmlHistoryClient().fetchCardHistory(
|
||||
session = session,
|
||||
cardId = state.account.internalId,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
@@ -170,7 +219,7 @@ class TransferHistoryFragment : Fragment() {
|
||||
}
|
||||
else -> {
|
||||
val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
|
||||
val (list, totalPages) = BmlLoginFlow().fetchAccountHistory(
|
||||
val (list, totalPages) = BmlHistoryClient().fetchAccountHistory(
|
||||
session = session,
|
||||
accountId = state.account.internalId,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
@@ -182,18 +231,16 @@ class TransferHistoryFragment : Fragment() {
|
||||
list
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { emptyList<Transaction>() }
|
||||
} catch (e: Exception) { trackError(e); emptyList<BankTransaction>() }
|
||||
}
|
||||
}.awaitAll().flatten())
|
||||
|
||||
// Fahipay accounts
|
||||
val fahipayStates = activeStates.filter { it.account.profileType == "FAHIPAY" }
|
||||
val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" }
|
||||
for (state in fahipayStates) {
|
||||
val session = app.fahipaySession ?: continue
|
||||
val session = app.fahipaySessionFor(state.account) ?: continue
|
||||
try {
|
||||
val flow = FahipayLoginFlow()
|
||||
flow.setSessionCookie(session.sessionCookie)
|
||||
val (list, total) = flow.fetchHistory(
|
||||
val (list, total) = FahipayHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
accountNumber = state.account.accountNumber,
|
||||
@@ -202,31 +249,34 @@ 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
|
||||
val mibStates = activeStates.filter {
|
||||
!it.account.profileType.startsWith("BML") && it.account.profileType != "FAHIPAY"
|
||||
}
|
||||
for ((profileId, states) in mibStates.groupBy { it.account.profileId }) {
|
||||
val session = mibSession ?: break
|
||||
app.mibMutex.withLock {
|
||||
val profile = app.mibProfiles.firstOrNull { it.profileId == profileId }
|
||||
if (profile != null) app.mibLoginFlow.switchProfile(session, profile)
|
||||
for (state in states) {
|
||||
val mibStates = activeStates.filter { it.account.bank == "MIB" }
|
||||
for ((loginId, loginStates) in mibStates.groupBy { it.account.loginTag.removePrefix("mib_") }) {
|
||||
val session = app.mibSessions[loginId] ?: continue
|
||||
for ((profileId, states) in loginStates.groupBy { it.account.profileId }) {
|
||||
app.mibMutex.withLock {
|
||||
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) {}
|
||||
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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -235,10 +285,21 @@ 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
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
}
|
||||
|
||||
if (newTransactions.isNotEmpty()) {
|
||||
@@ -280,7 +341,7 @@ class TransferHistoryFragment : Fragment() {
|
||||
return
|
||||
}
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val sess = app.mibSession ?: return
|
||||
val sess = app.anyMibSession() ?: return
|
||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val base64 = MibContactsClient().fetchProfileImageBase64(sess, hash) ?: return@launch
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
data class TransferReceiptData(
|
||||
val isMib: Boolean,
|
||||
val bank: String, // "MIB", "BML", etc.
|
||||
val amount: String,
|
||||
val currency: String,
|
||||
val fromLabel: String,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
@@ -46,7 +50,7 @@ class TransferReceiptFragment : Fragment() {
|
||||
private val receiptCard get() = _receiptCard!!
|
||||
|
||||
companion object {
|
||||
private const val ARG_IS_MIB = "is_mib"
|
||||
private const val ARG_BANK = "bank"
|
||||
private const val ARG_AMOUNT = "amount"
|
||||
private const val ARG_CURRENCY = "currency"
|
||||
private const val ARG_FROM_LABEL = "from_label"
|
||||
@@ -69,7 +73,7 @@ class TransferReceiptFragment : Fragment() {
|
||||
fun newInstance(data: TransferReceiptData, toAvatarBitmap: Bitmap?) = TransferReceiptFragment().apply {
|
||||
pendingToAvatarBitmap = toAvatarBitmap
|
||||
arguments = Bundle().apply {
|
||||
putBoolean(ARG_IS_MIB, data.isMib)
|
||||
putString(ARG_BANK, data.bank)
|
||||
putString(ARG_AMOUNT, data.amount)
|
||||
putString(ARG_CURRENCY, data.currency)
|
||||
putString(ARG_FROM_LABEL, data.fromLabel)
|
||||
@@ -90,8 +94,8 @@ class TransferReceiptFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val isMib = arguments?.getBoolean(ARG_IS_MIB, true) ?: true
|
||||
return if (isMib) {
|
||||
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
||||
return if (bank == "MIB") {
|
||||
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
|
||||
bindMib(binding)
|
||||
_receiptCard = binding.receiptCard
|
||||
@@ -105,6 +109,8 @@ class TransferReceiptFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
receiptCard.setOnClickListener { showFullScreenReceipt() }
|
||||
|
||||
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
|
||||
parentFragmentManager.popBackStack()
|
||||
}
|
||||
@@ -150,15 +156,21 @@ class TransferReceiptFragment : Fragment() {
|
||||
binding.tvTransactionDate.text = args.getString(ARG_MIB_DATE, "")
|
||||
binding.tvValueDate.text = args.getString(ARG_MIB_DATE, "")
|
||||
binding.tvPurpose.text = args.getString(ARG_REMARKS, "")
|
||||
|
||||
copyOnLongClick(
|
||||
binding.tvFromLabel, binding.tvToLabel, binding.tvAmount,
|
||||
binding.tvReferenceNo, binding.tvToAccount, binding.tvToBank,
|
||||
binding.tvTransactionDate, binding.tvValueDate, binding.tvPurpose
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadProfileImage(hash: String, isProfile: Boolean, onLoaded: (Bitmap) -> Unit) {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val sess = app.mibSession ?: return
|
||||
val sess = app.anyMibSession() ?: return
|
||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val base64 = if (isProfile) {
|
||||
app.mibLoginFlow.fetchProfileImage(sess, hash)
|
||||
app.anyMibFlow()?.fetchProfileImage(sess, hash)
|
||||
} else {
|
||||
MibContactsClient().fetchProfileImageBase64(sess, hash)
|
||||
} ?: return@launch
|
||||
@@ -201,6 +213,13 @@ class TransferReceiptFragment : Fragment() {
|
||||
binding.remarksDivider.visibility = View.VISIBLE
|
||||
binding.remarksRow.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
copyOnLongClick(
|
||||
binding.tvMessage, binding.tvMessageRow, binding.tvReference,
|
||||
binding.tvTransactionDate, binding.tvFrom, binding.tvToName,
|
||||
binding.tvToAccount, binding.tvAmountRow, binding.tvAmountValue,
|
||||
binding.tvAmountCurrency, binding.tvRemarks
|
||||
)
|
||||
}
|
||||
|
||||
// ── Share / Save ──────────────────────────────────────────────────────────
|
||||
@@ -310,9 +329,75 @@ class TransferReceiptFragment : Fragment() {
|
||||
return bm
|
||||
}
|
||||
|
||||
private fun showFullScreenReceipt() {
|
||||
val ctx = requireContext()
|
||||
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
||||
val dialog = Dialog(ctx, android.R.style.Theme_Black_NoTitleBar_Fullscreen)
|
||||
|
||||
val scrollView = android.widget.ScrollView(ctx).apply {
|
||||
setBackgroundColor(Color.BLACK)
|
||||
}
|
||||
|
||||
val cardView = if (bank == "MIB") {
|
||||
val binding = FragmentReceiptMibBinding.inflate(layoutInflater)
|
||||
bindMib(binding)
|
||||
binding.receiptCard
|
||||
} else {
|
||||
val binding = FragmentReceiptBmlBinding.inflate(layoutInflater)
|
||||
bindBml(binding)
|
||||
binding.receiptCard
|
||||
}
|
||||
(cardView.parent as? ViewGroup)?.removeView(cardView)
|
||||
cardView.setOnClickListener { dialog.dismiss() }
|
||||
scrollView.addView(cardView)
|
||||
scrollView.setOnTouchListener { _, _ -> dialog.dismiss(); true }
|
||||
|
||||
dialog.setContentView(scrollView)
|
||||
|
||||
val actWin = requireActivity().window
|
||||
val prevColor = actWin.statusBarColor
|
||||
val insetsCtrl = androidx.core.view.WindowInsetsControllerCompat(actWin, actWin.decorView)
|
||||
actWin.statusBarColor = Color.BLACK
|
||||
insetsCtrl.isAppearanceLightStatusBars = false
|
||||
dialog.setOnDismissListener {
|
||||
actWin.statusBarColor = prevColor
|
||||
val isLight = (resources.configuration.uiMode and
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
|
||||
android.content.res.Configuration.UI_MODE_NIGHT_NO
|
||||
insetsCtrl.isAppearanceLightStatusBars = isLight
|
||||
}
|
||||
dialog.show()
|
||||
dialog.window?.let { win ->
|
||||
androidx.core.view.WindowCompat.setDecorFitsSystemWindows(win, false)
|
||||
androidx.core.view.WindowInsetsControllerCompat(win, scrollView).apply {
|
||||
hide(androidx.core.view.WindowInsetsCompat.Type.systemBars())
|
||||
systemBarsBehavior = androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun copyOnLongClick(vararg views: android.widget.TextView) {
|
||||
for (tv in views) {
|
||||
tv.setOnLongClickListener {
|
||||
val text = tv.text?.toString()?.trim() ?: return@setOnLongClickListener false
|
||||
if (text.isBlank()) return@setOnLongClickListener false
|
||||
val cm = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
cm.setPrimaryClip(ClipData.newPlainText("receipt", text))
|
||||
Toast.makeText(requireContext(), "Copied", Toast.LENGTH_SHORT).show()
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = "Receipt"
|
||||
(activity as? HomeActivity)?.setBottomNavVisible(false)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
(activity as? HomeActivity)?.setBottomNavVisible(true)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
|
||||
@@ -18,14 +18,20 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.util.Totp
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||
import sh.sar.basedbank.api.bml.BmlActivationResult
|
||||
import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.fahipay.FahipayAccountClient
|
||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibProfileClient
|
||||
import sh.sar.basedbank.util.AccountCache
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
|
||||
import sh.sar.basedbank.ui.home.HomeActivity
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
||||
class CredentialsFragment : Fragment() {
|
||||
|
||||
@@ -46,6 +52,11 @@ class CredentialsFragment : Fragment() {
|
||||
private var fahipayFlow: FahipayLoginFlow? = null
|
||||
private var fahipayAwaitingTotp = false
|
||||
|
||||
// BML multi-profile state
|
||||
private var bmlFlow: BmlLoginFlow? = null
|
||||
private var bmlLoginId: String = ""
|
||||
private var bmlAccumulatedAccounts = mutableListOf<BankAccount>()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
@@ -54,7 +65,7 @@ class CredentialsFragment : Fragment() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
when (bankType) {
|
||||
"BML" -> {
|
||||
binding.ivBankLogo.setImageResource(R.drawable.bml_logo_vector)
|
||||
binding.ivBankLogo.setImageResource(R.drawable.bml_logo_long)
|
||||
binding.tvSignInDesc.setText(R.string.bml_sign_in_desc)
|
||||
}
|
||||
"FAHIPAY" -> {
|
||||
@@ -67,11 +78,27 @@ class CredentialsFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
binding.btnLogin.isEnabled = false
|
||||
binding.btnLogin.setOnClickListener { attemptLogin() }
|
||||
|
||||
val loginFieldWatcher = object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) { updateLoginButtonState() }
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
}
|
||||
binding.etUsername.addTextChangedListener(loginFieldWatcher)
|
||||
binding.etPassword.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) { updateLoginButtonState(); updateOtpDisplay() }
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
|
||||
if (bankType != "FAHIPAY") {
|
||||
binding.etOtpSeed.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) { updateOtpDisplay() }
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
updateOtpDisplay()
|
||||
updateLoginButtonState()
|
||||
}
|
||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
@@ -88,10 +115,35 @@ class CredentialsFragment : Fragment() {
|
||||
otpHandler.removeCallbacks(otpRunnable)
|
||||
}
|
||||
|
||||
private fun resolveOtpSeed(input: String): String {
|
||||
val secret = if (input.startsWith("otpauth://totp/"))
|
||||
android.net.Uri.parse(input).getQueryParameter("secret") ?: input
|
||||
else
|
||||
input
|
||||
return secret.replace("\\s".toRegex(), "").replace("-", "").uppercase()
|
||||
}
|
||||
|
||||
private fun updateLoginButtonState() {
|
||||
val username = binding.etUsername.text.toString().trim()
|
||||
val password = binding.etPassword.text.toString()
|
||||
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
|
||||
val otpSeed = resolveOtpSeed(otpSeedRaw)
|
||||
binding.btnLogin.isEnabled = when (bankType) {
|
||||
"FAHIPAY" -> username.isNotEmpty() && password.isNotEmpty()
|
||||
else -> username.isNotEmpty() && password.isNotEmpty() && otpSeed.isNotEmpty() && password != otpSeedRaw
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateOtpDisplay() {
|
||||
val seed = binding.etOtpSeed.text.toString().trim()
|
||||
val otpSeedRaw = binding.etOtpSeed.text.toString().trim()
|
||||
val seed = resolveOtpSeed(otpSeedRaw)
|
||||
if (seed.isEmpty()) {
|
||||
binding.cardOtp.visibility = View.GONE
|
||||
binding.cardOtp.visibility = View.INVISIBLE
|
||||
return
|
||||
}
|
||||
val password = binding.etPassword.text.toString()
|
||||
if (otpSeedRaw == password || seed.matches(Regex("\\d{6}"))) {
|
||||
binding.cardOtp.visibility = View.INVISIBLE
|
||||
return
|
||||
}
|
||||
try {
|
||||
@@ -104,7 +156,7 @@ class CredentialsFragment : Fragment() {
|
||||
binding.otpTimer.progress = remaining
|
||||
binding.cardOtp.visibility = View.VISIBLE
|
||||
} catch (e: Exception) {
|
||||
binding.cardOtp.visibility = View.GONE
|
||||
binding.cardOtp.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +168,7 @@ class CredentialsFragment : Fragment() {
|
||||
|
||||
val username = binding.etUsername.text.toString().trim()
|
||||
val password = binding.etPassword.text.toString()
|
||||
val otpSeed = binding.etOtpSeed.text.toString().trim()
|
||||
val otpSeed = resolveOtpSeed(binding.etOtpSeed.text.toString().trim())
|
||||
|
||||
if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
|
||||
binding.tvError.text = "Please fill in all fields"
|
||||
@@ -129,6 +181,7 @@ class CredentialsFragment : Fragment() {
|
||||
binding.btnLogin.isEnabled = false
|
||||
|
||||
val passwordHash = MibLoginFlow.hashPassword(password)
|
||||
val loginId = username
|
||||
val flow = MibLoginFlow(CredentialStore(requireContext()))
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
@@ -137,11 +190,12 @@ class CredentialsFragment : Fragment() {
|
||||
flow.login(username, passwordHash, otpSeed)
|
||||
}
|
||||
val store = CredentialStore(requireContext())
|
||||
store.saveMibCredentials(username, passwordHash, otpSeed)
|
||||
store.saveMibCredentials(loginId, username, passwordHash, otpSeed)
|
||||
withContext(Dispatchers.IO) {
|
||||
flow.lastSession?.let { s ->
|
||||
val profile = flow.fetchPersonalProfile(s)
|
||||
val profile = MibProfileClient().fetchPersonalProfile(s)
|
||||
if (profile != null) store.saveMibUserProfile(
|
||||
loginId,
|
||||
CredentialStore.MibUserProfile(
|
||||
fullName = profile.fullName,
|
||||
username = profile.username,
|
||||
@@ -152,11 +206,15 @@ class CredentialsFragment : Fragment() {
|
||||
)
|
||||
}
|
||||
}
|
||||
AccountCache.save(requireContext(), accounts)
|
||||
store.saveMibProfiles(loginId, flow.lastProfiles)
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
app.accounts = accounts
|
||||
app.mibSession = flow.lastSession
|
||||
app.mibProfiles = flow.lastProfiles
|
||||
// Merge with any existing MIB accounts from other logins
|
||||
app.mibAccounts = app.mibAccounts.filter { it.loginTag != "mib_$loginId" } + accounts
|
||||
app.accounts = app.accounts.filter { it.loginTag != "mib_$loginId" } + accounts
|
||||
AccountCache.save(requireContext(), app.mibAccounts)
|
||||
app.mibSessions[loginId] = flow.lastSession!!
|
||||
app.mibProfilesMap[loginId] = flow.lastProfiles
|
||||
app.mibLoginFlows[loginId] = flow
|
||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
startActivity(intent)
|
||||
@@ -173,7 +231,7 @@ class CredentialsFragment : Fragment() {
|
||||
private fun attemptBmlLogin() {
|
||||
val username = binding.etUsername.text.toString().trim()
|
||||
val password = binding.etPassword.text.toString()
|
||||
val otpSeed = binding.etOtpSeed.text.toString().trim()
|
||||
val otpSeed = resolveOtpSeed(binding.etOtpSeed.text.toString().trim())
|
||||
|
||||
if (username.isEmpty() || password.isEmpty() || otpSeed.isEmpty()) {
|
||||
binding.tvError.text = "Please fill in all fields"
|
||||
@@ -185,20 +243,72 @@ class CredentialsFragment : Fragment() {
|
||||
binding.progressBar.visibility = View.VISIBLE
|
||||
binding.btnLogin.isEnabled = false
|
||||
|
||||
val loginId = username
|
||||
val flow = BmlLoginFlow()
|
||||
bmlLoginId = username
|
||||
bmlAccumulatedAccounts.clear()
|
||||
|
||||
val flow = BmlLoginFlow().also { bmlFlow = it }
|
||||
val loginTag = "bml_$username"
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val (session, accounts) = withContext(Dispatchers.IO) {
|
||||
val profiles = withContext(Dispatchers.IO) {
|
||||
flow.login(username, password, otpSeed)
|
||||
}
|
||||
if (profiles.isEmpty()) throw Exception("No profiles found for this account")
|
||||
|
||||
var hasBusinessProfiles = false
|
||||
for (profile in profiles) {
|
||||
if (profile.profileType == "business") {
|
||||
hasBusinessProfiles = true
|
||||
continue // skip — user can enable in Settings → Logins
|
||||
}
|
||||
val result = withContext(Dispatchers.IO) { flow.activateProfile(profile, loginTag) }
|
||||
if (result is BmlActivationResult.Success) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
val store = CredentialStore(requireContext())
|
||||
store.saveBmlCredentials(loginId, username, password, otpSeed)
|
||||
store.saveBmlSession(loginId, session.accessToken, session.deviceId)
|
||||
withContext(Dispatchers.IO) {
|
||||
val info = flow.fetchUserInfo(session)
|
||||
if (info != null) store.saveBmlUserProfile(
|
||||
loginId,
|
||||
store.saveBmlCredentials(bmlLoginId, username, password, otpSeed)
|
||||
store.saveBmlProfiles(bmlLoginId, profiles)
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
app.bmlProfilesMap[bmlLoginId] = profiles
|
||||
app.bmlLoginFlows[bmlLoginId] = flow
|
||||
|
||||
binding.progressBar.visibility = View.GONE
|
||||
binding.btnLogin.isEnabled = true
|
||||
|
||||
finishBmlLogin(hasBusinessProfiles)
|
||||
} catch (e: Exception) {
|
||||
binding.tvError.text = e.message ?: "Login failed"
|
||||
binding.tvError.visibility = View.VISIBLE
|
||||
binding.progressBar.visibility = View.GONE
|
||||
binding.btnLogin.isEnabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun finishBmlLogin(hasBusinessProfiles: Boolean = false) {
|
||||
val store = CredentialStore(requireContext())
|
||||
val accounts = bmlAccumulatedAccounts.toList()
|
||||
|
||||
// Fetch user profile info from any active session
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val anySession = app.anyBmlSessionFor(bmlLoginId)
|
||||
if (anySession != null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val info = BmlAccountClient().fetchUserInfo(anySession)
|
||||
if (info != null) {
|
||||
store.saveBmlUserProfile(
|
||||
bmlLoginId,
|
||||
CredentialStore.BmlUserProfile(
|
||||
fullName = info.fullName,
|
||||
email = info.email,
|
||||
@@ -208,24 +318,55 @@ class CredentialsFragment : Fragment() {
|
||||
birthdate = info.birthdate
|
||||
)
|
||||
)
|
||||
// Single-profile accounts used username as a temporary profileId.
|
||||
// Replace it with the real BML customer ID so multi-login doesn't collide.
|
||||
val customerId = info.customerId
|
||||
if (customerId.isNotBlank()) {
|
||||
val profiles = store.loadBmlProfiles(bmlLoginId)
|
||||
val autoProfile = profiles.firstOrNull { it.autoActivated }
|
||||
if (autoProfile != null && autoProfile.profileId != customerId) {
|
||||
val oldId = autoProfile.profileId
|
||||
// Re-key session in memory and storage
|
||||
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 {
|
||||
if (it.autoActivated) it.copy(profileId = customerId) else it
|
||||
}
|
||||
store.saveBmlProfiles(bmlLoginId, updatedProfiles)
|
||||
app.bmlProfilesMap[bmlLoginId] = updatedProfiles
|
||||
// Update accounts to use real profileId
|
||||
bmlAccumulatedAccounts.replaceAll { acc ->
|
||||
if (acc.profileId == oldId) acc.copy(profileId = customerId) else acc
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AccountCache.saveBml(requireContext(), loginId, accounts)
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
app.bmlSessions[loginId] = session
|
||||
// Merge with any existing BML accounts from other logins
|
||||
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$loginId" } + accounts
|
||||
app.accounts = app.accounts + accounts
|
||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
binding.tvError.text = e.message ?: "Login failed"
|
||||
binding.tvError.visibility = View.VISIBLE
|
||||
} finally {
|
||||
binding.progressBar.visibility = View.GONE
|
||||
binding.btnLogin.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
AccountCache.saveBml(requireContext(), bmlLoginId, accounts)
|
||||
app.bmlAccounts = app.bmlAccounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
|
||||
app.accounts = app.accounts.filter { it.loginTag != "bml_$bmlLoginId" } + accounts
|
||||
|
||||
if (hasBusinessProfiles) {
|
||||
Toast.makeText(requireContext(), "Business profiles can be enabled in Settings → Logins", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun attemptFahipayLogin() {
|
||||
@@ -329,15 +470,17 @@ class CredentialsFragment : Fragment() {
|
||||
store: CredentialStore
|
||||
) {
|
||||
val (profile, balance) = withContext(Dispatchers.IO) {
|
||||
val p = flow.fetchProfile(session)
|
||||
val b = flow.fetchBalance(session)
|
||||
val p = FahipayAccountClient().fetchProfile(session)
|
||||
val b = FahipayAccountClient().fetchBalance(session)
|
||||
Pair(p, b)
|
||||
}
|
||||
val loginTag = "fahipay_${profile.profileId}"
|
||||
val account = flow.buildAccount(profile, balance, loginTag)
|
||||
store.saveFahipayCredentials(idCard, password)
|
||||
store.saveFahipaySession(session.authId, session.sessionCookie)
|
||||
val loginId = profile.profileId
|
||||
val loginTag = "fahipay_$loginId"
|
||||
val account = FahipayAccountClient().buildAccount(profile, balance, loginTag)
|
||||
store.saveFahipayCredentials(loginId, idCard, password)
|
||||
store.saveFahipaySession(loginId, session.authId, session.sessionCookie)
|
||||
store.saveFahipayUserProfile(
|
||||
loginId,
|
||||
CredentialStore.FahipayUserProfile(
|
||||
fullName = profile.fullName,
|
||||
email = profile.email,
|
||||
@@ -348,11 +491,11 @@ class CredentialsFragment : Fragment() {
|
||||
linkedAccounts = profile.linkedAccounts
|
||||
)
|
||||
)
|
||||
AccountCache.saveFahipay(requireContext(), listOf(account))
|
||||
AccountCache.saveFahipay(requireContext(), loginId, listOf(account))
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
app.fahipaySession = session
|
||||
app.fahipayAccounts = listOf(account)
|
||||
app.accounts = app.accounts + listOf(account)
|
||||
app.fahipaySessions[loginId] = session
|
||||
app.fahipayAccounts = app.fahipayAccounts.filter { it.loginTag != loginTag } + listOf(account)
|
||||
app.accounts = app.accounts.filter { it.loginTag != loginTag } + listOf(account)
|
||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
startActivity(intent)
|
||||
|
||||