Compare commits
108
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
caf0db60da
|
||
|
|
b48022d249
|
||
|
|
7574a5e8b0
|
||
|
|
992c8d4364
|
||
|
|
80fd238195
|
||
|
|
0357d7e0bc
|
||
|
|
71dd654edc
|
||
|
|
a75d8e420a
|
||
|
|
ca6ecc4283
|
||
|
|
f59c2be6c4
|
||
|
|
eb019a20a0
|
||
|
|
c356762baa
|
||
|
|
255f43db24
|
||
|
|
01cae559cf
|
||
|
|
015919a4ac
|
||
|
|
93a7c8bbde
|
||
|
|
8f4672f269
|
||
|
|
00e6b40ee0
|
||
|
|
86e1dc0521
|
||
|
|
a90d832dba
|
||
|
|
51c2dff4b2
|
||
|
|
43f3cca2aa
|
||
|
|
0e226d17ae
|
||
|
|
24021d7eeb
|
||
|
|
e997969070
|
||
|
|
3182e14873
|
||
|
|
52d2eb235b
|
||
|
|
ae18a8c6c8
|
||
|
|
a8cd22cbe1
|
||
|
|
281864347e
|
||
|
|
16fd909c7f
|
||
|
|
a95ca0e7a5
|
||
|
|
286a6f845d
|
||
|
|
5b5f776715
|
||
|
|
98990544fc
|
||
|
|
798e9da9ca
|
||
|
|
014c002ebe
|
||
|
|
6f8b7130fe
|
||
|
|
05430f043a
|
||
|
|
80bbacc130
|
||
|
|
570e6b750b
|
||
|
|
21fbd8b12c
|
||
|
|
d0f46e2118
|
||
|
|
71002ed70c
|
||
|
|
fbc34d6435
|
||
|
|
4b1c2419ec
|
||
|
|
26dcb20f7f
|
||
|
|
33eb33e18c
|
||
|
|
6a910facaf
|
||
|
|
e3c6b3a695
|
||
|
|
e978f11343
|
||
|
|
d227d468b1
|
||
|
|
d0fb88d15a
|
||
|
|
b08d983077
|
||
|
|
c7c89184c0
|
||
|
|
0e5435f0fe
|
||
|
|
3bb44f1c32
|
||
|
|
5dc1a5dbc9
|
||
|
|
982596f2a8
|
||
|
|
140b0069bd
|
||
|
|
74ec9c383c
|
||
|
|
b4f66342af
|
||
|
|
f575941141
|
||
|
|
ceaad0e313
|
||
|
|
528663a330
|
||
|
|
a1abbc9843
|
||
|
|
ffee918258
|
||
|
|
fc7fa420b2
|
||
|
|
5f6ec236bf
|
||
|
|
890cf15fd0
|
||
|
|
98a003727b
|
||
|
|
9ca13d3518
|
||
|
|
395e2308a0
|
||
|
|
ad7c5a4e5b
|
||
|
|
0ba2396c2c
|
||
|
|
173c02ab8f
|
||
|
|
b37b12996f
|
||
|
|
21203b39e7
|
||
|
|
0be492ca18
|
||
|
|
973576cf6a
|
||
|
|
4523aed69e
|
||
|
|
f90d83b59e
|
||
|
|
a03b1b1682
|
||
|
|
bc958e2df6
|
||
|
|
ae8ad24d13
|
||
|
|
a20f2a9ce7
|
||
|
|
0795df35a1
|
||
|
|
86e1e66a20
|
||
|
|
a5124096d7
|
||
|
|
1d2cd40b3c
|
||
|
|
abc1a43ad6
|
||
|
|
c7718f94b3
|
||
|
|
57bc488b98
|
||
|
|
7f87c9e13f
|
||
|
|
cc15ab1c6c
|
||
|
|
0efe833e40
|
||
|
|
f5f52829c7
|
||
|
|
3db077cf9a
|
||
|
|
ee5ecdaa18
|
||
|
|
2df162c09e
|
||
|
|
0f77216d2d
|
||
|
|
71e893faf8
|
||
|
|
1cd254c134
|
||
|
|
87536a339b
|
||
|
|
32d23a43b3
|
||
|
|
846ce22245
|
||
|
|
ed5b456e3b
|
||
|
|
9b284cc8d4
|
@@ -17,6 +17,8 @@ jobs:
|
||||
run: |
|
||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n' | base64 -d > app/key.jks
|
||||
echo "${{ secrets.DOTENV_BASE64 }}" | tr -d '\n' | base64 -d > .build/release/.env
|
||||
echo "ACCOUNT_MVR=${{ vars.ACCOUNT_MVR }}" >> .build/release/.env
|
||||
echo "ACCOUNT_USD=${{ vars.ACCOUNT_USD }}" >> .build/release/.env
|
||||
|
||||
- name: Build APK
|
||||
working-directory: .build/release
|
||||
|
||||
@@ -16,5 +16,7 @@ local.properties
|
||||
docs/mibapi/tmp
|
||||
docs/bmlapi/tmp
|
||||
docs/fahipayapi/tmp
|
||||
docs/mfaisaapi/tmp
|
||||
tmp
|
||||
app/key.jks
|
||||
.kotlin/*
|
||||
|
||||
Generated
+2
-2
@@ -4,10 +4,10 @@
|
||||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-05-22T00:11:32.873305232Z">
|
||||
<DropdownSelection timestamp="2026-06-13T17:53:06.478193524Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=683a9830" />
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
|
||||
@@ -14,17 +14,25 @@ A native Android client for Maldivian banking services. It is a pure client: req
|
||||
- Existing accounts with MIB, BML, or Fahipay
|
||||
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
|
||||
|
||||
## Download
|
||||
[Download latest APK](https://git.shihaam.dev/shihaam/ISODroid/releases/latest)
|
||||
## Download APK
|
||||
[Gitea Releases](https://git.shihaam.dev/shihaam/thijooree/releases)
|
||||
[Telegram Channel](https://t.me/s/thijooreeapks)
|
||||
|
||||
## Privacy
|
||||
|
||||
No data ever leaves your device except the API calls to the banking services themselves. See the [security audit](docs/AI_SECURITY_CHECK.md) for a full list of every server the app connects to.
|
||||
No data ever leaves your device except the API calls to the banking services themselves. See the [security audit](docs/thijooree/AI_SECURITY_CHECK.md) for a full list of every server the app connects to.
|
||||
|
||||
## Documentation
|
||||
|
||||
API reverse-engineering notes and app internals are in [`docs/`](docs/README.md).
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This is an unofficial third-party app. It is not affiliated with, endorsed by, or supported by MIB, BML, or Fahipay. Use at your own risk. Review the source code before entering your banking credentials.
|
||||
|
||||
## Contributing
|
||||
|
||||
<blockquote class="twitter-tweet"><p lang="en" dir="ltr">Talk is cheap, send patches.</p>— FFmpeg (@FFmpeg) <a href="https://x.com/FFmpeg/status/1762805900035686805?ref_src=twsrc%5Etfw">February 28, 2024</a></blockquote>
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+19
-2
@@ -1,8 +1,18 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
}
|
||||
|
||||
val localProps = Properties().also { props ->
|
||||
val f = rootProject.file("local.properties")
|
||||
if (f.exists()) props.load(f.inputStream())
|
||||
}
|
||||
|
||||
fun localOrEnv(key: String, envKey: String) =
|
||||
localProps.getProperty(key) ?: System.getenv(envKey) ?: ""
|
||||
|
||||
android {
|
||||
namespace = "sh.sar.basedbank"
|
||||
compileSdk = 36
|
||||
@@ -11,10 +21,13 @@ android {
|
||||
applicationId = "sh.sar.basedbank"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 9
|
||||
versionName = "1.0.10"
|
||||
versionCode = 26
|
||||
versionName = "1.0.25"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
buildConfigField("String", "ACCOUNT_MVR", "\"${localOrEnv("account.mvr", "ACCOUNT_MVR")}\"")
|
||||
buildConfigField("String", "ACCOUNT_USD", "\"${localOrEnv("account.usd", "ACCOUNT_USD")}\"")
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -49,6 +62,7 @@ android {
|
||||
}
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,6 +105,9 @@ dependencies {
|
||||
// Biometric authentication
|
||||
implementation("androidx.biometric:biometric:1.1.0")
|
||||
|
||||
// Encrypted SharedPreferences (HCE token store)
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
androidTestImplementation(libs.androidx.espresso.core)
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_logo_background">#CC0000</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,43 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<shortcut
|
||||
android:shortcutId="transfer"
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_shortcut_transfer"
|
||||
android:shortcutShortLabel="@string/transfer"
|
||||
android:shortcutLongLabel="@string/transfer">
|
||||
<intent
|
||||
android:action="sh.sar.basedbank.OPEN_TRANSFER"
|
||||
android:targetPackage="sh.sar.basedbank.debug"
|
||||
android:targetClass="sh.sar.basedbank.MainActivity" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
|
||||
<shortcut
|
||||
android:shortcutId="scan_qr"
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_shortcut_scan_qr"
|
||||
android:shortcutShortLabel="@string/transfer_scan_qr"
|
||||
android:shortcutLongLabel="@string/transfer_scan_qr">
|
||||
<intent
|
||||
android:action="sh.sar.basedbank.OPEN_SCAN_QR"
|
||||
android:targetPackage="sh.sar.basedbank.debug"
|
||||
android:targetClass="sh.sar.basedbank.MainActivity" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
|
||||
<shortcut
|
||||
android:shortcutId="tap_to_pay"
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_shortcut_pay_card"
|
||||
android:shortcutShortLabel="@string/card_pay_nfc"
|
||||
android:shortcutLongLabel="@string/card_pay_nfc">
|
||||
<intent
|
||||
android:action="sh.sar.basedbank.TAP_TO_PAY"
|
||||
android:targetPackage="sh.sar.basedbank.debug"
|
||||
android:targetClass="sh.sar.basedbank.MainActivity" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
|
||||
</shortcuts>
|
||||
@@ -7,6 +7,14 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.NFC" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
|
||||
|
||||
<application
|
||||
android:name=".BasedBankApp"
|
||||
@@ -59,6 +67,44 @@
|
||||
android:exported="false"
|
||||
android:screenOrientation="portrait" />
|
||||
|
||||
<activity
|
||||
android:name=".nfc.BmlTapToPayActivity"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/Theme.BasedBank" />
|
||||
|
||||
<service
|
||||
android:name=".service.NotificationPollingService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service
|
||||
android:name=".nfc.BmlHostCardEmulatorService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_NFC_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.nfc.cardemulation.host_apdu_service"
|
||||
android:resource="@xml/bml_aid_list" />
|
||||
</service>
|
||||
|
||||
<!-- Share-sheet alias: "Scan to Pay" receives shared images and decodes their QR code -->
|
||||
<activity-alias
|
||||
android:name=".ScanToPayActivity"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/transfer_scan_qr"
|
||||
android:icon="@drawable/ic_qr_scan">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="image/*" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
||||
@@ -8,6 +8,7 @@ import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.bml.BmlProfile
|
||||
import sh.sar.basedbank.api.bml.BmlSession
|
||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaSession
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibProfile
|
||||
@@ -48,6 +49,10 @@ class BasedBankApp : Application() {
|
||||
val fahipaySessions: MutableMap<String, FahipaySession> = mutableMapOf()
|
||||
var fahipayAccounts: List<BankAccount> = emptyList()
|
||||
|
||||
/** Active M-Faisa sessions keyed by loginId (= msisdn). */
|
||||
val mfaisaSessions: MutableMap<String, MfaisaSession> = mutableMapOf()
|
||||
var mfaisaAccounts: List<BankAccount> = emptyList()
|
||||
|
||||
// ─── MIB helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the MIB session for the given account (matched via loginTag). */
|
||||
@@ -110,6 +115,26 @@ class BasedBankApp : Application() {
|
||||
fun fahipaySessionFor(account: BankAccount): FahipaySession? =
|
||||
fahipaySessions[account.loginTag.removePrefix("fahipay_")]
|
||||
|
||||
// ─── M-Faisa helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/** Returns the M-Faisa session for the given account (matched via loginTag = "mfaisa_${msisdn}"). */
|
||||
fun mfaisaSessionFor(account: BankAccount): MfaisaSession? =
|
||||
mfaisaSessions[account.loginTag.removePrefix("mfaisa_")]
|
||||
|
||||
/**
|
||||
* Re-runs `fetchSubscriber` + `doMobileLogin` using the saved credentials for [loginId] and
|
||||
* replaces the cached session. Call this after catching [sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException].
|
||||
* Returns the fresh session, or null if no credentials are saved for that login.
|
||||
*/
|
||||
fun refreshMfaisaSession(loginId: String): MfaisaSession? {
|
||||
val creds = CredentialStore(this).loadMfaisaCredentials(loginId) ?: return null
|
||||
val flow = sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow(this)
|
||||
flow.fetchSubscriber(creds.msisdn)
|
||||
val result = flow.doMobileLogin(creds.msisdn, creds.pin)
|
||||
mfaisaSessions[loginId] = result.session
|
||||
return result.session
|
||||
}
|
||||
|
||||
/** Serialises all MIB profile-switch + request sequences to prevent session corruption. */
|
||||
val mibMutex = Mutex()
|
||||
|
||||
|
||||
@@ -124,8 +124,17 @@ class LockActivity : AppCompatActivity() {
|
||||
else
|
||||
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
||||
val btn = MaterialButton(this, null, style).apply {
|
||||
if (key == "⌫" || key == "✓") {
|
||||
text = ""
|
||||
icon = ContextCompat.getDrawable(this@LockActivity,
|
||||
if (key == "⌫") R.drawable.ic_backspace else R.drawable.ic_check)
|
||||
iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START
|
||||
iconPadding = 0
|
||||
iconSize = (28 * dp).toInt()
|
||||
} else {
|
||||
text = key
|
||||
textSize = 24f
|
||||
}
|
||||
insetTop = 0; insetBottom = 0
|
||||
minimumWidth = 0; minimumHeight = 0
|
||||
cornerRadius = btnSize / 2
|
||||
@@ -270,7 +279,8 @@ class LockActivity : AppCompatActivity() {
|
||||
finish()
|
||||
} else {
|
||||
val store = CredentialStore(this)
|
||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() ||
|
||||
store.hasFahipayCredentials() || store.hasMfaisaCredentials()
|
||||
if (!hasCredentials) {
|
||||
startActivity(Intent(this, sh.sar.basedbank.ui.login.LoginActivity::class.java))
|
||||
finish()
|
||||
@@ -278,9 +288,13 @@ class LockActivity : AppCompatActivity() {
|
||||
}
|
||||
val navDest = intent.getIntExtra("nav_destination", -1)
|
||||
val autoScan = intent.getBooleanExtra("auto_scan", false)
|
||||
val autoTapMode = intent.getBooleanExtra("auto_tap_mode", false)
|
||||
val shareQrText = intent.getStringExtra("share_qr_text")
|
||||
startActivity(Intent(this, HomeActivity::class.java).apply {
|
||||
if (navDest != -1) putExtra("nav_destination", navDest)
|
||||
if (autoScan) putExtra("auto_scan", true)
|
||||
if (autoTapMode) putExtra("auto_tap_mode", true)
|
||||
if (shareQrText != null) putExtra("share_qr_text", shareQrText)
|
||||
})
|
||||
finish()
|
||||
}
|
||||
|
||||
@@ -12,6 +12,28 @@ import sh.sar.basedbank.R
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
|
||||
private fun decodeQrFromSharedImage(uri: android.net.Uri): String? {
|
||||
return try {
|
||||
val bitmap = contentResolver.openInputStream(uri)?.use {
|
||||
android.graphics.BitmapFactory.decodeStream(it)
|
||||
} ?: return null
|
||||
val opts = de.markusfisch.android.zxingcpp.ZxingCpp.ReaderOptions(
|
||||
tryHarder = true, tryRotate = true, tryInvert = true,
|
||||
tryDownscale = true, maxNumberOfSymbols = 1,
|
||||
textMode = de.markusfisch.android.zxingcpp.ZxingCpp.TextMode.PLAIN
|
||||
)
|
||||
val result = (de.markusfisch.android.zxingcpp.ZxingCpp.readBitmap(
|
||||
bitmap, 0, 0, bitmap.width, bitmap.height, 0,
|
||||
opts.apply { binarizer = de.markusfisch.android.zxingcpp.ZxingCpp.Binarizer.LOCAL_AVERAGE }
|
||||
) ?: de.markusfisch.android.zxingcpp.ZxingCpp.readBitmap(
|
||||
bitmap, 0, 0, bitmap.width, bitmap.height, 0,
|
||||
opts.apply { binarizer = de.markusfisch.android.zxingcpp.ZxingCpp.Binarizer.GLOBAL_HISTOGRAM }
|
||||
))?.firstOrNull()?.text
|
||||
bitmap.recycle()
|
||||
result
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
@@ -19,15 +41,29 @@ class MainActivity : AppCompatActivity() {
|
||||
val onboardingDone = prefs.getBoolean("onboarding_done", false)
|
||||
val securitySet = prefs.getString("security_method", null) != null
|
||||
val store = CredentialStore(this)
|
||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() ||
|
||||
store.hasFahipayCredentials() || store.hasMfaisaCredentials()
|
||||
|
||||
// Image shared via "Scan to Pay" — decode QR here while we still hold the URI permission
|
||||
val shareQrText: String? = if (intent?.action == Intent.ACTION_SEND &&
|
||||
intent.type?.startsWith("image/") == true) {
|
||||
val uri: android.net.Uri? =
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU)
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM, android.net.Uri::class.java)
|
||||
else
|
||||
@Suppress("DEPRECATION") intent.getParcelableExtra(Intent.EXTRA_STREAM)
|
||||
if (uri != null) decodeQrFromSharedImage(uri) else null
|
||||
} else null
|
||||
|
||||
val navDestination = when (intent?.action) {
|
||||
"sh.sar.basedbank.OPEN_TRANSFER" -> R.id.nav_transfer
|
||||
"sh.sar.basedbank.OPEN_SCAN_QR" -> R.id.nav_transfer
|
||||
"sh.sar.basedbank.OPEN_PAY_WITH_CARD" -> R.id.nav_pay_with_card
|
||||
"sh.sar.basedbank.TAP_TO_PAY" -> R.id.nav_pay_with_card
|
||||
else -> -1
|
||||
}
|
||||
val autoScan = intent?.action == "sh.sar.basedbank.OPEN_SCAN_QR"
|
||||
val autoTapMode = intent?.action == "sh.sar.basedbank.TAP_TO_PAY"
|
||||
|
||||
val target = when {
|
||||
!onboardingDone -> OnboardingActivity::class.java
|
||||
@@ -43,6 +79,8 @@ class MainActivity : AppCompatActivity() {
|
||||
startActivity(Intent(this, target).apply {
|
||||
if (navDestination != -1) putExtra("nav_destination", navDestination)
|
||||
if (autoScan) putExtra("auto_scan", true)
|
||||
if (autoTapMode) putExtra("auto_tap_mode", true)
|
||||
if (shareQrText != null) putExtra("share_qr_text", shareQrText)
|
||||
})
|
||||
finish()
|
||||
}
|
||||
|
||||
@@ -14,9 +14,15 @@ internal fun newBmlApiClient(): OkHttpClient = OkHttpClient.Builder()
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Headers are sent in the order (and with the names) BML's own app uses. `accept` is not optional:
|
||||
* handled errors come back as JSON either way, but when a route throws (HTTP 500) the server
|
||||
* renders the Internet Banking HTML page — under HTTP 200 — unless the request asks for JSON.
|
||||
*/
|
||||
internal fun bmlApiRequest(session: BmlSession, url: String): Request =
|
||||
Request.Builder().url(url)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("accept", "application/json")
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("user-agent", BML_USER_AGENT)
|
||||
.header("authorization", "Bearer ${session.accessToken}")
|
||||
.build()
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
|
||||
data class BmlCardActionResult(
|
||||
val success: Boolean,
|
||||
val message: String
|
||||
)
|
||||
|
||||
class BmlCardClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
/**
|
||||
* Freezes or unfreezes a BML card.
|
||||
* @param cardId BML card UUID (BankAccount.internalId)
|
||||
* @param action "freeze" or "unfreeze"
|
||||
*/
|
||||
fun setCardFreezeState(session: BmlSession, cardId: String, action: String): BmlCardActionResult {
|
||||
val body = JSONObject().apply {
|
||||
put("card", cardId)
|
||||
put("action", action)
|
||||
}.toString().toRequestBody("application/json".toMediaType())
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("$BML_BASE_URL/api/mobile/services/card/freeze")
|
||||
.post(body)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.build()
|
||||
|
||||
val resp = client.newCall(request).execute()
|
||||
val code = resp.code
|
||||
val responseBody = resp.body?.string()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
if (code in 500..599) throw BankServerException("BML")
|
||||
return try {
|
||||
val json = JSONObject(responseBody ?: "")
|
||||
val ok = json.optBoolean("success") && json.optInt("code") == 0
|
||||
BmlCardActionResult(
|
||||
success = ok,
|
||||
message = json.optString("payload").ifBlank { json.optString("message") }
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
BmlCardActionResult(success = false, message = "")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,12 @@ import sh.sar.basedbank.api.models.BankTransaction
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
data class BmlCardHistoryResult(
|
||||
val statement: List<BankTransaction>,
|
||||
val outstanding: List<BankTransaction>,
|
||||
val unbilled: List<BankTransaction>
|
||||
)
|
||||
|
||||
class BmlHistoryClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
@@ -70,7 +76,7 @@ class BmlHistoryClient {
|
||||
accountDisplayName: String,
|
||||
accountNumber: String,
|
||||
month: String
|
||||
): List<BankTransaction> {
|
||||
): BmlCardHistoryResult {
|
||||
val body = """{"card":"$cardId","month":"$month"}"""
|
||||
.toRequestBody("application/json".toMediaType())
|
||||
val resp = client.newCall(
|
||||
@@ -81,6 +87,72 @@ class BmlHistoryClient {
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return BmlCardHistoryResult(emptyList(), emptyList(), emptyList())
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
if (code in 500..599) throw BankServerException("BML")
|
||||
return try {
|
||||
val root = JSONObject(json)
|
||||
if (!root.optBoolean("success")) return BmlCardHistoryResult(emptyList(), emptyList(), emptyList())
|
||||
val payload = root.optJSONObject("payload")
|
||||
?: return BmlCardHistoryResult(emptyList(), emptyList(), emptyList())
|
||||
|
||||
val outstanding = parseCardArray(
|
||||
payload.optJSONObject("outstanding")?.optJSONArray("CardOutStdAuthDetails"),
|
||||
idPrefix = "auth", accountNumber, accountDisplayName
|
||||
)
|
||||
val unbilled = parseCardArray(
|
||||
payload.optJSONObject("unbilled")?.optJSONArray("CardUnbillTxnDetails"),
|
||||
idPrefix = "unbilled", accountNumber, accountDisplayName
|
||||
)
|
||||
val statement = parseCardArray(
|
||||
payload.optJSONArray("cardstatement"),
|
||||
idPrefix = "stmt", accountNumber, accountDisplayName
|
||||
)
|
||||
|
||||
BmlCardHistoryResult(statement, outstanding, unbilled)
|
||||
} catch (_: Exception) { BmlCardHistoryResult(emptyList(), emptyList(), emptyList()) }
|
||||
}
|
||||
|
||||
private fun parseCardArray(
|
||||
arr: org.json.JSONArray?,
|
||||
idPrefix: String,
|
||||
accountNumber: String,
|
||||
accountDisplayName: String
|
||||
): List<BankTransaction> {
|
||||
if (arr == null) return emptyList()
|
||||
return (0 until arr.length()).map { i ->
|
||||
val item = arr.getJSONObject(i)
|
||||
val ref = item.optString("TranApprCode")
|
||||
BankTransaction(
|
||||
id = "${idPrefix}_${ref.ifBlank { i.toString() }}",
|
||||
date = item.optString("DateTime"),
|
||||
description = item.optString("TranDesc").trim(),
|
||||
amount = item.optDouble("BillingAmount", 0.0),
|
||||
currency = item.optString("BillingCcy", "MVR"),
|
||||
counterpartyName = null,
|
||||
reference = ref.takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchPendingHistory(
|
||||
session: BmlSession,
|
||||
accountId: String,
|
||||
accountDisplayName: String,
|
||||
accountNumber: String
|
||||
): List<BankTransaction> {
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BML_BASE_URL/api/mobile/history/pending/$accountId")
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val json = resp.body?.string() ?: return emptyList()
|
||||
resp.close()
|
||||
if (code == 401 || code == 419) throw AuthExpiredException()
|
||||
@@ -88,68 +160,22 @@ class BmlHistoryClient {
|
||||
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() },
|
||||
val payload = root.optJSONArray("payload") ?: return emptyList()
|
||||
(0 until payload.length()).map { i ->
|
||||
val item = payload.getJSONObject(i)
|
||||
BankTransaction(
|
||||
id = item.optString("LockedID"),
|
||||
date = item.optString("FromDate"),
|
||||
description = "Pending",
|
||||
amount = -item.optDouble("LockedAmount", 0.0),
|
||||
currency = "MVR",
|
||||
counterpartyName = item.optString("Description").trim().takeIf { it.isNotBlank() },
|
||||
reference = null,
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
))
|
||||
source = "BML"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val unbilled = payload.optJSONObject("unbilled")
|
||||
?.optJSONArray("CardUnbillTxnDetails")
|
||||
if (unbilled != null) {
|
||||
for (i in 0 until unbilled.length()) {
|
||||
val item = unbilled.getJSONObject(i)
|
||||
result.add(BankTransaction(
|
||||
id = "unbilled_${item.optString("TranApprCode")}_$i",
|
||||
date = item.optString("DateTime"),
|
||||
description = item.optString("TranDesc").trim(),
|
||||
amount = item.optDouble("BillingAmount", 0.0),
|
||||
currency = item.optString("BillingCcy", "MVR"),
|
||||
counterpartyName = null,
|
||||
reference = item.optString("TranApprCode").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
val statement = payload.optJSONArray("cardstatement")
|
||||
if (statement != null) {
|
||||
for (i in 0 until statement.length()) {
|
||||
val item = statement.getJSONObject(i)
|
||||
result.add(BankTransaction(
|
||||
id = "stmt_${item.optString("TranRef", i.toString())}",
|
||||
date = item.optString("TransDate", item.optString("TranDate", "")),
|
||||
description = item.optString("TranDesc", item.optString("Description", "")).trim(),
|
||||
amount = -item.optDouble("TranAmount", 0.0),
|
||||
currency = item.optString("TranCcy", "MVR"),
|
||||
counterpartyName = null,
|
||||
reference = item.optString("TranRef").takeIf { it.isNotBlank() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
))
|
||||
}
|
||||
}
|
||||
result
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,13 @@ data class BmlQrPayInfo(
|
||||
val currency: String
|
||||
)
|
||||
|
||||
/**
|
||||
* The pay-request lookup answered `success: false` — [message] is BML's own wording, safe to show
|
||||
* (e.g. code 103 "The payment request has expired", 112 "Unsupported payment link"). Network and
|
||||
* parse failures stay plain exceptions so the generic message is used for those instead.
|
||||
*/
|
||||
class BmlQrPayLookupException(val code: Int, message: String) : Exception(message)
|
||||
|
||||
data class BmlQrPayResult(
|
||||
val success: Boolean,
|
||||
val merchant: String = "",
|
||||
@@ -82,6 +89,15 @@ data class BmlQrPayResult(
|
||||
val errorMessage: String = ""
|
||||
)
|
||||
|
||||
data class BmlWalletToken(
|
||||
val token: String,
|
||||
val expiry: String,
|
||||
val appCode: String, // AID hex, e.g. "A0000000031010"
|
||||
val serviceCode: String,
|
||||
val data: String,
|
||||
val validUntil: String // "YYYY-MM-DD HH:mm:ss.SSS"
|
||||
)
|
||||
|
||||
data class BmlForeignLimit(
|
||||
val type: String,
|
||||
val used: Double,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.ui.home.AppNotification
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
private const val BML_NOTIF_BASE = "https://app.bankofmaldives.com.mv"
|
||||
|
||||
class BmlNotificationsClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
private val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US)
|
||||
|
||||
data class FetchResult(
|
||||
val items: List<AppNotification>,
|
||||
val total: Int
|
||||
)
|
||||
|
||||
fun fetchNotifications(
|
||||
session: BmlSession,
|
||||
loginId: String,
|
||||
group: String = "ALL",
|
||||
page: Int = 1
|
||||
): FetchResult {
|
||||
val url = "$BML_NOTIF_BASE/api/v2/notifications?group=$group&page=$page"
|
||||
return try {
|
||||
val resp = client.newCall(bmlApiRequest(session, url)).execute()
|
||||
if (!resp.isSuccessful) { resp.close(); return FetchResult(emptyList(), 0) }
|
||||
val body = resp.body?.string().also { resp.close() } ?: return FetchResult(emptyList(), 0)
|
||||
parseResponse(body, loginId)
|
||||
} catch (_: Exception) { FetchResult(emptyList(), 0) }
|
||||
}
|
||||
|
||||
fun markAllRead(session: BmlSession): Boolean {
|
||||
val url = "$BML_NOTIF_BASE/api/v2/notifications/read"
|
||||
val reqBody = """{"all":true}""".toRequestBody("application/json".toMediaType())
|
||||
val req = Request.Builder().url(url)
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.header("accept", "application/json")
|
||||
.put(reqBody)
|
||||
.build()
|
||||
return try {
|
||||
val resp = client.newCall(req).execute()
|
||||
val ok = resp.isSuccessful
|
||||
resp.close()
|
||||
ok
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
private fun parseResponse(body: String, loginId: String): FetchResult {
|
||||
val json = JSONObject(body)
|
||||
if (!json.optBoolean("success")) return FetchResult(emptyList(), 0)
|
||||
val total = json.optInt("total", 0)
|
||||
val payload = json.optJSONArray("payload") ?: return FetchResult(emptyList(), total)
|
||||
|
||||
val items = (0 until payload.length()).map { i ->
|
||||
val obj = payload.getJSONObject(i)
|
||||
val dataObj = obj.optJSONObject("data")
|
||||
val detailFields = mutableListOf<Pair<String, String>>()
|
||||
detailFields.add("Bank" to "BML")
|
||||
detailFields.add("Group" to obj.optString("group"))
|
||||
detailFields.add("Type" to obj.optString("type"))
|
||||
if (dataObj != null) {
|
||||
dataObj.keys().forEach { key ->
|
||||
val v = dataObj.opt(key)?.toString()?.takeIf { it.isNotBlank() } ?: return@forEach
|
||||
detailFields.add(formatKey(key) to v)
|
||||
}
|
||||
}
|
||||
val createdAt = obj.optString("created_at")
|
||||
val tsMs = try { sdf.parse(createdAt)?.time ?: System.currentTimeMillis() }
|
||||
catch (_: Exception) { System.currentTimeMillis() }
|
||||
AppNotification(
|
||||
id = obj.optString("id"),
|
||||
bank = "BML",
|
||||
loginId = loginId,
|
||||
group = obj.optString("group", "ALERTS"),
|
||||
title = obj.optString("title"),
|
||||
message = obj.optString("message"),
|
||||
timestampMs = tsMs,
|
||||
isRead = obj.optBoolean("is_read", true),
|
||||
detailFields = detailFields
|
||||
)
|
||||
}
|
||||
return FetchResult(items, total)
|
||||
}
|
||||
|
||||
private fun formatKey(key: String): String =
|
||||
key.replace('_', ' ').split(' ').joinToString(" ") { it.replaceFirstChar(Char::uppercase) }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import android.util.Base64
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
@@ -10,17 +11,27 @@ 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).
|
||||
* Resolves a BML QR to merchant details. [payTarget] is the QR URL, or for POS QRs the bare
|
||||
* `35` → `20` → `01` reference.
|
||||
*
|
||||
* The key is Base64-encoded here without padding, as BML's own app sends it. The padded form
|
||||
* resolves too, so this is parity rather than a requirement.
|
||||
*/
|
||||
fun lookupPayRequest(session: BmlSession, base64Url: String): BmlQrPayInfo {
|
||||
fun lookupPayRequest(session: BmlSession, payTarget: String): BmlQrPayInfo {
|
||||
val key = Base64.encodeToString(
|
||||
payTarget.toByteArray(Charsets.UTF_8), Base64.NO_WRAP or Base64.NO_PADDING)
|
||||
val request = bmlApiRequest(session,
|
||||
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$base64Url")
|
||||
"$BML_BASE_URL/api/mobile/walletpayments/payrequest/$key")
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val body = response.body?.string() ?: throw Exception("No response")
|
||||
// A server-side error renders an HTML page under HTTP 200 rather than an error JSON.
|
||||
if (!body.trimStart().startsWith("{"))
|
||||
throw Exception("Unexpected non-JSON response (HTTP ${response.code})")
|
||||
val json = JSONObject(body)
|
||||
if (!json.optBoolean("success"))
|
||||
throw Exception(json.optString("message").ifBlank { "Lookup failed" })
|
||||
throw BmlQrPayLookupException(
|
||||
json.optInt("code"),
|
||||
json.optString("message").ifBlank { "Lookup failed" })
|
||||
val payload = json.getJSONObject("payload")
|
||||
val addr2 = payload.optString("narrative2").trim()
|
||||
val addr3 = payload.optString("narrative3").trim()
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package sh.sar.basedbank.api.bml
|
||||
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class BmlTapToPayClient {
|
||||
|
||||
private val client = newBmlApiClient()
|
||||
|
||||
/**
|
||||
* Fetches up to [quantity] single-use payment tokens for [cardId].
|
||||
* [otp] is a TOTP code generated from the stored BML OTP seed.
|
||||
*
|
||||
* Flow:
|
||||
* 1. POST → code 99 (OTP required) or 0 (direct, unlikely)
|
||||
* 2. POST with channel=token → code 22 (OTP generated on BML side, but we use TOTP)
|
||||
* 3. POST with otp=TOTP → code 0, payload = token list
|
||||
*/
|
||||
fun fetchTokens(
|
||||
session: BmlSession,
|
||||
cardId: String,
|
||||
otp: String,
|
||||
quantity: Int = 3
|
||||
): List<BmlWalletToken> {
|
||||
val url = "$BML_BASE_URL/api/mobile/walletpayments/gettoken"
|
||||
|
||||
// Step 1: initiate
|
||||
val base = JSONObject().apply {
|
||||
put("type", "track2")
|
||||
put("cardid", cardId)
|
||||
put("quantity", quantity)
|
||||
}
|
||||
val step1 = post(session, url, base)
|
||||
if (step1.optInt("code") == 0) return parseTokens(step1.optJSONArray("payload"))
|
||||
if (step1.optInt("code") != 99) throw Exception(step1.optString("message", "Token request failed"))
|
||||
|
||||
// Step 2: request OTP channel (triggers BML to validate we can use TOTP)
|
||||
val body2 = JSONObject(base.toString()).apply { put("channel", "token") }
|
||||
val step2 = post(session, url, body2)
|
||||
if (step2.optInt("code") != 22) throw Exception(step2.optString("message", "OTP channel request failed"))
|
||||
|
||||
// Step 3: submit TOTP
|
||||
val body3 = JSONObject(body2.toString()).apply { put("otp", otp) }
|
||||
val step3 = post(session, url, body3)
|
||||
if (step3.optInt("code") != 0) throw Exception(step3.optString("message", "Token fetch failed"))
|
||||
|
||||
return parseTokens(step3.optJSONArray("payload"))
|
||||
}
|
||||
|
||||
private fun post(session: BmlSession, url: String, body: JSONObject): JSONObject {
|
||||
val req = okhttp3.Request.Builder()
|
||||
.url(url)
|
||||
.post(body.toString().toRequestBody("application/json".toMediaType()))
|
||||
.header("Authorization", "Bearer ${session.accessToken}")
|
||||
.header("User-Agent", BML_USER_AGENT)
|
||||
.header("x-app-version", BML_APP_VERSION)
|
||||
.build()
|
||||
return client.newCall(req).execute().use { resp ->
|
||||
JSONObject(resp.body?.string() ?: throw Exception("Empty response"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseTokens(arr: JSONArray?): List<BmlWalletToken> {
|
||||
arr ?: return emptyList()
|
||||
return (0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
BmlWalletToken(
|
||||
token = o.getString("token"),
|
||||
expiry = o.getString("expiry"),
|
||||
appCode = o.getString("app_code"),
|
||||
serviceCode = o.getString("service_code"),
|
||||
data = o.optString("data", ""),
|
||||
validUntil = o.optString("valid_until", "")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,8 @@ class BmlTransferClient {
|
||||
try {
|
||||
val json = JSONObject(bodyStr)
|
||||
if (!json.optBoolean("success")) {
|
||||
BmlTransferResult(false, errorMessage = json.optString("message").ifBlank { "Transfer failed" })
|
||||
val payloadStr = json.optString("payload").takeIf { it.isNotBlank() && it != "null" }
|
||||
BmlTransferResult(false, errorMessage = payloadStr ?: json.optString("message").ifBlank { "Transfer failed" })
|
||||
} else {
|
||||
val payload = json.optJSONObject("payload")
|
||||
BmlTransferResult(
|
||||
|
||||
@@ -71,7 +71,8 @@ class BmlValidateClient {
|
||||
originalInput = account,
|
||||
name = root.optString("name"),
|
||||
alias = null,
|
||||
currency = "MVR",
|
||||
// BML's MIB verify endpoint doesn't return the MIB account's currency.
|
||||
currency = "",
|
||||
agnt = root.optString("agnt").takeIf { it.isNotBlank() }
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
|
||||
object MfaisaAccountClient {
|
||||
|
||||
/**
|
||||
* Build one BankAccount per pocket from a login result.
|
||||
* `loginTag` is "mfaisa_<msisdn>" (one per M-Faisa account on the device).
|
||||
*/
|
||||
fun buildAccounts(result: MfaisaLoginResult, loginTag: String): List<BankAccount> {
|
||||
val displayName = result.profile.name.ifBlank { "M-Faisa" }
|
||||
return result.pockets.map { p ->
|
||||
val balance = "%.2f".format(p.balance)
|
||||
BankAccount(
|
||||
bank = "MFAISA",
|
||||
profileName = displayName,
|
||||
profileType = if (p.pocketValueType == "PAYPAL_USD") "MFAISA_PAYPAL" else "MFAISA",
|
||||
accountNumber = p.pocketId,
|
||||
accountBriefName = p.nickname.ifBlank { p.displayName.ifBlank { "M-Faisa" } },
|
||||
currencyName = p.currency,
|
||||
accountTypeName = p.displayName.ifBlank { "Mobile Wallet" },
|
||||
availableBalance = balance,
|
||||
currentBalance = balance,
|
||||
blockedAmount = "0.00",
|
||||
mvrBalance = if (p.currency == "MVR") balance else "0.00",
|
||||
statusDesc = p.statusType.ifBlank { "ACTIVE" },
|
||||
profileImageHash = null,
|
||||
loginTag = loginTag,
|
||||
profileId = result.profile.subscriberId,
|
||||
internalId = result.profile.walletId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
import android.util.Base64
|
||||
import java.math.BigInteger
|
||||
import java.security.KeyFactory
|
||||
import java.security.PublicKey
|
||||
import java.security.SecureRandom
|
||||
import java.security.spec.MGF1ParameterSpec
|
||||
import java.security.spec.RSAPublicKeySpec
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.spec.OAEPParameterSpec
|
||||
import javax.crypto.spec.PSource
|
||||
|
||||
/**
|
||||
* Field-level encryption for Ooredoo M-Faisa request payloads.
|
||||
*
|
||||
* Both keys live (obfuscated) in libnative-lib.so and were extracted by hooking
|
||||
* the live app with Frida.
|
||||
*/
|
||||
object MfaisaCrypto {
|
||||
|
||||
// 1024-bit RSA key. Used for mdnId / mobileNumber / userName.
|
||||
// Plaintext is "960" + msisdn. Cipher is OAEP/SHA-256.
|
||||
private val MOBILE_N = BigInteger(
|
||||
"125043708524451715642963973698406708755269502293565460606118930542682275971580032704131362488150174351194407172452175275612284031366512484449720820404229217064541745811143629538982383390723079478499614160620616911679256603296752844216620113064874342531851472851319065258962732556596958868200227678294957694889"
|
||||
)
|
||||
private val MOBILE_E = BigInteger("65537")
|
||||
|
||||
// 2048-bit RSA key. Used for mPin. Plaintext is `pin + <6-char alphanumeric salt>`.
|
||||
// Cipher is OAEP/SHA-1. Output is hex.
|
||||
private val PIN_N = BigInteger(
|
||||
"30853988905151679601945771998041800603731623930944610745590884250489036547584511246061683594739124713335100655247634233703624305850983479131604065498722268916133039937128796419041248167624160300158401049118446352988895953596475734156239882174799821436218294725935232359347780127398770443981734096915599443841496235741614376221345134752344583283770986295156829944214841171989893291834036934949311011654192369326666754259268756426483563391867503815261490458479377640385950664660570354934951526319509191336410208609648686869010157285218492218371799827560010164293202383337546810220755107741865769246084291990864545504123"
|
||||
)
|
||||
private val PIN_E = BigInteger("65537")
|
||||
|
||||
private val mobileKey: PublicKey by lazy { rsaPublicKey(MOBILE_N, MOBILE_E) }
|
||||
private val pinKey: PublicKey by lazy { rsaPublicKey(PIN_N, PIN_E) }
|
||||
|
||||
private val random = SecureRandom()
|
||||
private const val SALT_ALPHABET =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
/** Encrypts "960" + MSISDN. Output is non-deterministic (OAEP random padding). */
|
||||
fun encryptMobile(msisdn: String): String {
|
||||
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding")
|
||||
val params = OAEPParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, mobileKey, params)
|
||||
val ct = cipher.doFinal(("960" + msisdn).toByteArray(Charsets.UTF_8))
|
||||
return Base64.encodeToString(ct, Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
/** Encrypts `pin + <6-char random alphanumeric salt>`. Output is hex (lowercase). */
|
||||
fun encryptPin(pin: String): String {
|
||||
val salt = buildString {
|
||||
repeat(6) { append(SALT_ALPHABET[random.nextInt(SALT_ALPHABET.length)]) }
|
||||
}
|
||||
val plaintext = (pin + salt).toByteArray(Charsets.UTF_8)
|
||||
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding")
|
||||
val params = OAEPParameterSpec("SHA-1", "MGF1", MGF1ParameterSpec.SHA1, PSource.PSpecified.DEFAULT)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, pinKey, params)
|
||||
val ct = cipher.doFinal(plaintext)
|
||||
return ct.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun rsaPublicKey(n: BigInteger, e: BigInteger): PublicKey =
|
||||
KeyFactory.getInstance("RSA").generatePublic(RSAPublicKeySpec(n, e))
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.Adler32
|
||||
|
||||
/**
|
||||
* Fetches the M-Faisa transaction summary for the active subscriber session.
|
||||
*
|
||||
* Endpoint: POST /transactionInquiry/fetchSummary
|
||||
*
|
||||
* Two extra anti-replay fields are required:
|
||||
* - rndValue : RSA-OAEP-SHA1 encryption of a fresh timestamp+salt with the mPin key
|
||||
* (i.e. the same routine as [MfaisaCrypto.encryptPin] applied to a timestamp)
|
||||
* - csValue : Adler32(formDataJson + timestampPlaintext), as a decimal string
|
||||
*/
|
||||
class MfaisaHistoryClient {
|
||||
|
||||
private val baseUrl = "https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web"
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val random = SecureRandom()
|
||||
|
||||
/**
|
||||
* Fetches one page (`pageNo` is 1-based; recordSize defaults to 70 — the official app's value).
|
||||
* Returns the parsed transactions and a flag indicating whether the server returned a full
|
||||
* page (= more pages may be available).
|
||||
*/
|
||||
data class Page(val transactions: List<BankTransaction>, val hasMore: Boolean)
|
||||
|
||||
fun fetchHistory(
|
||||
session: MfaisaSession,
|
||||
accountNumber: String,
|
||||
accountDisplayName: String,
|
||||
pageNo: Int,
|
||||
recordSize: Int = 70
|
||||
): Page {
|
||||
if (session.loginExchangeKey.isBlank() || session.subscriberId.isBlank() || session.msisdn.isBlank()) {
|
||||
throw IllegalStateException("M-Faisa session is missing fields required for fetchSummary")
|
||||
}
|
||||
|
||||
val innerMdn = MfaisaCrypto.encryptMobile(session.msisdn)
|
||||
val outerMdn = MfaisaCrypto.encryptMobile(session.msisdn) // independent encryption
|
||||
|
||||
val formData = JSONObject()
|
||||
.put("actorRole", "RETAIL_SUBSCRIBER")
|
||||
.put("actorRoleId", session.subscriberId)
|
||||
.put("fromDate", "")
|
||||
.put("mdnId", innerMdn)
|
||||
.put("pageNo", pageNo.toString())
|
||||
.put("recordSize", recordSize.toString())
|
||||
.put("toDate", "")
|
||||
.put("transactionType", "")
|
||||
val formJson = formData.toString().matchGsonHtmlSafe()
|
||||
|
||||
// Anti-replay: nonce_str = (currentTimeMillis() + offset). Offset is small noise (0..5).
|
||||
val offset = (random.nextInt(5) + 10) xor 0xE
|
||||
val nonceStr = (System.currentTimeMillis() + offset).toString()
|
||||
// rndValue uses the same key/cipher as the mPin encryption — see [MfaisaCrypto.encryptPin].
|
||||
val rndValue = MfaisaCrypto.encryptPin(nonceStr)
|
||||
val csValue = Adler32().apply { update((formJson + nonceStr).toByteArray(Charsets.UTF_8)) }
|
||||
.value.toString()
|
||||
|
||||
val body = FormBody.Builder()
|
||||
.add("role", "RETAIL_SUBSCRIBER")
|
||||
.add("channel", "SubscriberApp")
|
||||
.add("rndValue", rndValue)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("formData", formJson)
|
||||
.add("mdnId", outerMdn)
|
||||
.add("csValue", csValue)
|
||||
.build()
|
||||
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$baseUrl/transactionInquiry/fetchSummary").post(body).build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val raw = resp.body?.string() ?: throw Exception("Empty history response")
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Ooredoo M-Faisa")
|
||||
|
||||
// The server returns its error envelope as a JSON array even on HTTP 200.
|
||||
val trimmed = raw.trimStart()
|
||||
if (trimmed.startsWith("[")) {
|
||||
val errArr = JSONArray(trimmed)
|
||||
val first = errArr.optJSONObject(0)
|
||||
val errObj = first?.optJSONArray("error")?.optJSONObject(0)
|
||||
val attrVal = errObj?.optString("attributeValue")
|
||||
val errCode = errObj?.optString("errorCode")
|
||||
if (attrVal == "SESSION_EXPIRED" || errCode == "SESSION_EXPIRED") {
|
||||
throw MfaisaSessionExpiredException()
|
||||
}
|
||||
val msg = errObj?.optString("errorMessage") ?: first?.optString("message")
|
||||
throw Exception(msg?.ifBlank { null } ?: "M-Faisa history failed")
|
||||
}
|
||||
|
||||
val obj = JSONObject(trimmed)
|
||||
val arr = obj.optJSONArray("transactionInquiryDTOList") ?: JSONArray()
|
||||
val out = mutableListOf<BankTransaction>()
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.getJSONObject(i) ?: continue
|
||||
out += parse(o, accountNumber, accountDisplayName)
|
||||
}
|
||||
// The server returns nothing useful for "total"; assume more pages exist when this page is full.
|
||||
return Page(out, hasMore = arr.length() >= recordSize)
|
||||
}
|
||||
|
||||
private fun parse(o: JSONObject, accountNumber: String, accountDisplayName: String): BankTransaction {
|
||||
val trnDate = o.optString("trnDate") // "yyyy-MM-dd HH:mm:ss" — already in target format
|
||||
val trnType = o.optString("trnType") // CASH_IN | PURCHASE | TRANSFER | …
|
||||
val status = o.optString("status") // SUCCESS | FAILED
|
||||
val amtObj = o.optJSONObject("transactionAmount") ?: JSONObject()
|
||||
val amount = amtObj.optDouble("amount", 0.0)
|
||||
val currency = amtObj.optString("currencyCode", "MVR")
|
||||
val refId = o.optString("referenceId").ifBlank { o.optString("requestId") }
|
||||
val narration = o.optString("narrationString").ifBlank { trnType.replace("_", " ").lowercase().replaceFirstChar { it.uppercase() } }
|
||||
|
||||
// Direction: CASH_IN / TRANSFER_IN etc. are credits, everything else is a debit
|
||||
val isCredit = trnType.endsWith("_IN") ||
|
||||
trnType == "CASH_IN" ||
|
||||
trnType == "RECEIVE_MONEY"
|
||||
val signedAmount = if (isCredit) amount else -amount
|
||||
|
||||
// Counterparty hint: parse the typeSummaryString for richer info if present
|
||||
val counterparty = extractCounterparty(o)
|
||||
|
||||
// Failed transactions still appear in the list — we still show them but tag in the description.
|
||||
val description = if (status == "FAILED") "$narration · Failed" else narration
|
||||
|
||||
return BankTransaction(
|
||||
id = refId,
|
||||
date = trnDate,
|
||||
description = description,
|
||||
amount = signedAmount,
|
||||
currency = currency,
|
||||
counterpartyName = counterparty,
|
||||
reference = refId.ifBlank { null },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "MFAISA"
|
||||
)
|
||||
}
|
||||
|
||||
/** Best-effort counterparty / merchant name extraction from the response's nested JSON. */
|
||||
private fun extractCounterparty(o: JSONObject): String? {
|
||||
// typeSummaryString is itself a JSON-encoded array string
|
||||
val ts = o.optString("typeSummaryString").trim()
|
||||
if (ts.startsWith("[")) {
|
||||
try {
|
||||
val arr = JSONArray(ts)
|
||||
for (i in 0 until arr.length()) {
|
||||
val item = arr.optJSONObject(i) ?: continue
|
||||
item.optString("Merchant Name").takeIf { it.isNotBlank() }?.let { return it }
|
||||
item.optString("Receiver Name").takeIf { it.isNotBlank() }?.let { return it }
|
||||
item.optString("Sender Name").takeIf { it.isNotBlank() }?.let { return it }
|
||||
}
|
||||
} catch (_: Exception) { /* fall through */ }
|
||||
}
|
||||
// sourceMDN like "Shiham-DT Pocket-9609198026" — the bit before the first dash is the user-facing name
|
||||
val source = o.optString("sourceMDN")
|
||||
if (source.isNotBlank() && source.contains("-")) return source.substringBefore("-")
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Match the official app's Gson serialiser: replace `=` with the Unicode-escaped equivalent so
|
||||
* the M-Faisa server's strict parser accepts the payload (same trick as in [MfaisaLoginFlow]).
|
||||
*/
|
||||
private fun String.matchGsonHtmlSafe(): String =
|
||||
replace("\\/", "/").replace("=", "\\u003d")
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MfaisaLoginFlow(context: Context) {
|
||||
|
||||
private val BASE_URL = "https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web"
|
||||
|
||||
// Do NOT set User-Agent explicitly: Cloudflare in front of superapp.ooredoo.mv
|
||||
// fingerprints header order, and an explicit .header("User-Agent", ...) call
|
||||
// pushes it to the front of the request, returning 400. Letting OkHttp's
|
||||
// BridgeInterceptor add its default "okhttp/4.12.0" at the end matches the
|
||||
// official app's on-wire ordering and gets 200.
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* Step 0: look up the subscriber by MSISDN and verify they have Full KYC.
|
||||
* Throws [MfaisaKycRequiredException] if kycStatus != "Full KYC".
|
||||
* Throws [MfaisaWalletNotReadyException] if the wallet isn't registered / activated / PIN-set.
|
||||
*/
|
||||
fun fetchSubscriber(msisdn: String): JSONObject {
|
||||
val body = JSONObject()
|
||||
.put("mdnId", MfaisaCrypto.encryptMobile(msisdn))
|
||||
.toString()
|
||||
.matchGsonHtmlSafe()
|
||||
.toRequestBody("application/json; charset=UTF-8".toMediaType())
|
||||
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/fetchSubscriberByMDN")
|
||||
.post(body)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val raw = resp.body?.string() ?: throw Exception("Empty subscriber response")
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Ooredoo M-Faisa")
|
||||
|
||||
val obj = JSONObject(raw)
|
||||
if (!obj.optBoolean("success", false)) {
|
||||
throw Exception(obj.optString("message").ifBlank { "Could not look up this number" })
|
||||
}
|
||||
if (!obj.optBoolean("subscriberRegistered", false)) {
|
||||
throw MfaisaNotRegisteredException()
|
||||
}
|
||||
if (!obj.optBoolean("passwordCreated", false)) {
|
||||
throw MfaisaWalletNotReadyException("Set your M-Faisa mPIN in the Ooredoo SuperApp first, then try again.")
|
||||
}
|
||||
if (obj.optBoolean("activationPending", false)) {
|
||||
throw MfaisaWalletNotReadyException("Your M-Faisa wallet activation is still pending. Complete it in the Ooredoo SuperApp first.")
|
||||
}
|
||||
val kyc = obj.optString("kycStatus")
|
||||
if (kyc != "Full KYC") {
|
||||
throw MfaisaKycRequiredException(kyc)
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 1: submit the PIN. Returns parsed login result on success.
|
||||
* Throws [MfaisaInvalidPinException] on a rejected PIN (with [MfaisaInvalidPinException.lastAttempt] = true
|
||||
* if the server's message says "one more wrong attempt will lock your account").
|
||||
*/
|
||||
fun doMobileLogin(msisdn: String, pin: String): MfaisaLoginResult {
|
||||
val mobileEnc = MfaisaCrypto.encryptMobile(msisdn)
|
||||
val userNameEnc = MfaisaCrypto.encryptMobile(msisdn) // independent encryption, same plaintext
|
||||
val pinEnc = MfaisaCrypto.encryptPin(pin)
|
||||
|
||||
val deviceId = androidId()
|
||||
val deviceGeo = JSONObject()
|
||||
.put("appType", "CustomerAndroid")
|
||||
.put("appversion", "1.0")
|
||||
.put("deviceId", deviceId)
|
||||
.put("deviceManufacturer", Build.MANUFACTURER)
|
||||
.put("imieNumber", deviceId)
|
||||
.put("ipaddress", "11.22.33.55")
|
||||
.put("latitude", "0.0")
|
||||
.put("longitude", "0.0")
|
||||
.put("simId", deviceId)
|
||||
|
||||
val formData = JSONObject()
|
||||
.put("deviceGeoInfo", deviceGeo)
|
||||
.put("mPin", pinEnc)
|
||||
.put("mobileNumber", mobileEnc)
|
||||
.put("role", "RETAIL_SUBSCRIBER")
|
||||
.put("tenantCode", "ooredoo")
|
||||
.put("userName", userNameEnc)
|
||||
.toString()
|
||||
.matchGsonHtmlSafe()
|
||||
|
||||
val body = FormBody.Builder()
|
||||
.add("channel", "C03")
|
||||
.add("formData", formData)
|
||||
.add("formDataCs", "null")
|
||||
.build()
|
||||
|
||||
val resp = client.newCall(
|
||||
Request.Builder().url("$BASE_URL/doMobileLogin")
|
||||
.post(body)
|
||||
.build()
|
||||
).execute()
|
||||
val code = resp.code
|
||||
val raw = resp.body?.string() ?: throw Exception("Empty login response")
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Ooredoo M-Faisa")
|
||||
|
||||
// Wrong-PIN response is a JSON array; success is an object.
|
||||
val trimmed = raw.trimStart()
|
||||
if (trimmed.startsWith("[")) {
|
||||
val arr = JSONArray(trimmed)
|
||||
val first = arr.optJSONObject(0)
|
||||
val errObj = first?.optJSONArray("error")?.optJSONObject(0)
|
||||
val msg = errObj?.optString("errorMessage")
|
||||
?: first?.optString("message") ?: "Login failed"
|
||||
val lastAttempt = msg.contains("one more", ignoreCase = true) ||
|
||||
msg.contains("will lock", ignoreCase = true)
|
||||
throw MfaisaInvalidPinException(msg, lastAttempt)
|
||||
}
|
||||
|
||||
val obj = try { JSONObject(trimmed) } catch (e: JSONException) {
|
||||
throw Exception("Unexpected login response")
|
||||
}
|
||||
if (!obj.optBoolean("success", false)) {
|
||||
throw MfaisaInvalidPinException(obj.optString("message").ifBlank { "Login failed" }, false)
|
||||
}
|
||||
// Defensive: server also returns kycStatus on success.
|
||||
val kyc = obj.optString("kycStatus")
|
||||
if (kyc.isNotBlank() && kyc != "Full KYC") {
|
||||
throw MfaisaKycRequiredException(kyc)
|
||||
}
|
||||
|
||||
val session = MfaisaSession(
|
||||
loginExchangeKey = obj.optString("loginExchangeKey"),
|
||||
sessionTimeoutSec = obj.optString("mobileLoginSessionTimeout").toIntOrNull() ?: 240,
|
||||
msisdn = msisdn,
|
||||
subscriberId = obj.optString("suscriberId")
|
||||
)
|
||||
|
||||
// pocketDetails[0] holds this user's identity + pockets.
|
||||
val pd = obj.optJSONArray("pocketDetails")?.optJSONObject(0) ?: JSONObject()
|
||||
val profile = MfaisaUserProfile(
|
||||
name = pd.optString("name").ifBlank { "M-Faisa" },
|
||||
email = pd.optString("eMailId"),
|
||||
mdnId = pd.optString("mdnId").ifBlank { msisdn },
|
||||
roleId = pd.optString("roleId"),
|
||||
walletId = pd.optString("walletId"),
|
||||
subscriberId = obj.optString("suscriberId"),
|
||||
offerId = pd.optString("offerId")
|
||||
)
|
||||
val pockets = mutableListOf<MfaisaPocket>()
|
||||
val pktArr = pd.optJSONArray("pocketSummaryDetailsArrayDTO") ?: JSONArray()
|
||||
for (i in 0 until pktArr.length()) {
|
||||
val p = pktArr.getJSONObject(i)
|
||||
val bal = p.optJSONObject("balanceAmount") ?: JSONObject()
|
||||
pockets += MfaisaPocket(
|
||||
pocketId = p.optString("pocketId"),
|
||||
pocketType = p.optString("pocketType"),
|
||||
pocketValueType = p.optString("pocketValueType"),
|
||||
nickname = p.optString("nickName"),
|
||||
currency = bal.optString("currencyCode", "MVR"),
|
||||
balance = bal.optDouble("amount", 0.0),
|
||||
isDefault = p.optBoolean("isDefaultPocket", false),
|
||||
isSecondary = p.optBoolean("isSecondaryPocket", false),
|
||||
statusType = p.optString("statusType"),
|
||||
displayName = p.optString("displayName")
|
||||
)
|
||||
}
|
||||
|
||||
return MfaisaLoginResult(session, profile, pockets)
|
||||
}
|
||||
|
||||
private fun androidId(): String {
|
||||
return Settings.Secure.getString(appContext.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
?: "0000000000000000"
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the body byte-identical to what the official app's Gson serializer emits:
|
||||
* 1. `\/` (org.json's default escape for `/`) → `/`
|
||||
* 2. `=` → `=` (Gson `htmlSafe` mode)
|
||||
* Both are technically valid JSON either way, but the M-Faisa server's parser appears strict.
|
||||
*/
|
||||
private fun String.matchGsonHtmlSafe(): String =
|
||||
replace("\\/", "/").replace("=", "\\u003d")
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
data class MfaisaSession(
|
||||
val loginExchangeKey: String,
|
||||
val sessionTimeoutSec: Int,
|
||||
val msisdn: String = "",
|
||||
val subscriberId: String = ""
|
||||
)
|
||||
|
||||
/** Subset of the doMobileLogin success response we keep around. */
|
||||
data class MfaisaUserProfile(
|
||||
val name: String,
|
||||
val email: String,
|
||||
val mdnId: String,
|
||||
val roleId: String,
|
||||
val walletId: String,
|
||||
val subscriberId: String,
|
||||
val offerId: String
|
||||
)
|
||||
|
||||
/** Pocket = M-Faisa balance bucket (E-Money MVR, IMT MVR, PayPal USD). */
|
||||
data class MfaisaPocket(
|
||||
val pocketId: String,
|
||||
val pocketType: String, // INTERNAL, ...
|
||||
val pocketValueType: String, // EMONEY, PAYPAL_USD, ...
|
||||
val nickname: String,
|
||||
val currency: String, // MVR, USD
|
||||
val balance: Double,
|
||||
val isDefault: Boolean,
|
||||
val isSecondary: Boolean,
|
||||
val statusType: String,
|
||||
val displayName: String
|
||||
)
|
||||
|
||||
data class MfaisaLoginResult(
|
||||
val session: MfaisaSession,
|
||||
val profile: MfaisaUserProfile,
|
||||
val pockets: List<MfaisaPocket>
|
||||
)
|
||||
|
||||
/** Thrown when the wallet is not "Full KYC" — login must abort. */
|
||||
class MfaisaKycRequiredException(val kycStatus: String) :
|
||||
Exception("M-Faisa wallet is not fully verified (kycStatus=$kycStatus)")
|
||||
|
||||
/** Thrown when this MSISDN has no M-Faisa wallet at all — user must sign up in the Ooredoo SuperApp. */
|
||||
class MfaisaNotRegisteredException : Exception("This number does not have an M-Faisa wallet")
|
||||
|
||||
/** Thrown when fetchSubscriberByMDN says the wallet exists but is not yet usable (no PIN, activation pending, …). */
|
||||
class MfaisaWalletNotReadyException(message: String) : Exception(message)
|
||||
|
||||
/**
|
||||
* Thrown for an invalid PIN. The PIN field should be re-enabled.
|
||||
* [lastAttempt] is true when the server's message warns the user one more wrong attempt will lock their account.
|
||||
*/
|
||||
class MfaisaInvalidPinException(message: String, val lastAttempt: Boolean = false) : Exception(message)
|
||||
|
||||
/**
|
||||
* Thrown when a session-scoped M-Faisa endpoint returns the
|
||||
* `[{ ..., "attributeValue": "SESSION_EXPIRED", ... }]` envelope (still as HTTP 200).
|
||||
* Callers should re-run the login (`fetchSubscriber` + `doMobileLogin`) using the saved
|
||||
* credentials and retry the request once.
|
||||
*/
|
||||
class MfaisaSessionExpiredException : Exception("M-Faisa session expired")
|
||||
|
||||
/** Thrown by [MfaisaTransferClient.searchRecipient] when no M-Faisa wallet exists for the queried MSISDN. */
|
||||
class MfaisaRecipientNotFoundException : Exception("No M-Faisa wallet found for this number")
|
||||
|
||||
/** Thrown by [MfaisaTransferClient.confirmTransfer] when the OTP is rejected. */
|
||||
class MfaisaInvalidOtpException(message: String) : Exception(message)
|
||||
@@ -0,0 +1,199 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.Adler32
|
||||
|
||||
/**
|
||||
* M-Faisa merchant QR payment ("smart pay") flow:
|
||||
* 1. [fetchQrDetails] POST /QRCodeUtility/fetchQRCodeById — resolve qrCodeId to merchant
|
||||
* 2. [initiatePurchase] POST /initiateNewBuy — start the purchase, returns referenceId.
|
||||
* Server returns 2FARequired=NONE for wallet QR pay,
|
||||
* so no OTP is required.
|
||||
* 3. [confirmPurchase] POST /confirmNewBuy — settles the purchase. `transactionAuthDetails`
|
||||
* is sent as the literal string "null".
|
||||
*
|
||||
* Anti-replay scheme is the same as [MfaisaTransferClient]: rndValue = encryptPin(timestampStr),
|
||||
* csValue = Adler32(formDataJson + timestampStr). The server responds with `[{...}]` envelopes
|
||||
* for both success and error — callers must check the `success` flag.
|
||||
*/
|
||||
class MfaisaQrPayClient {
|
||||
|
||||
private val baseUrl = "https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web"
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val random = SecureRandom()
|
||||
|
||||
/** Resolved merchant for a scanned M-Faisa QR. */
|
||||
data class QrMerchant(
|
||||
val qrCodeId: String,
|
||||
val merchantId: String, // customerId from the lookup response
|
||||
val merchantName: String, // commercialName
|
||||
val merchantMsisdn: String, // mobileNumber — already includes "960" prefix
|
||||
val currencyCode: String, // e.g. "MVR"
|
||||
/** Pre-set amount for a dynamic QR; null for a static QR (user enters amount). */
|
||||
val txnAmount: String?,
|
||||
val status: String // "Active" for usable QRs
|
||||
)
|
||||
|
||||
// ─── Step 1: resolve qrCodeId → merchant details ─────────────────────────
|
||||
|
||||
fun fetchQrDetails(session: MfaisaSession, qrCodeId: String): QrMerchant {
|
||||
val formData = JSONObject()
|
||||
.put("qrCodeId", qrCodeId)
|
||||
.put("tenantCode", "ooredoo")
|
||||
.toString().matchGsonHtmlSafe()
|
||||
|
||||
val (rnd, cs) = makeAntiReplay(formData)
|
||||
// Note: fetchQRCodeById uses role=R01 (not RETAIL_SUBSCRIBER like the other two endpoints).
|
||||
val body = FormBody.Builder()
|
||||
.add("role", "R01")
|
||||
.add("channel", "C03")
|
||||
.add("rndValue", rnd)
|
||||
.add("formData", formData)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("csValue", cs)
|
||||
.build()
|
||||
|
||||
val first = postAndUnwrap("$baseUrl/QRCodeUtility/fetchQRCodeById", body, "QR lookup failed")
|
||||
val response = first.optJSONArray("response")?.optJSONObject(0)
|
||||
?: throw Exception("QR code not found")
|
||||
if (!response.optString("status").equals("Active", ignoreCase = true)) {
|
||||
throw Exception("QR code is not active")
|
||||
}
|
||||
|
||||
// The lookup response stores absent values as the literal JSON null (decoded by org.json as
|
||||
// `JSONObject.NULL`) — optString surfaces that as the string "null". Guard against both.
|
||||
fun strOrNull(name: String): String? = response.opt(name)
|
||||
?.takeIf { it != JSONObject.NULL }
|
||||
?.toString()
|
||||
?.takeIf { it.isNotBlank() && it != "null" }
|
||||
|
||||
return QrMerchant(
|
||||
qrCodeId = strOrNull("qrCodeId") ?: qrCodeId,
|
||||
merchantId = strOrNull("customerId") ?: throw Exception("Merchant id missing"),
|
||||
merchantName = strOrNull("commercialName") ?: throw Exception("Merchant name missing"),
|
||||
merchantMsisdn = strOrNull("mobileNumber") ?: throw Exception("Merchant number missing"),
|
||||
currencyCode = strOrNull("currencyCode") ?: "MVR",
|
||||
txnAmount = strOrNull("txnAmount"),
|
||||
status = response.optString("status")
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Step 2: initiate the purchase ───────────────────────────────────────
|
||||
|
||||
/** Returns the `referenceId` to be passed to [confirmPurchase]. */
|
||||
fun initiatePurchase(
|
||||
session: MfaisaSession,
|
||||
sourcePocketId: String,
|
||||
sourceMsisdn: String, // user's "960..." MSISDN
|
||||
merchant: QrMerchant,
|
||||
amount: String,
|
||||
description: String = ""
|
||||
): String {
|
||||
val formData = JSONObject()
|
||||
.put("channel", "SubscriberApp")
|
||||
.put("commodityType", "WALLET")
|
||||
.put("description", description)
|
||||
.put("merchantId", merchant.merchantId)
|
||||
.put("mobileNumber", merchant.merchantMsisdn)
|
||||
.put("sourceDetails", JSONObject()
|
||||
.put("MDNId", sourceMsisdn)
|
||||
.put("actorRoleType", "RETAIL_SUBSCRIBER")
|
||||
.put("pocketId", sourcePocketId))
|
||||
.put("transactionAmount", amount)
|
||||
.put("transactionCurrency", merchant.currencyCode)
|
||||
.put("transactionType", "PURCHASE")
|
||||
.toString().matchGsonHtmlSafe()
|
||||
|
||||
val (rnd, cs) = makeAntiReplay(formData)
|
||||
val body = FormBody.Builder()
|
||||
.add("role", "RETAIL_SUBSCRIBER")
|
||||
.add("channel", "C03")
|
||||
.add("rndValue", rnd)
|
||||
.add("formData", formData)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("csValue", cs)
|
||||
.build()
|
||||
|
||||
val first = postAndUnwrap("$baseUrl/initiateNewBuy", body, "Payment initiation failed")
|
||||
// We've only seen 2FARequired=NONE for wallet QR pay. If the server ever asks for OTP we
|
||||
// surface a clear error instead of silently completing a no-op confirm.
|
||||
val twoFa = first.optString("2FARequired").ifBlank { "NONE" }
|
||||
if (!twoFa.equals("NONE", ignoreCase = true)) {
|
||||
throw Exception("This QR requires 2FA ($twoFa) which is not yet supported")
|
||||
}
|
||||
val responseObj = first.optJSONArray("response")?.optJSONObject(0)?.optJSONObject("responseObject")
|
||||
?: throw Exception("Missing responseObject")
|
||||
val refId = responseObj.optString("referenceId")
|
||||
if (refId.isBlank()) throw Exception("Server did not return a referenceId")
|
||||
return refId
|
||||
}
|
||||
|
||||
// ─── Step 3: confirm (no OTP) ────────────────────────────────────────────
|
||||
|
||||
fun confirmPurchase(session: MfaisaSession, referenceId: String) {
|
||||
val formData = JSONObject().put("referenceId", referenceId).toString().matchGsonHtmlSafe()
|
||||
val (rnd, cs) = makeAntiReplay(formData)
|
||||
val body = FormBody.Builder()
|
||||
.add("role", "RETAIL_SUBSCRIBER")
|
||||
.add("channel", "C03")
|
||||
.add("rndValue", rnd)
|
||||
// Literal string "null" — matches the captured request from the official app.
|
||||
.add("transactionAuthDetails", "null")
|
||||
.add("formData", formData)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("csValue", cs)
|
||||
.build()
|
||||
|
||||
postAndUnwrap("$baseUrl/confirmNewBuy", body, "Payment confirmation failed")
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** POSTs [body] to [url], unwraps the `[{...}]` envelope, throws on non-success / session expiry. */
|
||||
private fun postAndUnwrap(url: String, body: okhttp3.RequestBody, fallbackError: String): JSONObject {
|
||||
val resp = client.newCall(Request.Builder().url(url).post(body).build()).execute()
|
||||
val code = resp.code
|
||||
val raw = resp.body?.string() ?: throw Exception("Empty response")
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Ooredoo M-Faisa")
|
||||
|
||||
val arr = JSONArray(raw.trimStart())
|
||||
val first = arr.optJSONObject(0) ?: throw Exception(fallbackError)
|
||||
handleSessionExpiry(first)
|
||||
if (!first.optBoolean("success", false)) {
|
||||
val errObj = first.optJSONArray("error")?.optJSONObject(0)
|
||||
throw Exception(errObj?.optString("errorMessage")?.ifBlank { null }
|
||||
?: first.optString("message").ifBlank { fallbackError })
|
||||
}
|
||||
return first
|
||||
}
|
||||
|
||||
private fun handleSessionExpiry(envelope: JSONObject?) {
|
||||
val attr = envelope?.optJSONArray("error")?.optJSONObject(0)?.optString("attributeValue")
|
||||
val code = envelope?.optJSONArray("error")?.optJSONObject(0)?.optString("errorCode")
|
||||
if (attr == "SESSION_EXPIRED" || code == "SESSION_EXPIRED") throw MfaisaSessionExpiredException()
|
||||
}
|
||||
|
||||
private fun makeAntiReplay(formJson: String): Pair<String, String> {
|
||||
val offset = (random.nextInt(5) + 10) xor 0xE
|
||||
val nonceStr = (System.currentTimeMillis() + offset).toString()
|
||||
val rndValue = MfaisaCrypto.encryptPin(nonceStr)
|
||||
val csValue = Adler32().apply {
|
||||
update((formJson + nonceStr).toByteArray(Charsets.UTF_8))
|
||||
}.value.toString()
|
||||
return rndValue to csValue
|
||||
}
|
||||
|
||||
private fun String.matchGsonHtmlSafe(): String =
|
||||
replace("\\/", "/").replace("=", "\\u003d")
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package sh.sar.basedbank.api.mfaisa
|
||||
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.zip.Adler32
|
||||
|
||||
/**
|
||||
* Three-step M-Faisa transfer flow:
|
||||
* 1. [searchRecipient] POST /Pocket/basicBeneDetails — look up the recipient
|
||||
* 2. [initiateTransfer] POST /initiateFTRequest — kicks off the transfer; server SMSes an OTP
|
||||
* 3. [confirmTransfer] POST /confirmFTRequest — submit the OTP to actually move the money
|
||||
*
|
||||
* Every request uses the same anti-replay scheme as the history endpoint (see [MfaisaHistoryClient]):
|
||||
* `rndValue` = `encryptPin(timestampStr)` and `csValue` = `Adler32(formDataJson + timestampStr)`.
|
||||
*
|
||||
* On a session timeout the server returns `[{... "attributeValue":"SESSION_EXPIRED" ...}]` with HTTP 200;
|
||||
* each method throws [MfaisaSessionExpiredException] in that case so the caller can re-login and retry.
|
||||
*/
|
||||
class MfaisaTransferClient(private val deviceId: String) {
|
||||
|
||||
private val baseUrl = "https://superapp.ooredoo.mv/api/mfaisaa-bff/mfino/v1.1/web"
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
private val random = SecureRandom()
|
||||
|
||||
// ─── Step 1: recipient lookup ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Result of [searchRecipient]. The MVR pocket (`isMvr`) is the only target for outgoing transfers
|
||||
* in Thijooree — PayPal pockets are not supported as recipients.
|
||||
*/
|
||||
data class Recipient(
|
||||
val name: String,
|
||||
val msisdn: String, // already includes the "960" prefix, as returned by the server
|
||||
val mvrPocketId: String?,
|
||||
val paypalPocketId: String?,
|
||||
val walletId: String,
|
||||
val actorId: String
|
||||
) {
|
||||
val isMvr: Boolean get() = mvrPocketId != null
|
||||
}
|
||||
|
||||
/** @throws MfaisaRecipientNotFoundException if no M-Faisa wallet exists for [recipientMsisdn]. */
|
||||
fun searchRecipient(session: MfaisaSession, recipientMsisdn: String): Recipient {
|
||||
require(session.msisdn.isNotBlank() && session.subscriberId.isNotBlank()) {
|
||||
"session is missing fields required for basicBeneDetails"
|
||||
}
|
||||
val formData = JSONObject()
|
||||
.put("beneficaryDetails", JSONObject()
|
||||
.put("MDNId", MfaisaCrypto.encryptMobile(recipientMsisdn))
|
||||
.put("actorRoleType", "RETAIL_SUBSCRIBER"))
|
||||
.put("initiatorDetailsDTO", JSONObject()
|
||||
.put("initiatingMDN", MfaisaCrypto.encryptMobile(session.msisdn))
|
||||
.put("initiatingRoleId", session.subscriberId)
|
||||
.put("initiatorRole", "RETAIL_SUBSCRIBER"))
|
||||
.toString().matchGsonHtmlSafe()
|
||||
|
||||
val (rnd, cs) = makeAntiReplay(formData)
|
||||
val body = FormBody.Builder()
|
||||
.add("role", "RETAIL_SUBSCRIBER")
|
||||
.add("channel", "SubscriberApp")
|
||||
.add("rndValue", rnd)
|
||||
.add("formData", formData)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("csValue", cs)
|
||||
.build()
|
||||
|
||||
val raw = execute("$baseUrl/Pocket/basicBeneDetails", body)
|
||||
// Response shape: `[{ success, response: [[pocket1, pocket2, ...]] }]`
|
||||
val arr = JSONArray(raw.trimStart())
|
||||
val first = arr.optJSONObject(0) ?: throw Exception("Unexpected response")
|
||||
if (!first.optBoolean("success", false)) {
|
||||
handleSessionExpiry(first)
|
||||
val msg = first.optString("message")
|
||||
if (msg.contains("not found", ignoreCase = true)) throw MfaisaRecipientNotFoundException()
|
||||
throw Exception(msg.ifBlank { "Recipient lookup failed" })
|
||||
}
|
||||
val outer = first.optJSONArray("response") ?: throw Exception("Empty recipient list")
|
||||
val pockets = outer.optJSONArray(0) ?: throw MfaisaRecipientNotFoundException()
|
||||
if (pockets.length() == 0) throw MfaisaRecipientNotFoundException()
|
||||
|
||||
var name = ""
|
||||
var msisdn = ""
|
||||
var mvr: String? = null
|
||||
var paypal: String? = null
|
||||
var wallet = ""
|
||||
var actor = ""
|
||||
for (i in 0 until pockets.length()) {
|
||||
val p = pockets.getJSONObject(i)
|
||||
if (name.isBlank()) name = p.optString("name")
|
||||
if (msisdn.isBlank()) msisdn = p.optString("MDNId")
|
||||
if (wallet.isBlank()) wallet = p.optString("walletId")
|
||||
if (actor.isBlank()) actor = p.optString("actorId")
|
||||
when (p.optString("pocketValueType")) {
|
||||
"EMONEY" -> mvr = p.optString("pocketId")
|
||||
"PAYPAL_USD" -> paypal = p.optString("pocketId")
|
||||
}
|
||||
}
|
||||
return Recipient(name, msisdn, mvr, paypal, wallet, actor)
|
||||
}
|
||||
|
||||
// ─── Step 2: initiate (server sends OTP) ─────────────────────────────────
|
||||
|
||||
/** Returns the `referenceId` to be passed to [confirmTransfer]. */
|
||||
fun initiateTransfer(
|
||||
session: MfaisaSession,
|
||||
sourcePocketId: String,
|
||||
recipient: Recipient,
|
||||
amount: String,
|
||||
description: String
|
||||
): String {
|
||||
require(recipient.isMvr) { "M-Faisa transfers can only target the recipient's MVR pocket" }
|
||||
|
||||
// Inner formData JSON. The server expects PLAINTEXT mobile numbers here (already
|
||||
// prefixed with "960" — recipient.msisdn already includes that), unlike step 1 which
|
||||
// encrypts them.
|
||||
val formData = JSONObject()
|
||||
.put("MDNId", recipient.msisdn)
|
||||
.put("beneDetails", JSONObject()
|
||||
.put("miscDetails", description)
|
||||
.put("transferMode", "MOBILE"))
|
||||
.put("channel", "SubscriberApp")
|
||||
.put("commodityType", "WALLET")
|
||||
.put("description", description)
|
||||
.put("inputDetailsDTO", JSONObject()
|
||||
.put("deviceId", deviceId)
|
||||
.put("simId", deviceId))
|
||||
.put("mfs-transactionType", "send-money-to-mobile")
|
||||
.put("pocketId", "")
|
||||
.put("sourceDetails", JSONObject()
|
||||
.put("MDNId", "960${session.msisdn}")
|
||||
.put("actorRoleType", "RETAIL_SUBSCRIBER")
|
||||
.put("pocketId", sourcePocketId))
|
||||
.put("transactionAmount", amount)
|
||||
.put("transactionCurrency", "MVR")
|
||||
.put("transferMode", "MOBILE")
|
||||
.toString().matchGsonHtmlSafe()
|
||||
|
||||
val (rnd, cs) = makeAntiReplay(formData)
|
||||
// The "identifier" top-level field is the recipient MDN re-encrypted (matches step 1's
|
||||
// `beneficaryDetails.MDNId` plaintext; independent OAEP randomness gives a different ciphertext).
|
||||
val identifier = MfaisaCrypto.encryptMobile(recipient.msisdn.removePrefix("960"))
|
||||
|
||||
val body = FormBody.Builder()
|
||||
.add("identifier", identifier)
|
||||
.add("role", "RETAIL_SUBSCRIBER")
|
||||
.add("transferMode", "MOBILE")
|
||||
.add("channel", "C03") // NB: top-level "C03", inner formData.channel is "SubscriberApp"
|
||||
.add("rndValue", rnd)
|
||||
.add("formData", formData)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("tPin", "")
|
||||
.add("csValue", cs)
|
||||
.build()
|
||||
|
||||
val raw = execute("$baseUrl/initiateFTRequest", body)
|
||||
val trimmed = raw.trimStart()
|
||||
if (trimmed.startsWith("[")) {
|
||||
val errArr = JSONArray(trimmed)
|
||||
handleSessionExpiry(errArr.optJSONObject(0))
|
||||
val errObj = errArr.optJSONObject(0)?.optJSONArray("error")?.optJSONObject(0)
|
||||
throw Exception(errObj?.optString("errorMessage")?.ifBlank { null } ?: "Transfer initiation failed")
|
||||
}
|
||||
val obj = JSONObject(trimmed)
|
||||
if (!obj.optBoolean("success", false)) {
|
||||
throw Exception(obj.optString("message").ifBlank { "Transfer initiation failed" })
|
||||
}
|
||||
val responseArr = obj.optJSONArray("response") ?: throw Exception("Missing response array")
|
||||
val responseObj = responseArr.optJSONObject(0)?.optJSONObject("responseObject")
|
||||
?: throw Exception("Missing responseObject")
|
||||
val refId = responseObj.optString("referenceId")
|
||||
if (refId.isBlank()) throw Exception("Server did not return a referenceId")
|
||||
return refId
|
||||
}
|
||||
|
||||
// ─── Step 3: confirm with OTP ────────────────────────────────────────────
|
||||
|
||||
/** Submits [otpCode] for [referenceId]. Throws on invalid OTP / server failure. */
|
||||
fun confirmTransfer(session: MfaisaSession, referenceId: String, otpCode: String) {
|
||||
val formData = JSONObject().put("referenceId", referenceId).toString().matchGsonHtmlSafe()
|
||||
val transactionAuthDetails = JSONObject()
|
||||
.put("authenticationType", "OTP")
|
||||
.put("authenticationValue", MfaisaCrypto.encryptPin(otpCode)) // same cipher as PIN
|
||||
.put("otpTransactionType", "TRANSACTION")
|
||||
.put("referenceId", referenceId)
|
||||
.toString().matchGsonHtmlSafe()
|
||||
|
||||
val (rnd, cs) = makeAntiReplay(formData)
|
||||
val body = FormBody.Builder()
|
||||
.add("role", "RETAIL_SUBSCRIBER")
|
||||
.add("channel", "C03")
|
||||
.add("rndValue", rnd)
|
||||
.add("transactionAuthDetails", transactionAuthDetails)
|
||||
.add("formData", formData)
|
||||
.add("loginExchangeKey", session.loginExchangeKey)
|
||||
.add("csValue", cs)
|
||||
.build()
|
||||
|
||||
val raw = execute("$baseUrl/confirmFTRequest", body)
|
||||
val trimmed = raw.trimStart()
|
||||
if (trimmed.startsWith("[")) {
|
||||
val errArr = JSONArray(trimmed)
|
||||
handleSessionExpiry(errArr.optJSONObject(0))
|
||||
val errObj = errArr.optJSONObject(0)?.optJSONArray("error")?.optJSONObject(0)
|
||||
val attr = errObj?.optString("attributeName")
|
||||
val msg = errObj?.optString("errorMessage")?.ifBlank { null }
|
||||
?: errArr.optJSONObject(0)?.optString("message")
|
||||
if (attr.equals("OTP", ignoreCase = true) || (msg ?: "").contains("OTP", ignoreCase = true)) {
|
||||
throw MfaisaInvalidOtpException(msg ?: "Invalid OTP")
|
||||
}
|
||||
throw Exception(msg ?: "Transfer confirmation failed")
|
||||
}
|
||||
val obj = JSONObject(trimmed)
|
||||
if (!obj.optBoolean("success", false)) {
|
||||
throw Exception(obj.optString("message").ifBlank { "Transfer confirmation failed" })
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
private fun execute(url: String, body: okhttp3.RequestBody): String {
|
||||
val resp = client.newCall(Request.Builder().url(url).post(body).build()).execute()
|
||||
val code = resp.code
|
||||
val raw = resp.body?.string() ?: throw Exception("Empty response")
|
||||
resp.close()
|
||||
if (code in 500..599) throw BankServerException("Ooredoo M-Faisa")
|
||||
return raw
|
||||
}
|
||||
|
||||
private fun handleSessionExpiry(envelope: JSONObject?) {
|
||||
val attr = envelope?.optJSONArray("error")?.optJSONObject(0)?.optString("attributeValue")
|
||||
val code = envelope?.optJSONArray("error")?.optJSONObject(0)?.optString("errorCode")
|
||||
if (attr == "SESSION_EXPIRED" || code == "SESSION_EXPIRED") throw MfaisaSessionExpiredException()
|
||||
}
|
||||
|
||||
private fun makeAntiReplay(formJson: String): Pair<String, String> {
|
||||
val offset = (random.nextInt(5) + 10) xor 0xE
|
||||
val nonceStr = (System.currentTimeMillis() + offset).toString()
|
||||
val rndValue = MfaisaCrypto.encryptPin(nonceStr)
|
||||
val csValue = Adler32().apply {
|
||||
update((formJson + nonceStr).toByteArray(Charsets.UTF_8))
|
||||
}.value.toString()
|
||||
return rndValue to csValue
|
||||
}
|
||||
|
||||
private fun String.matchGsonHtmlSafe(): String =
|
||||
replace("\\/", "/").replace("=", "\\u003d")
|
||||
|
||||
companion object {
|
||||
/** Convenience factory that pulls the device identifier the way [MfaisaLoginFlow] does. */
|
||||
fun forContext(context: android.content.Context): MfaisaTransferClient {
|
||||
val id = Settings.Secure.getString(context.applicationContext.contentResolver, Settings.Secure.ANDROID_ID)
|
||||
?: "0000000000000000"
|
||||
// suppress "unused" — kept for symmetry with MfaisaLoginFlow if we later read Build.MANUFACTURER.
|
||||
@Suppress("UNUSED_VARIABLE") val mfg = Build.MANUFACTURER
|
||||
return MfaisaTransferClient(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package sh.sar.basedbank.api.mib
|
||||
|
||||
import android.os.Build
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.ui.home.AppNotification
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
private val SKIP_TYPES = setOf("Switch Profile", "Log in")
|
||||
private const val MIB_WV_URL = "https://faisamobilex-wv.mib.com.mv"
|
||||
|
||||
class MibActivityHistoryClient {
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
private val sdf = SimpleDateFormat("dd MMM yyyy HH:mm", Locale.US)
|
||||
|
||||
data class FetchResult(
|
||||
val items: List<AppNotification>, // already filtered (no Switch Profile)
|
||||
val rawCount: Int, // raw items returned by API before filtering
|
||||
val totalCount: Int,
|
||||
val nextStart: Int
|
||||
)
|
||||
|
||||
fun fetchActivity(
|
||||
session: MibSession,
|
||||
loginId: String,
|
||||
start: Int,
|
||||
end: Int
|
||||
): FetchResult {
|
||||
val cookieHeader = "mbmodel=IOS-1.0; " +
|
||||
"xxid=${session.xxid}; " +
|
||||
"IBSID=${session.xxid}; " +
|
||||
"mbnonce=${session.nonceGenerator}; " +
|
||||
"time-tracker=597"
|
||||
|
||||
val formBody = FormBody.Builder()
|
||||
.add("start", start.toString())
|
||||
.add("end", end.toString())
|
||||
.add("includeCount", "1")
|
||||
.build()
|
||||
|
||||
val req = Request.Builder()
|
||||
.url("$MIB_WV_URL/aProfile/getPagedActivityHistory")
|
||||
.header("Cookie", cookieHeader)
|
||||
.header("User-Agent", "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36")
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.post(formBody)
|
||||
.build()
|
||||
|
||||
val body = try {
|
||||
val resp = client.newCall(req).execute()
|
||||
if (!resp.isSuccessful) { resp.close(); return FetchResult(emptyList(), 0, 0, end + 1) }
|
||||
resp.body?.string().also { resp.close() } ?: return FetchResult(emptyList(), 0, 0, end + 1)
|
||||
} catch (_: Exception) { return FetchResult(emptyList(), 0, 0, end + 1) }
|
||||
|
||||
return try {
|
||||
val json = JSONObject(body)
|
||||
if (!json.optBoolean("success")) return FetchResult(emptyList(), 0, 0, end + 1)
|
||||
val totalCount = json.optString("total_count", "0").toIntOrNull() ?: 0
|
||||
val dataArr = json.optJSONArray("data") ?: return FetchResult(emptyList(), 0, totalCount, end + 1)
|
||||
|
||||
val items = mutableListOf<AppNotification>()
|
||||
val rawCount = dataArr.length()
|
||||
for (i in 0 until rawCount) {
|
||||
val obj = dataArr.getJSONObject(i)
|
||||
val activityType = obj.optString("activityType")
|
||||
if (activityType in SKIP_TYPES) continue
|
||||
|
||||
val pa = obj.optString("pa")
|
||||
val activity = obj.optString("activity")
|
||||
val pb = obj.optString("pb")
|
||||
val dateStr = obj.optString("date")
|
||||
|
||||
val message = buildString {
|
||||
append(pa)
|
||||
if (activity.isNotBlank()) { append(" "); append(activity) }
|
||||
if (pb.isNotBlank()) { append(" "); append(pb) }
|
||||
}
|
||||
|
||||
val tsMs = try { sdf.parse(dateStr)?.time ?: System.currentTimeMillis() }
|
||||
catch (_: Exception) { System.currentTimeMillis() }
|
||||
|
||||
val detailFields = mutableListOf<Pair<String, String>>().apply {
|
||||
add("Bank" to "MIB")
|
||||
add("Type" to activityType)
|
||||
if (pa.isNotBlank()) add("By" to pa)
|
||||
if (activity.isNotBlank() && pb.isNotBlank()) add("Action" to "$activity $pb")
|
||||
if (dateStr.isNotBlank()) add("Date" to dateStr)
|
||||
}
|
||||
|
||||
items.add(AppNotification(
|
||||
id = obj.optString("aid"),
|
||||
bank = "MIB",
|
||||
loginId = loginId,
|
||||
group = "ALERTS",
|
||||
title = activityType,
|
||||
message = message,
|
||||
timestampMs = tsMs,
|
||||
isRead = false, // resolved from cache in the sheet
|
||||
detailFields = detailFields
|
||||
))
|
||||
}
|
||||
FetchResult(items, rawCount, totalCount, end + 1)
|
||||
} catch (_: Exception) { FetchResult(emptyList(), 0, 0, end + 1) }
|
||||
}
|
||||
|
||||
// Keeps fetching pages until at least `minCount` non-Switch-Profile items found or all pages exhausted.
|
||||
fun fetchUntilEnough(
|
||||
session: MibSession,
|
||||
loginId: String,
|
||||
minCount: Int = 5,
|
||||
pageSize: Int = 100
|
||||
): FetchResult {
|
||||
val accumulated = mutableListOf<AppNotification>()
|
||||
var start = 1
|
||||
var totalCount = 0
|
||||
|
||||
while (accumulated.size < minCount) {
|
||||
val result = fetchActivity(session, loginId, start, start + pageSize - 1)
|
||||
totalCount = result.totalCount
|
||||
accumulated.addAll(result.items)
|
||||
if (result.rawCount == 0 || start + pageSize - 1 >= totalCount) break
|
||||
start = result.nextStart
|
||||
}
|
||||
return FetchResult(accumulated, accumulated.size, totalCount, start)
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,18 @@ import okhttp3.Request
|
||||
import org.json.JSONObject
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
data class MibCardActionResult(
|
||||
val success: Boolean,
|
||||
val message: String,
|
||||
val currentStatusCode: String
|
||||
)
|
||||
|
||||
class MibCardsClient {
|
||||
|
||||
private val BASE_WV_URL = "https://faisamobilex-wv.mib.com.mv"
|
||||
|
||||
private val USER_AGENT = "Mozilla/5.0 (Linux; Android ${Build.VERSION.RELEASE}; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/129.0.6668.70 Mobile Safari/537.36"
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(20, TimeUnit.SECONDS)
|
||||
.readTimeout(20, TimeUnit.SECONDS)
|
||||
@@ -20,7 +28,7 @@ class MibCardsClient {
|
||||
"mbmodel=IOS-1.0; xxid=${session.xxid}; IBSID=${session.xxid}; " +
|
||||
"mbnonce=${session.nonceGenerator}; time-tracker=597"
|
||||
|
||||
fun fetchCards(session: MibSession, loginTag: String): List<MibCard> {
|
||||
fun fetchCards(session: MibSession, loginTag: String, profileId: String = ""): List<MibCard> {
|
||||
val body = FormBody.Builder()
|
||||
.add("name", "")
|
||||
.add("start", "1")
|
||||
@@ -32,7 +40,7 @@ class MibCardsClient {
|
||||
.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("User-Agent", USER_AGENT)
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("Accept", "*/*")
|
||||
.header("Origin", BASE_WV_URL)
|
||||
@@ -55,9 +63,42 @@ class MibCardsClient {
|
||||
customerId = item.optString("customerId"),
|
||||
phoneNumber = item.optString("phoneNumber"),
|
||||
cardHolderName = item.optString("cardHolderName"),
|
||||
loginTag = loginTag
|
||||
loginTag = loginTag,
|
||||
profileId = profileId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Freezes a MIB card. action = "freeze" or "unfreeze". */
|
||||
fun setCardFreezeState(session: MibSession, cardId: String, action: String, comments: String): MibCardActionResult {
|
||||
val body = FormBody.Builder()
|
||||
.add("cardId", cardId)
|
||||
.add("comments", comments)
|
||||
.build()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("$BASE_WV_URL/ajaxDebitCard/$action")
|
||||
.post(body)
|
||||
.header("Cookie", cookieHeader(session))
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.header("X-Requested-With", "XMLHttpRequest")
|
||||
.header("Accept", "*/*")
|
||||
.header("Origin", BASE_WV_URL)
|
||||
.header("Referer", "$BASE_WV_URL//debitCards/manage?cardId=$cardId&dashurl=1")
|
||||
.build()
|
||||
|
||||
return client.newCall(request).execute().use { response ->
|
||||
val bodyStr = response.body?.string()
|
||||
?: return MibCardActionResult(false, "", "")
|
||||
val json = try { JSONObject(bodyStr) } catch (_: Exception) {
|
||||
return MibCardActionResult(false, "", "")
|
||||
}
|
||||
MibCardActionResult(
|
||||
success = json.optBoolean("success"),
|
||||
message = json.optString("reasonText"),
|
||||
currentStatusCode = json.optString("currentStatusCode")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ class MibHistoryClient {
|
||||
id = item.optString("trxNumber"),
|
||||
date = item.optString("trxDate"),
|
||||
description = item.optString("descr1").trim(),
|
||||
amount = item.optString("baseAmount", "0").toDoubleOrNull() ?: 0.0,
|
||||
amount = item.optString("foreignAmount", "0").toDoubleOrNull() ?: 0.0,
|
||||
currency = item.optString("curCodeDesc"),
|
||||
counterpartyName = item.optString("benefName").takeIf {
|
||||
it.isNotBlank() && it != "null"
|
||||
|
||||
@@ -42,7 +42,8 @@ data class MibTransferResult(
|
||||
data class MibIpsAccountInfo(
|
||||
val accountName: String,
|
||||
val accountNumber: String,
|
||||
val bankId: String
|
||||
val bankId: String,
|
||||
val currency: String = "" // "MVR", "USD", or "" if unknown
|
||||
)
|
||||
|
||||
|
||||
@@ -55,7 +56,8 @@ data class MibCard(
|
||||
val customerId: String,
|
||||
val phoneNumber: String,
|
||||
val cardHolderName: String,
|
||||
val loginTag: String
|
||||
val loginTag: String,
|
||||
val profileId: String = ""
|
||||
)
|
||||
|
||||
data class MibFinanceDeal(
|
||||
|
||||
@@ -130,7 +130,10 @@ class MibTransferClient {
|
||||
MibIpsAccountInfo(
|
||||
accountName = json.optString("accountName").trim(),
|
||||
accountNumber = accountNumber,
|
||||
bankId = json.optString("bankBic")
|
||||
bankId = json.optString("bankBic"),
|
||||
// MIB IPS only returns success for MVR cross-bank accounts;
|
||||
// USD cross-bank accounts fail this lookup entirely.
|
||||
currency = "MVR"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -156,10 +159,18 @@ class MibTransferClient {
|
||||
// accountName may be at root or inside a "data" object
|
||||
val name = json.optString("accountName").takeIf { it.isNotBlank() }
|
||||
?: json.optJSONObject("data")?.optString("accountName") ?: ""
|
||||
val currencyCode = json.optString("currencyCode").takeIf { it.isNotBlank() }
|
||||
?: json.optJSONObject("data")?.optString("currencyCode") ?: ""
|
||||
val currency = when (currencyCode) {
|
||||
"840" -> "USD"
|
||||
"462" -> "MVR"
|
||||
else -> ""
|
||||
}
|
||||
MibIpsAccountInfo(
|
||||
accountName = name.trim(),
|
||||
accountNumber = accountNumber,
|
||||
bankId = "MADVMVMV" // MIB
|
||||
bankId = "MADVMVMV", // MIB
|
||||
currency = currency
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package sh.sar.basedbank.nfc
|
||||
|
||||
import android.content.Intent
|
||||
import android.nfc.cardemulation.HostApduService
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import sh.sar.basedbank.api.bml.BmlWalletToken
|
||||
|
||||
/**
|
||||
* HCE service that emulates a BML contactless payment card.
|
||||
*
|
||||
* Implements the minimal EMV mag-stripe contactless flow:
|
||||
* SELECT PPSE → SELECT AID → GET PROCESSING OPTIONS → READ RECORD
|
||||
*
|
||||
* Each BmlWalletToken is single-use and is set via [setToken] before tapping.
|
||||
* After READ RECORD is sent the [onTransactionComplete] callback fires.
|
||||
*/
|
||||
class BmlHostCardEmulatorService : HostApduService() {
|
||||
|
||||
private var gpoSent = false
|
||||
|
||||
override fun processCommandApdu(commandApdu: ByteArray?, extras: Bundle?): ByteArray {
|
||||
if (commandApdu == null) return SW_UNKNOWN_ERROR
|
||||
val apdu = Apdu(commandApdu)
|
||||
if (apdu.isError) return apdu.errorResponse()
|
||||
|
||||
return when (apdu.ins) {
|
||||
INS_SELECT -> handleSelect(apdu)
|
||||
INS_GPO -> handleGpo()
|
||||
INS_READ -> handleReadRecord()
|
||||
else -> SW_INS_NOT_SUPPORTED
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDeactivated(reason: Int) {
|
||||
if (!gpoSent) onTransactionComplete?.invoke(false)
|
||||
gpoSent = false
|
||||
}
|
||||
|
||||
// ── APDU handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
private fun handleSelect(apdu: Apdu): ByteArray {
|
||||
val data = apdu.data ?: return SW_UNKNOWN_ERROR
|
||||
|
||||
if (data.contentEquals(PPSE_BYTES)) {
|
||||
val token = activeToken ?: run { launchPromptActivity(); return SW_UNKNOWN_ERROR }
|
||||
return hexToBytes(buildSelectPpseResponse(token.appCode, applicationLabel(token.appCode), "01"))
|
||||
}
|
||||
|
||||
val token = activeToken ?: return SW_UNKNOWN_ERROR
|
||||
return if (data.contentEquals(hexToBytes(token.appCode))) {
|
||||
hexToBytes(buildSelectAidResponse(token.appCode, applicationLabel(token.appCode)))
|
||||
} else {
|
||||
SW_UNKNOWN_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
private fun launchPromptActivity() {
|
||||
val intent = Intent(applicationContext, BmlTapToPayActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun handleGpo(): ByteArray {
|
||||
gpoSent = true
|
||||
// AIP=0080 (mag-stripe mode), AFL=08010100 (SFI=1, record 1-1, offline 0)
|
||||
val miscData = "008008010100"
|
||||
val body = tlv("80", miscData)
|
||||
return hexToBytes(body + SW_OK_HEX)
|
||||
}
|
||||
|
||||
private fun handleReadRecord(): ByteArray {
|
||||
val token = activeToken ?: return SW_UNKNOWN_ERROR
|
||||
val track2 = buildTrack2(token)
|
||||
val body = tlv("70", tlv("57", track2))
|
||||
val response = hexToBytes(body + SW_OK_HEX)
|
||||
onTransactionComplete?.invoke(true)
|
||||
return response
|
||||
}
|
||||
|
||||
// ── TLV / APDU response builders ───────────────────────────────────────────
|
||||
|
||||
private fun buildSelectPpseResponse(aid: String, label: String, priority: String): String {
|
||||
val priorityTlv = tlv("87", priority) // tag 87
|
||||
val aidTlv = tlv("4F", aid) // tag 4F (ADF Name)
|
||||
val appEntry = tlv("61", aidTlv + priorityTlv) // tag 61
|
||||
val ppseTlv = tlv("84", PPSE_HEX) // tag 84 (DF Name)
|
||||
val inner = tlv("BF0C", appEntry) // tag BF0C
|
||||
val propTemplate = tlv("A5", inner) // tag A5
|
||||
val fci = tlv("6F", ppseTlv + propTemplate) // tag 6F
|
||||
return fci + SW_OK_HEX
|
||||
}
|
||||
|
||||
private fun buildSelectAidResponse(aid: String, label: String): String {
|
||||
val aidTlv = tlv("84", aid) // tag 84
|
||||
val labelTlv = tlv("50", asciiToHex(label)) // tag 50
|
||||
val pdolTlv = tlv("9F38", "9F6602") // PDOL: TTQ 2 bytes
|
||||
val propTemplate = tlv("A5", labelTlv + pdolTlv) // tag A5
|
||||
val fci = tlv("6F", aidTlv + propTemplate) // tag 6F
|
||||
return fci + SW_OK_HEX
|
||||
}
|
||||
|
||||
private fun buildTrack2(token: BmlWalletToken): String {
|
||||
var t2 = "${token.token}D${token.expiry}${token.serviceCode}${token.data}"
|
||||
if (t2.length % 2 != 0) t2 += "F"
|
||||
return t2
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Build BER-TLV: tag (hex string, 1 or 2 bytes) + DER length + data (hex string). */
|
||||
private fun tlv(tagHex: String, dataHex: String): String {
|
||||
val lenBytes = dataHex.length / 2
|
||||
val lenHex = when {
|
||||
lenBytes <= 0x7F -> lenBytes.toHexByte()
|
||||
lenBytes <= 0xFF -> "81" + lenBytes.toHexByte()
|
||||
else -> "82" + (lenBytes shr 8).toHexByte() + (lenBytes and 0xFF).toHexByte()
|
||||
}
|
||||
return tagHex + lenHex + dataHex
|
||||
}
|
||||
|
||||
private fun Int.toHexByte(): String = toString(16).padStart(2, '0').uppercase()
|
||||
|
||||
private fun asciiToHex(s: String): String =
|
||||
s.toByteArray(Charsets.US_ASCII).joinToString("") { "%02X".format(it) }
|
||||
|
||||
private fun hexToBytes(hex: String): ByteArray {
|
||||
val s = hex.uppercase()
|
||||
return ByteArray(s.length / 2) { i ->
|
||||
s.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||
}
|
||||
}
|
||||
|
||||
// ── APDU parser ─────────────────────────────────────────────────────────────
|
||||
|
||||
private inner class Apdu(raw: ByteArray) {
|
||||
val isError: Boolean
|
||||
val ins: Int
|
||||
val data: ByteArray?
|
||||
|
||||
init {
|
||||
if (raw.size < 5) {
|
||||
isError = true; ins = -1; data = null
|
||||
} else {
|
||||
isError = false
|
||||
ins = raw[1].toInt() and 0xFF
|
||||
val lc = if (raw.size > 4) raw[4].toInt() and 0xFF else 0
|
||||
data = if (lc > 0 && raw.size >= 5 + lc) raw.copyOfRange(5, 5 + lc) else null
|
||||
}
|
||||
}
|
||||
|
||||
fun errorResponse() = SW_UNKNOWN_ERROR
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BmlHCE"
|
||||
|
||||
private const val INS_SELECT = 0xA4
|
||||
private const val INS_GPO = 0xA8
|
||||
private const val INS_READ = 0xB2
|
||||
|
||||
private val PPSE_HEX = "325041592E5359532E4444463031" // "2PAY.SYS.DDF01"
|
||||
private val PPSE_BYTES = byteArrayOf(
|
||||
0x32,0x50,0x41,0x59,0x2E,0x53,0x59,0x53,0x2E,0x44,0x44,0x46,0x30,0x31
|
||||
)
|
||||
|
||||
private const val SW_OK_HEX = "9000"
|
||||
private val SW_UNKNOWN_ERROR = byteArrayOf(0x6F.toByte(), 0x00.toByte())
|
||||
private val SW_INS_NOT_SUPPORTED = byteArrayOf(0x6D.toByte(), 0x00.toByte())
|
||||
|
||||
@Volatile var activeToken: BmlWalletToken? = null
|
||||
@Volatile var onTransactionComplete: ((success: Boolean) -> Unit)? = null
|
||||
|
||||
fun setToken(token: BmlWalletToken) { activeToken = token }
|
||||
fun clearToken() { activeToken = null }
|
||||
|
||||
fun applicationLabel(aidHex: String): String = when {
|
||||
aidHex.startsWith("A0000000031010", ignoreCase = true) -> "VISA"
|
||||
aidHex.startsWith("A0000000041010", ignoreCase = true) -> "MASTERCARD"
|
||||
aidHex.startsWith("A000000025", ignoreCase = true) -> "AMEX"
|
||||
else -> "BML"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package sh.sar.basedbank.nfc
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import sh.sar.basedbank.MainActivity
|
||||
|
||||
/**
|
||||
* Fallback entry point — redirects to MainActivity which routes to the in-app tap-to-pay screen.
|
||||
*/
|
||||
class BmlTapToPayActivity : AppCompatActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
startActivity(Intent(this, MainActivity::class.java).apply {
|
||||
action = "sh.sar.basedbank.TAP_TO_PAY"
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
})
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package sh.sar.basedbank.service
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlNotificationsClient
|
||||
import sh.sar.basedbank.api.mib.MibActivityHistoryClient
|
||||
import sh.sar.basedbank.ui.home.AppNotification
|
||||
import sh.sar.basedbank.util.NotificationsCache
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class NotificationPollingService : Service() {
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val app get() = application as BasedBankApp
|
||||
private val notifIdCounter = AtomicInteger(2000)
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createChannels()
|
||||
startForeground(SERVICE_NOTIF_ID, buildServiceNotification())
|
||||
startPolling()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY
|
||||
|
||||
override fun onDestroy() {
|
||||
scope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startPolling() {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
runCatching { poll() }
|
||||
delay(POLL_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun poll() {
|
||||
pollBml()
|
||||
pollMib()
|
||||
}
|
||||
|
||||
private suspend fun pollBml() {
|
||||
val sessions = app.bmlSessions.toMap()
|
||||
if (sessions.isEmpty()) return
|
||||
val client = BmlNotificationsClient()
|
||||
sessions.forEach { (loginId, session) ->
|
||||
val result = try { client.fetchNotifications(session, loginId, page = 1) }
|
||||
catch (_: Exception) { return@forEach }
|
||||
if (result.items.isEmpty()) return@forEach
|
||||
|
||||
val cached = NotificationsCache.loadBml(this@NotificationPollingService, loginId)
|
||||
if (cached.isEmpty()) {
|
||||
NotificationsCache.saveBml(this@NotificationPollingService, loginId, result.items)
|
||||
return@forEach
|
||||
}
|
||||
val cachedIds = cached.map { it.id }.toSet()
|
||||
val newItems = result.items.filter { it.id !in cachedIds }
|
||||
if (newItems.isNotEmpty()) {
|
||||
NotificationsCache.saveBml(this@NotificationPollingService, loginId, result.items)
|
||||
val channelId = ensureLoginChannel("BML", loginId)
|
||||
newItems.forEach { postBankNotification(it, channelId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun pollMib() {
|
||||
val sessions = app.mibSessions.toMap()
|
||||
if (sessions.isEmpty()) return
|
||||
val client = MibActivityHistoryClient()
|
||||
sessions.forEach { (loginId, session) ->
|
||||
val result = try { client.fetchActivity(session, loginId, 1, 100) }
|
||||
catch (_: Exception) { return@forEach }
|
||||
|
||||
val readIds = NotificationsCache.getMibReadIds(this@NotificationPollingService)
|
||||
val cached = NotificationsCache.loadMib(this@NotificationPollingService, loginId, readIds)
|
||||
if (cached.isEmpty()) {
|
||||
NotificationsCache.saveMib(this@NotificationPollingService, loginId, result.items)
|
||||
return@forEach
|
||||
}
|
||||
val cachedIds = cached.map { it.id }.toSet()
|
||||
val newItems = result.items.filter { it.id !in cachedIds }
|
||||
if (newItems.isNotEmpty()) {
|
||||
val all = (cached + newItems).sortedByDescending { it.timestampMs }
|
||||
NotificationsCache.saveMib(this@NotificationPollingService, loginId, all)
|
||||
val channelId = ensureLoginChannel("MIB", loginId)
|
||||
newItems.forEach { postBankNotification(it, channelId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureLoginChannel(bank: String, loginId: String): String {
|
||||
val channelId = "bank_${bank.lowercase()}_$loginId"
|
||||
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (nm.getNotificationChannel(channelId) == null) {
|
||||
val profileName = when (bank) {
|
||||
"BML" -> app.bmlProfilesMap[loginId]?.firstOrNull()?.name
|
||||
"MIB" -> app.mibProfilesMap[loginId]?.firstOrNull()?.name
|
||||
else -> null
|
||||
} ?: loginId
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(channelId, "$bank · $profileName", NotificationManager.IMPORTANCE_DEFAULT)
|
||||
)
|
||||
}
|
||||
return channelId
|
||||
}
|
||||
|
||||
private fun postBankNotification(notif: AppNotification, channelId: String) {
|
||||
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
val pi = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
packageManager.getLaunchIntentForPackage(packageName),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
val n = Notification.Builder(this, channelId)
|
||||
.setSmallIcon(R.drawable.ic_bell)
|
||||
.setContentTitle(notif.title)
|
||||
.setContentText(notif.message)
|
||||
.setContentIntent(pi)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
nm.notify(notifIdCounter.getAndIncrement(), n)
|
||||
}
|
||||
|
||||
private fun buildServiceNotification(): Notification {
|
||||
val pi = PendingIntent.getActivity(
|
||||
this, 0,
|
||||
packageManager.getLaunchIntentForPackage(packageName),
|
||||
PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
return Notification.Builder(this, CHANNEL_SERVICE)
|
||||
.setSmallIcon(R.drawable.ic_bell)
|
||||
.setContentTitle(getString(R.string.notif_service_title))
|
||||
.setContentText(getString(R.string.notif_service_desc))
|
||||
.setContentIntent(pi)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun createChannels() {
|
||||
val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_SERVICE,
|
||||
getString(R.string.notif_channel_service),
|
||||
NotificationManager.IMPORTANCE_MIN
|
||||
).apply { setShowBadge(false) }
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val POLL_INTERVAL_MS = 30_000L
|
||||
private const val SERVICE_NOTIF_ID = 1001
|
||||
const val CHANNEL_SERVICE = "notif_polling_service"
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,10 @@ class AccountHistoryAdapter(
|
||||
|
||||
private sealed class Item {
|
||||
data class DateHeader(val label: String) : Item()
|
||||
data class Trx(val transaction: BankTransaction) : Item()
|
||||
data class Trx(val transaction: BankTransaction, val showDate: Boolean = false) : Item()
|
||||
}
|
||||
|
||||
private val pendingItems = mutableListOf<Item>()
|
||||
private val displayItems = mutableListOf<Item>()
|
||||
private var lastInsertedDateKey = ""
|
||||
private val imageCache = mutableMapOf<String, Bitmap>()
|
||||
@@ -37,15 +38,22 @@ class AccountHistoryAdapter(
|
||||
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
||||
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
||||
var onTransferClick: ((BankAccount) -> Unit)? = null
|
||||
var onDefaultToggle: ((Boolean) -> Unit)? = null
|
||||
private var hideAmounts: Boolean = false
|
||||
var showDefaultToggle: Boolean = false
|
||||
set(value) { if (field == value) return; field = value; notifyItemChanged(0) }
|
||||
var isDefaultAccount: Boolean = false
|
||||
set(value) { if (field == value) return; field = value; notifyItemChanged(0) }
|
||||
|
||||
fun setHideAmounts(hide: Boolean) {
|
||||
if (hideAmounts == hide) return
|
||||
hideAmounts = hide
|
||||
notifyItemChanged(0) // refresh header card
|
||||
// refresh all transaction rows
|
||||
for (i in pendingItems.indices) {
|
||||
if (pendingItems[i] is Item.Trx) notifyItemChanged(i + 1)
|
||||
}
|
||||
for (i in displayItems.indices) {
|
||||
if (displayItems[i] is Item.Trx) notifyItemChanged(i + 1)
|
||||
if (displayItems[i] is Item.Trx) notifyItemChanged(1 + pendingItems.size + i)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +61,7 @@ class AccountHistoryAdapter(
|
||||
imageCache[counterpartyName] = bitmap
|
||||
displayItems.forEachIndexed { i, item ->
|
||||
if (item is Item.Trx && item.transaction.counterpartyName == counterpartyName)
|
||||
notifyItemChanged(i + 1) // +1 for account header at position 0
|
||||
notifyItemChanged(1 + pendingItems.size + i)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,10 +69,28 @@ class AccountHistoryAdapter(
|
||||
iconUrlCache[url] = bitmap
|
||||
displayItems.forEachIndexed { i, item ->
|
||||
if (item is Item.Trx && item.transaction.iconUrl == url)
|
||||
notifyItemChanged(i + 1) // +1 for account header at position 0
|
||||
notifyItemChanged(1 + pendingItems.size + i)
|
||||
}
|
||||
}
|
||||
|
||||
fun setPendingTransactions(transactions: List<BankTransaction>) {
|
||||
setLeadingSections(listOf("Pending" to transactions))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets one or more labeled sections that render above the main statement list
|
||||
* (e.g. card "Outstanding" + "Unbilled"). Empty sections are skipped.
|
||||
*/
|
||||
fun setLeadingSections(sections: List<Pair<String, List<BankTransaction>>>) {
|
||||
pendingItems.clear()
|
||||
for ((label, transactions) in sections) {
|
||||
if (transactions.isEmpty()) continue
|
||||
pendingItems.add(Item.DateHeader(label))
|
||||
for (trx in transactions) pendingItems.add(Item.Trx(trx, showDate = true))
|
||||
}
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
private var _showLoadingFooter = false
|
||||
var showLoadingFooter: Boolean
|
||||
get() = _showLoadingFooter
|
||||
@@ -122,18 +148,24 @@ class AccountHistoryAdapter(
|
||||
displayItems.add(Item.Trx(trx))
|
||||
}
|
||||
val added = displayItems.size - oldCount
|
||||
if (added > 0) notifyItemRangeInserted(1 + oldCount, added) // +1 for account header
|
||||
if (added > 0) notifyItemRangeInserted(1 + pendingItems.size + oldCount, added)
|
||||
}
|
||||
|
||||
// Position 0 = account header card
|
||||
// Positions 1..displayItems.size = date headers + transactions
|
||||
// Positions 1..pendingItems.size = pending header + pending transactions
|
||||
// Positions 1+pendingItems.size..1+pendingItems.size+displayItems.size = date headers + transactions
|
||||
// Last position = loading footer when showLoadingFooter = true
|
||||
override fun getItemCount() = 1 + displayItems.size + if (_showLoadingFooter) 1 else 0
|
||||
override fun getItemCount() = 1 + pendingItems.size + displayItems.size + if (_showLoadingFooter) 1 else 0
|
||||
|
||||
private fun itemAt(position: Int): Item {
|
||||
val idx = position - 1
|
||||
return if (idx < pendingItems.size) pendingItems[idx] else displayItems[idx - pendingItems.size]
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int) = when {
|
||||
position == 0 -> TYPE_HEADER
|
||||
_showLoadingFooter && position == itemCount - 1 -> TYPE_LOADING
|
||||
else -> when (displayItems[position - 1]) {
|
||||
else -> when (itemAt(position)) {
|
||||
is Item.DateHeader -> TYPE_DATE_HEADER
|
||||
is Item.Trx -> TYPE_TRANSACTION
|
||||
}
|
||||
@@ -152,8 +184,11 @@ class AccountHistoryAdapter(
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
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)
|
||||
is DateHeaderVH -> holder.bind((itemAt(position) as Item.DateHeader).label)
|
||||
is TransactionVH -> {
|
||||
val item = itemAt(position) as Item.Trx
|
||||
holder.bind(item.transaction, item.showDate)
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
@@ -174,6 +209,20 @@ class AccountHistoryAdapter(
|
||||
b.llHeaderBlocked.visibility = View.GONE
|
||||
}
|
||||
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
||||
|
||||
if (showDefaultToggle) {
|
||||
b.dividerDefaultAccount.visibility = View.VISIBLE
|
||||
b.llDefaultAccountRow.visibility = View.VISIBLE
|
||||
b.switchDefaultAccount.setOnCheckedChangeListener(null)
|
||||
b.switchDefaultAccount.isChecked = isDefaultAccount
|
||||
b.switchDefaultAccount.setOnCheckedChangeListener { _, checked ->
|
||||
isDefaultAccount = checked
|
||||
onDefaultToggle?.invoke(checked)
|
||||
}
|
||||
} else {
|
||||
b.dividerDefaultAccount.visibility = View.GONE
|
||||
b.llDefaultAccountRow.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,7 +233,7 @@ class AccountHistoryAdapter(
|
||||
|
||||
inner class TransactionVH(private val b: ItemTransactionBinding) :
|
||||
RecyclerView.ViewHolder(b.root) {
|
||||
fun bind(trx: BankTransaction) {
|
||||
fun bind(trx: BankTransaction, showDate: Boolean = false) {
|
||||
val isCredit = trx.amount >= 0
|
||||
val color = sourceColor(trx.source)
|
||||
val name = trx.counterpartyName ?: trx.description
|
||||
@@ -220,7 +269,7 @@ class AccountHistoryAdapter(
|
||||
b.tvCounterparty.visibility = View.GONE
|
||||
}
|
||||
|
||||
b.tvDate.text = formatTime(trx.date)
|
||||
b.tvDate.text = if (showDate) formatDateOnly(trx.date) else formatTime(trx.date)
|
||||
|
||||
if (hideAmounts) {
|
||||
b.tvAmount.text = "${trx.currency} ••••••"
|
||||
@@ -267,6 +316,7 @@ class AccountHistoryAdapter(
|
||||
private val MIB_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
private val BML_FMT = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US)
|
||||
private val DATE_HEADER_FMT = SimpleDateFormat("MMMM d, yyyy", Locale.getDefault())
|
||||
private val DATE_ONLY_FMT = SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
|
||||
private val TIME_FMT = SimpleDateFormat("h:mm a", Locale.getDefault())
|
||||
private val FULL_DATE_FMT = SimpleDateFormat("MMM d, yyyy · h:mm a", Locale.getDefault())
|
||||
|
||||
@@ -288,6 +338,11 @@ class AccountHistoryAdapter(
|
||||
return DATE_HEADER_FMT.format(date)
|
||||
}
|
||||
|
||||
fun formatDateOnly(raw: String): String {
|
||||
val date = parseDate(raw) ?: return raw.take(10)
|
||||
return DATE_ONLY_FMT.format(date)
|
||||
}
|
||||
|
||||
fun formatTime(raw: String): String {
|
||||
val date = parseDate(raw) ?: return ""
|
||||
return TIME_FMT.format(date)
|
||||
|
||||
@@ -24,12 +24,15 @@ import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankServerException
|
||||
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
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.AccountListParser
|
||||
import sh.sar.basedbank.util.ContactImageCache
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.HistoryFetcher
|
||||
import sh.sar.basedbank.util.MerchantIconCache
|
||||
|
||||
@@ -80,6 +83,24 @@ class AccountHistoryFragment : Fragment() {
|
||||
}
|
||||
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||
|
||||
// Show default account toggle only for non-card, non-M-Faisa accounts.
|
||||
// M-Faisa pockets (including PayPal) cannot be set as the default transfer/QR account.
|
||||
val isCard = AccountListParser.from(account)?.isCard ?: false
|
||||
if (!isCard && account.bank != "MFAISA") {
|
||||
val store = CredentialStore(requireContext())
|
||||
adapter.showDefaultToggle = true
|
||||
adapter.isDefaultAccount = store.getDefaultAccountNumber() == account.accountNumber
|
||||
adapter.onDefaultToggle = { isChecked ->
|
||||
if (isChecked) {
|
||||
store.setDefaultAccountNumber(account.accountNumber)
|
||||
} else {
|
||||
if (store.getDefaultAccountNumber() == account.accountNumber) {
|
||||
store.setDefaultAccountNumber(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(requireContext())
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
@@ -119,6 +140,7 @@ class AccountHistoryFragment : Fragment() {
|
||||
}
|
||||
(activity as? HomeActivity)?.setRefreshing(true)
|
||||
loadNextPage()
|
||||
loadPendingTransactions()
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
if (isLoading) {
|
||||
@@ -131,7 +153,12 @@ class AccountHistoryFragment : Fragment() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (::account.isInitialized) requireActivity().title = account.accountBriefName
|
||||
if (::account.isInitialized) {
|
||||
requireActivity().title = account.accountBriefName
|
||||
if (adapter.showDefaultToggle) {
|
||||
adapter.isDefaultAccount = CredentialStore(requireContext()).getDefaultAccountNumber() == account.accountNumber
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterAndDisplay() {
|
||||
@@ -160,6 +187,7 @@ class AccountHistoryFragment : Fragment() {
|
||||
binding.emptyView.visibility = View.GONE
|
||||
}
|
||||
loadNextPage()
|
||||
loadPendingTransactions()
|
||||
}
|
||||
|
||||
private fun loadNextPage() {
|
||||
@@ -201,6 +229,13 @@ class AccountHistoryFragment : Fragment() {
|
||||
}
|
||||
(activity as? HomeActivity)?.hideConnectivityBanner()
|
||||
|
||||
fetcher.takeCardPendingSections()?.let { (outstanding, unbilled) ->
|
||||
adapter.setLeadingSections(listOf(
|
||||
"Outstanding" to outstanding,
|
||||
"Unbilled" to unbilled
|
||||
))
|
||||
}
|
||||
|
||||
if (transactions.isNotEmpty()) {
|
||||
val existingIds = allTransactions.map { it.id }.toHashSet()
|
||||
val newOnes = transactions.filter { it.id !in existingIds }
|
||||
@@ -226,6 +261,26 @@ class AccountHistoryFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadPendingTransactions() {
|
||||
if (account.bank != "BML" || account.profileType != "BML") return
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val session = app.bmlSessionFor(account) ?: return
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val pending = withContext(Dispatchers.IO) {
|
||||
BmlHistoryClient().fetchPendingHistory(
|
||||
session = session,
|
||||
accountId = account.internalId,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
accountNumber = account.accountNumber
|
||||
)
|
||||
}
|
||||
if (_binding == null) return@launch
|
||||
adapter.setPendingTransactions(pending)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadContactImage(name: String) {
|
||||
if (!pendingImageNames.add(name)) return
|
||||
val contact = viewModel.contacts.value?.firstOrNull { it.benefNickName == name } ?: return
|
||||
|
||||
@@ -75,10 +75,12 @@ class AccountsAdapter(
|
||||
"BML" -> "Bank of Maldives"
|
||||
"FAHIPAY" -> "Fahipay"
|
||||
"MIB" -> "Maldives Islamic Bank"
|
||||
"MFAISA" -> if (account.profileType == "MFAISA_PAYPAL") "PayPal · M-Faisa" else "M-Faisa"
|
||||
else -> account.bank
|
||||
}
|
||||
val profileLabel = when (account.bank) {
|
||||
"MIB" -> account.productCode.ifBlank { account.profileName }
|
||||
"MFAISA" -> "" // bank-level grouping is already specific (M-Faisa / PayPal · M-Faisa)
|
||||
else -> account.profileName
|
||||
}
|
||||
return if (profileLabel.isNotBlank()) "$profileLabel · $bankName" else bankName
|
||||
@@ -144,6 +146,7 @@ class AccountsAdapter(
|
||||
"BML" -> R.drawable.bml_logo_vector
|
||||
"FAHIPAY" -> R.drawable.fahipay_logo
|
||||
"MIB" -> R.drawable.mib_logo
|
||||
"MFAISA" -> R.drawable.ooredoo_logo
|
||||
else -> null
|
||||
}
|
||||
if (staticLogo != null) binding.ivBankLogo.setImageResource(staticLogo)
|
||||
|
||||
@@ -89,6 +89,52 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
categories = cats.filter { it.id != "BML" }
|
||||
if (selectedDest?.isBml == false) setupCategoryDropdown()
|
||||
}
|
||||
|
||||
applyPrefillArgs()
|
||||
}
|
||||
|
||||
private fun applyPrefillArgs() {
|
||||
val args = arguments ?: return
|
||||
val bmlProfileId = args.getString(ARG_BML_PROFILE_ID)
|
||||
val accountNumber = args.getString(ARG_ACCOUNT_NUMBER)
|
||||
val recipientName = args.getString(ARG_RECIPIENT_NAME)
|
||||
val currency = args.getString(ARG_CURRENCY)
|
||||
|
||||
if (bmlProfileId != null) {
|
||||
val match = destinations.firstOrNull { it.isBml && it.bmlLoginId == bmlProfileId }
|
||||
if (match != null) {
|
||||
selectedDest = match
|
||||
binding.actvDestination.setText(match.label, false)
|
||||
updateMibOnlyVisibility()
|
||||
}
|
||||
}
|
||||
|
||||
if (accountNumber != null) {
|
||||
binding.etAccount.setText(accountNumber)
|
||||
}
|
||||
|
||||
// Skip lookup only when we have a MIB-verified name+currency from the caller.
|
||||
if (selectedDest != null && accountNumber != null &&
|
||||
!recipientName.isNullOrBlank() && !currency.isNullOrBlank()
|
||||
) {
|
||||
val bankBic = when {
|
||||
accountNumber.matches(Regex("^9\\d{16}$")) -> "MADVMVMV"
|
||||
accountNumber.matches(Regex("^7\\d{12}$")) -> "MALBMVMV"
|
||||
else -> ""
|
||||
}
|
||||
val trnType = if (accountNumber.matches(Regex("^9\\d{16}$"))) "DOT" else "IAT"
|
||||
val validation = BmlAccountValidation(
|
||||
trnType = trnType,
|
||||
validationType = "prefilled",
|
||||
account = accountNumber,
|
||||
originalInput = accountNumber,
|
||||
name = recipientName,
|
||||
alias = null,
|
||||
currency = currency,
|
||||
agnt = bankBic.takeIf { it.isNotBlank() }
|
||||
)
|
||||
showLookupResult(validation, accountNumber)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDestinations(): List<DestinationOption> {
|
||||
@@ -517,5 +563,24 @@ class AddContactSheetFragment : BottomSheetDialogFragment() {
|
||||
companion object {
|
||||
// BML's internal UUID for MIB bank — used as the "swift" field when saving DOT contacts
|
||||
private const val MIB_SWIFT_ON_BML = "F4E79935-3E73-E611-80DD-00155D020F0A"
|
||||
|
||||
private const val ARG_BML_PROFILE_ID = "bml_profile_id"
|
||||
private const val ARG_ACCOUNT_NUMBER = "account_number"
|
||||
private const val ARG_RECIPIENT_NAME = "recipient_name"
|
||||
private const val ARG_CURRENCY = "currency"
|
||||
|
||||
fun newInstance(
|
||||
bmlProfileId: String? = null,
|
||||
accountNumber: String? = null,
|
||||
recipientName: String? = null,
|
||||
currency: String? = null
|
||||
) = AddContactSheetFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
if (bmlProfileId != null) putString(ARG_BML_PROFILE_ID, bmlProfileId)
|
||||
if (accountNumber != null) putString(ARG_ACCOUNT_NUMBER, accountNumber)
|
||||
if (recipientName != null) putString(ARG_RECIPIENT_NAME, recipientName)
|
||||
if (currency != null) putString(ARG_CURRENCY, currency)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
data class AppNotification(
|
||||
val id: String,
|
||||
val bank: String, // "BML" or "MIB"
|
||||
val loginId: String, // key in bmlSessions / mibSessions
|
||||
val group: String, // "ALERTS" or "INFORMATION"
|
||||
val title: String,
|
||||
val message: String,
|
||||
val timestampMs: Long,
|
||||
val isRead: Boolean,
|
||||
val detailFields: List<Pair<String, String>> = emptyList()
|
||||
)
|
||||
@@ -5,7 +5,6 @@ import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
@@ -33,6 +32,8 @@ import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.databinding.FragmentBmlQrPayBinding
|
||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.RecentPick
|
||||
import sh.sar.basedbank.util.RecentsCache
|
||||
import sh.sar.basedbank.util.Totp
|
||||
|
||||
class BmlQrPayFragment : Fragment() {
|
||||
@@ -126,7 +127,6 @@ class BmlQrPayFragment : Fragment() {
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -139,7 +139,7 @@ class BmlQrPayFragment : Fragment() {
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val info = withContext(Dispatchers.IO) {
|
||||
try { BmlQrPayClient().lookupPayRequest(session, base64Url) }
|
||||
try { BmlQrPayClient().lookupPayRequest(session, qrUrl) }
|
||||
catch (_: Exception) { null }
|
||||
}
|
||||
if (_binding == null) return@launch
|
||||
@@ -150,6 +150,19 @@ class BmlQrPayFragment : Fragment() {
|
||||
return@launch
|
||||
}
|
||||
merchantInfo = info
|
||||
if (info.amount == 0.0) {
|
||||
val qrUrl = arguments?.getString(ARG_QR_URL)
|
||||
if (qrUrl != null) {
|
||||
RecentsCache.save(requireContext(), RecentPick(
|
||||
accountNumber = "bmlqr:$qrUrl",
|
||||
displayName = info.merchantName,
|
||||
subtitle = info.merchantAddress.ifBlank { "BML Merchant" },
|
||||
colorHex = "#0066A1",
|
||||
imageHash = null,
|
||||
isProfileImage = false
|
||||
))
|
||||
}
|
||||
}
|
||||
populateMerchant(info)
|
||||
}
|
||||
}
|
||||
@@ -315,6 +328,7 @@ class BmlQrPayFragment : Fragment() {
|
||||
.setTitle(R.string.bml_qr_payment_success)
|
||||
.setView(container)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
.setCancelable(false)
|
||||
|
||||
@@ -0,0 +1,561 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.content.Context
|
||||
import android.graphics.*
|
||||
import android.os.Bundle
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.*
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import android.view.animation.LinearInterpolator
|
||||
import android.animation.AnimatorListenerAdapter
|
||||
import android.animation.Animator
|
||||
import android.widget.FrameLayout
|
||||
import android.graphics.Typeface
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.appcompat.content.res.AppCompatResources
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.graphics.drawable.DrawableCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import sh.sar.basedbank.R
|
||||
import kotlin.math.*
|
||||
|
||||
class CircularNavFragment : Fragment() {
|
||||
|
||||
private var wheelView: CircularWheelView? = null
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
val ctx = requireContext()
|
||||
val colorPrimary = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorPrimary, Color.RED)
|
||||
val colorSurface = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSurface, Color.WHITE)
|
||||
val colorOnSurface = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, Color.DKGRAY)
|
||||
|
||||
fun dp(v: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, ctx.resources.displayMetrics)
|
||||
|
||||
val root = android.widget.LinearLayout(ctx).apply {
|
||||
orientation = android.widget.LinearLayout.VERTICAL
|
||||
setBackgroundColor(colorSurface)
|
||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
}
|
||||
|
||||
// Wheel area (weight 1, fills remaining space)
|
||||
val wheelContainer = FrameLayout(ctx).apply {
|
||||
layoutParams = android.widget.LinearLayout.LayoutParams(
|
||||
android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f
|
||||
)
|
||||
}
|
||||
val prefs = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
wheelView = CircularWheelView(ctx).apply {
|
||||
layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)
|
||||
wheelAngle = prefs.getFloat("circular_wheel_angle", 0f)
|
||||
val savedSlots = NavCustomization.getCircularSlots(prefs).map { id ->
|
||||
val def = NavCustomization.ALL_SWAPPABLE.find { it.id == id }!!
|
||||
CircularWheelView.WheelItem(def.id, def.iconRes, ctx.getString(def.titleRes))
|
||||
}
|
||||
items = listOf(
|
||||
savedSlots[3], // 4 o'clock (strip slot 3)
|
||||
CircularWheelView.WheelItem(R.id.nav_dashboard, R.drawable.ic_nav_dashboard, ctx.getString(R.string.nav_dashboard)), // 6 o'clock
|
||||
CircularWheelView.WheelItem(R.id.nav_more, R.drawable.ic_nav_more, ctx.getString(R.string.nav_more)), // 8 o'clock
|
||||
savedSlots[0], // 10 o'clock (strip slot 0 — first in strip)
|
||||
savedSlots[1], // 12 o'clock (strip slot 1)
|
||||
savedSlots[2], // 2 o'clock (strip slot 2)
|
||||
)
|
||||
accentColor = colorPrimary
|
||||
surfaceColor = colorSurface
|
||||
labelColor = colorOnSurface
|
||||
onItemClick = { navId -> (activity as? HomeActivity)?.navigateTo(navId) }
|
||||
onCenterClick = { /* unused: tap on unlocked center locks the wheel */ }
|
||||
onWheelCenterLockedTap = { (activity as? HomeActivity)?.notifyWheelLockTap() }
|
||||
}
|
||||
wheelContainer.addView(wheelView)
|
||||
|
||||
// App icon centered at the bottom
|
||||
val iconSz = dp(48f).toInt()
|
||||
val footerIcon = android.widget.ImageView(ctx).apply {
|
||||
setImageDrawable(ctx.packageManager.getApplicationIcon(ctx.packageName))
|
||||
layoutParams = android.widget.LinearLayout.LayoutParams(iconSz, iconSz).also {
|
||||
it.gravity = Gravity.CENTER_HORIZONTAL
|
||||
it.topMargin = dp(12f).toInt()
|
||||
it.bottomMargin = dp(16f).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
root.addView(wheelContainer)
|
||||
root.addView(footerIcon)
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets ->
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
(footerIcon.layoutParams as android.widget.LinearLayout.LayoutParams).bottomMargin = dp(16f).toInt() + bars.bottom
|
||||
footerIcon.requestLayout()
|
||||
insets
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().invalidateOptionsMenu()
|
||||
val ctx = requireContext()
|
||||
val toolbar = requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar)
|
||||
requireActivity().title = ""
|
||||
|
||||
val textColor = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.DKGRAY)
|
||||
|
||||
val container = android.widget.TextView(ctx).apply {
|
||||
text = getString(R.string.app_name)
|
||||
setTextColor(textColor)
|
||||
textSize = 20f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
tag = "wheel_title"
|
||||
}
|
||||
|
||||
toolbar.addView(container, Toolbar.LayoutParams(
|
||||
Toolbar.LayoutParams.WRAP_CONTENT,
|
||||
Toolbar.LayoutParams.WRAP_CONTENT,
|
||||
Gravity.CENTER
|
||||
))
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
wheelView?.let { wv ->
|
||||
requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
.edit().putFloat("circular_wheel_angle", wv.wheelAngle).apply()
|
||||
}
|
||||
val toolbar = requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar)
|
||||
toolbar.findViewWithTag<android.view.View>("wheel_title")?.let { toolbar.removeView(it) }
|
||||
requireActivity().invalidateOptionsMenu()
|
||||
}
|
||||
|
||||
fun unlockWheelLock() {
|
||||
wheelView?.unlockWheel()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom wheel view
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class CircularWheelView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null
|
||||
) : View(context, attrs) {
|
||||
|
||||
data class WheelItem(
|
||||
val navId: Int,
|
||||
@DrawableRes val iconRes: Int,
|
||||
val label: String
|
||||
)
|
||||
|
||||
// ---- public properties ------------------------------------------------
|
||||
|
||||
var items: List<WheelItem> = emptyList()
|
||||
set(value) {
|
||||
field = value
|
||||
iconBitmaps = arrayOfNulls(value.size)
|
||||
if (cx > 0f) reloadBitmaps()
|
||||
invalidate()
|
||||
}
|
||||
|
||||
var accentColor: Int = Color.RED
|
||||
set(value) { field = value; if (cx > 0f) reloadBitmaps(); invalidate() }
|
||||
|
||||
var surfaceColor: Int = Color.WHITE
|
||||
set(value) { field = value; invalidate() }
|
||||
|
||||
var labelColor: Int = Color.DKGRAY
|
||||
set(value) { field = value; invalidate() }
|
||||
|
||||
var isWheelLocked = false
|
||||
set(value) { field = value; invalidate() }
|
||||
|
||||
var onItemClick: ((Int) -> Unit)? = null
|
||||
var onCenterClick: (() -> Unit)? = null
|
||||
var onWheelCenterLockedTap: (() -> Unit)? = null
|
||||
|
||||
// ---- geometry ---------------------------------------------------------
|
||||
|
||||
private var cx = 0f
|
||||
private var cy = 0f
|
||||
private var outerRadius = 0f
|
||||
private var innerRadius = 0f
|
||||
|
||||
// ---- paint ------------------------------------------------------------
|
||||
|
||||
private val discPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val accentRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
private val accentRing2Paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
private val dividerPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
textAlign = Paint.Align.CENTER
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
}
|
||||
private val centerFillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val centerRingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
|
||||
private val bitmapPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
|
||||
private var iconBitmaps: Array<Bitmap?> = emptyArray()
|
||||
private var centerBitmap: Bitmap? = null
|
||||
private var centerUnlockedBitmap: Bitmap? = null
|
||||
private val grayFilter = ColorMatrixColorFilter(ColorMatrix().apply { setSaturation(0f) })
|
||||
private var lockShakeAngle = 0f
|
||||
private var shakeAnimator: ValueAnimator? = null
|
||||
|
||||
// ---- touch & fling ----------------------------------------------------
|
||||
|
||||
var wheelAngle = 0f
|
||||
private var isDragging = false
|
||||
private var snapAnimator: ValueAnimator? = null
|
||||
|
||||
// Incremental drag state
|
||||
private var prevTouchAngle = 0f
|
||||
private var touchDownX = 0f
|
||||
private var touchDownY = 0f
|
||||
|
||||
// Velocity buffer: stores (cumulative wheel angle, timestamp) for last N samples
|
||||
private val VEL_SAMPLES = 6
|
||||
private val velAngles = FloatArray(VEL_SAMPLES)
|
||||
private val velTimes = LongArray(VEL_SAMPLES)
|
||||
private var velIdx = 0
|
||||
private var velCount = 0
|
||||
|
||||
private val touchSlop = ViewConfiguration.get(context).scaledTouchSlop.toFloat()
|
||||
|
||||
// ---- helpers ----------------------------------------------------------
|
||||
|
||||
private fun dp(v: Float) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, v, resources.displayMetrics)
|
||||
|
||||
// ---- sizing -----------------------------------------------------------
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
cx = w / 2f
|
||||
cy = h / 2f
|
||||
val size = minOf(w, h)
|
||||
outerRadius = size / 2f * 0.80f
|
||||
innerRadius = outerRadius * 0.26f
|
||||
|
||||
textPaint.textSize = size * 0.034f
|
||||
dividerPaint.strokeWidth = dp(0.7f)
|
||||
accentRingPaint.strokeWidth = dp(5f)
|
||||
accentRing2Paint.strokeWidth = dp(3f)
|
||||
centerRingPaint.strokeWidth = dp(4f)
|
||||
|
||||
reloadBitmaps()
|
||||
}
|
||||
|
||||
private fun reloadBitmaps() {
|
||||
val iconPx = (outerRadius * 0.24f).toInt().coerceAtLeast(1)
|
||||
items.forEachIndexed { i, item ->
|
||||
iconBitmaps[i] = tintedBitmap(item.iconRes, accentColor, iconPx)
|
||||
}
|
||||
val centerPx = (innerRadius * 0.64f).toInt().coerceAtLeast(1)
|
||||
centerBitmap = tintedBitmap(R.drawable.ic_lock, accentColor, centerPx)
|
||||
centerUnlockedBitmap = tintedBitmap(R.drawable.ic_lock_open, accentColor, centerPx)
|
||||
}
|
||||
|
||||
private fun tintedBitmap(@DrawableRes resId: Int, tint: Int, sizePx: Int): Bitmap? {
|
||||
if (sizePx <= 0) return null
|
||||
return try {
|
||||
val d = AppCompatResources.getDrawable(context, resId)!!.mutate()
|
||||
DrawableCompat.setTint(DrawableCompat.wrap(d), tint)
|
||||
val bmp = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
|
||||
Canvas(bmp).also { d.setBounds(0, 0, sizePx, sizePx); d.draw(it) }
|
||||
bmp
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
// ---- drawing ----------------------------------------------------------
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
if (items.isEmpty()) return
|
||||
|
||||
val segCount = items.size
|
||||
val segDeg = 360f / segCount
|
||||
|
||||
// Wheel disc
|
||||
discPaint.color = surfaceColor
|
||||
canvas.drawCircle(cx, cy, outerRadius, discPaint)
|
||||
|
||||
// Accent ring around wheel
|
||||
accentRingPaint.color = accentColor
|
||||
canvas.drawCircle(cx, cy, outerRadius + dp(20f), accentRingPaint)
|
||||
|
||||
// Rotatable layer
|
||||
canvas.save()
|
||||
canvas.rotate(wheelAngle, cx, cy)
|
||||
|
||||
// Divider lines between segments
|
||||
dividerPaint.color = (labelColor and 0x00FFFFFF) or (100 shl 24)
|
||||
for (i in 0 until segCount) {
|
||||
val rad = Math.toRadians((i * segDeg).toDouble())
|
||||
val cos = cos(rad).toFloat()
|
||||
val sin = sin(rad).toFloat()
|
||||
canvas.drawLine(
|
||||
cx + cos * (innerRadius + dp(6f)), cy + sin * (innerRadius + dp(6f)),
|
||||
cx + cos * (outerRadius - dp(12f)), cy + sin * (outerRadius - dp(12f)),
|
||||
dividerPaint
|
||||
)
|
||||
}
|
||||
|
||||
// Segment content
|
||||
for (i in 0 until segCount) {
|
||||
val midDeg = i * segDeg + segDeg / 2f
|
||||
drawSegment(canvas, i, midDeg)
|
||||
}
|
||||
|
||||
canvas.restore()
|
||||
|
||||
// Center button — always upright
|
||||
centerRingPaint.color = accentColor
|
||||
canvas.drawCircle(cx, cy, innerRadius + dp(3f), centerRingPaint)
|
||||
centerFillPaint.color = surfaceColor
|
||||
canvas.drawCircle(cx, cy, innerRadius, centerFillPaint)
|
||||
val activeCenterBitmap = if (isWheelLocked) centerBitmap else centerUnlockedBitmap
|
||||
activeCenterBitmap?.let {
|
||||
canvas.save()
|
||||
// Shake pivots around the bottom-centre of the icon
|
||||
if (lockShakeAngle != 0f) canvas.rotate(lockShakeAngle, cx, cy + it.height / 2f)
|
||||
canvas.drawBitmap(it, cx - it.width / 2f, cy - it.height / 2f, bitmapPaint)
|
||||
canvas.restore()
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawSegment(canvas: Canvas, index: Int, midDeg: Float) {
|
||||
val rad = Math.toRadians(midDeg.toDouble())
|
||||
val cosA = cos(rad).toFloat()
|
||||
val sinA = sin(rad).toFloat()
|
||||
|
||||
val iconX = cx + cosA * (outerRadius * 0.63f)
|
||||
val iconY = cy + sinA * (outerRadius * 0.63f)
|
||||
|
||||
// Icon — radially oriented; top items are naturally upside-down
|
||||
iconBitmaps.getOrNull(index)?.let { bmp ->
|
||||
canvas.save()
|
||||
canvas.translate(iconX, iconY)
|
||||
canvas.rotate(midDeg - 90f)
|
||||
if (isWheelLocked) {
|
||||
bitmapPaint.colorFilter = grayFilter
|
||||
bitmapPaint.alpha = 100
|
||||
}
|
||||
canvas.drawBitmap(bmp, -bmp.width / 2f, -bmp.height / 2f, bitmapPaint)
|
||||
if (isWheelLocked) {
|
||||
bitmapPaint.colorFilter = null
|
||||
bitmapPaint.alpha = 255
|
||||
}
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
// Curved label — same radial orientation as icons.
|
||||
// In the local rotated frame the wheel arc is a circle of radius `labelRadius`
|
||||
// with its centre directly "above" at (0, -labelRadius). A CCW arc through (0,0)
|
||||
// flows rightward at that point, matching the natural reading direction at 6 o'clock.
|
||||
val labelRadius = outerRadius * 0.84f
|
||||
val textX = cx + cosA * labelRadius
|
||||
val textY = cy + sinA * labelRadius
|
||||
val label = items[index].label
|
||||
textPaint.color = if (isWheelLocked) (labelColor and 0x00FFFFFF) or (80 shl 24) else labelColor
|
||||
textPaint.textAlign = Paint.Align.LEFT
|
||||
val halfAngleDeg = Math.toDegrees((textPaint.measureText(label) / 2.0) / labelRadius).toFloat()
|
||||
val localArcRect = RectF(-labelRadius, -2f * labelRadius, labelRadius, 0f)
|
||||
val arcPath = Path().apply { addArc(localArcRect, 90f + halfAngleDeg, -(halfAngleDeg * 2f)) }
|
||||
canvas.save()
|
||||
canvas.translate(textX, textY)
|
||||
canvas.rotate(midDeg - 90f)
|
||||
canvas.drawTextOnPath(label, arcPath, 0f, textPaint.textSize * 0.36f, textPaint)
|
||||
canvas.restore()
|
||||
}
|
||||
|
||||
// ---- touch ------------------------------------------------------------
|
||||
|
||||
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
snapAnimator?.cancel()
|
||||
prevTouchAngle = angleAt(event.x, event.y)
|
||||
touchDownX = event.x
|
||||
touchDownY = event.y
|
||||
isDragging = false
|
||||
velIdx = 0
|
||||
velCount = 0
|
||||
recordVelSample()
|
||||
return true
|
||||
}
|
||||
MotionEvent.ACTION_MOVE -> {
|
||||
val curr = angleAt(event.x, event.y)
|
||||
// Incremental delta — normalised to [-180, 180] to survive the ±180° wrap
|
||||
var dA = curr - prevTouchAngle
|
||||
if (dA > 180f) dA -= 360f
|
||||
if (dA < -180f) dA += 360f
|
||||
prevTouchAngle = curr
|
||||
|
||||
val moved = hypot(event.x - touchDownX, event.y - touchDownY)
|
||||
if (moved > touchSlop || isDragging) {
|
||||
isDragging = true
|
||||
wheelAngle += dA
|
||||
recordVelSample()
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_UP -> {
|
||||
if (!isDragging) {
|
||||
val dist = hypot(event.x - cx, event.y - cy)
|
||||
when {
|
||||
dist <= innerRadius -> {
|
||||
if (isWheelLocked) {
|
||||
onWheelCenterLockedTap?.invoke()
|
||||
} else {
|
||||
isWheelLocked = true
|
||||
}
|
||||
}
|
||||
dist <= outerRadius -> {
|
||||
if (isWheelLocked) {
|
||||
val idx = segmentAt(event.x, event.y)
|
||||
if (idx in items.indices) animateToSixOClock(idx) {
|
||||
vibrateDevice()
|
||||
shakeLock()
|
||||
}
|
||||
} else {
|
||||
val idx = segmentAt(event.x, event.y)
|
||||
if (idx in items.indices) animateToSixOClock(idx) { onItemClick?.invoke(items[idx].navId) }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val vel = computeVelocity()
|
||||
if (abs(vel) > 0.05f) fling(vel) else snapToNearest()
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL -> {
|
||||
if (isDragging) {
|
||||
val vel = computeVelocity()
|
||||
if (abs(vel) > 0.05f) fling(vel) else snapToNearest()
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun recordVelSample() {
|
||||
val slot = velIdx % VEL_SAMPLES
|
||||
velAngles[slot] = wheelAngle
|
||||
velTimes[slot] = System.currentTimeMillis()
|
||||
velIdx++
|
||||
if (velCount < VEL_SAMPLES) velCount++
|
||||
}
|
||||
|
||||
/** Returns angular velocity in degrees per millisecond, using the oldest available sample. */
|
||||
private fun computeVelocity(): Float {
|
||||
if (velCount < 2) return 0f
|
||||
val newest = (velIdx - 1 + VEL_SAMPLES) % VEL_SAMPLES
|
||||
// Use the sample that is ~100 ms old for a stable estimate
|
||||
val oldest = (velIdx - velCount + VEL_SAMPLES) % VEL_SAMPLES
|
||||
val dt = velTimes[newest] - velTimes[oldest]
|
||||
if (dt <= 0L) return 0f
|
||||
return (velAngles[newest] - velAngles[oldest]) / dt
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick off a physics-based fling: uniform deceleration from [initialVel] to zero,
|
||||
* then snap to the nearest segment.
|
||||
* Formula: total_rotation = v0² / (2 * DECEL), duration = v0 / DECEL
|
||||
* With DecelerateInterpolator(1) the initial animation velocity matches v0.
|
||||
*/
|
||||
private fun fling(initialVel: Float) {
|
||||
val DECEL = 0.0008f // deg / ms² — tune for feel
|
||||
val duration = (abs(initialVel) / DECEL).toLong().coerceIn(200, 3500)
|
||||
val sign = if (initialVel >= 0f) 1f else -1f
|
||||
val totalRot = sign * initialVel * initialVel / (2f * DECEL)
|
||||
val startAngle = wheelAngle
|
||||
val endAngle = startAngle + totalRot
|
||||
|
||||
snapAnimator = ValueAnimator.ofFloat(startAngle, endAngle).apply {
|
||||
this.duration = duration
|
||||
interpolator = DecelerateInterpolator() // matches v0 at t=0
|
||||
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
|
||||
addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(a: Animator) { snapToNearest() }
|
||||
})
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun angleAt(x: Float, y: Float): Float =
|
||||
Math.toDegrees(atan2((y - cy).toDouble(), (x - cx).toDouble())).toFloat()
|
||||
|
||||
private fun segmentAt(x: Float, y: Float): Int {
|
||||
var a = angleAt(x, y) - wheelAngle
|
||||
a = (a % 360f + 360f) % 360f
|
||||
return (a / (360f / items.size)).toInt() % items.size
|
||||
}
|
||||
|
||||
private fun animateToSixOClock(index: Int, onDone: () -> Unit) {
|
||||
val segDeg = 360f / items.size.coerceAtLeast(1)
|
||||
val midDeg = index * segDeg + segDeg / 2f
|
||||
// delta needed so this segment's midpoint lands at 90° (6 o'clock in math coords)
|
||||
var delta = (90f - midDeg) - wheelAngle
|
||||
// normalise to shortest path [-180, 180]
|
||||
delta = ((delta % 360f) + 360f) % 360f
|
||||
if (delta > 180f) delta -= 360f
|
||||
val endAngle = wheelAngle + delta
|
||||
|
||||
snapAnimator?.cancel()
|
||||
snapAnimator = ValueAnimator.ofFloat(wheelAngle, endAngle).apply {
|
||||
duration = 350
|
||||
interpolator = DecelerateInterpolator()
|
||||
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
|
||||
addListener(object : AnimatorListenerAdapter() {
|
||||
private var cancelled = false
|
||||
override fun onAnimationCancel(a: Animator) { cancelled = true }
|
||||
override fun onAnimationEnd(a: Animator) { if (!cancelled) onDone() }
|
||||
})
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapToNearest() {
|
||||
val segDeg = 360f / items.size.coerceAtLeast(1)
|
||||
val target = (wheelAngle / segDeg).roundToInt() * segDeg
|
||||
snapAnimator = ValueAnimator.ofFloat(wheelAngle, target).apply {
|
||||
duration = 300
|
||||
interpolator = DecelerateInterpolator()
|
||||
addUpdateListener { wheelAngle = it.animatedValue as Float; invalidate() }
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun vibrateDevice() {
|
||||
val v = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||
v.vibrate(VibrationEffect.createOneShot(60, VibrationEffect.DEFAULT_AMPLITUDE))
|
||||
}
|
||||
|
||||
fun shakeLock() {
|
||||
shakeAnimator?.cancel()
|
||||
shakeAnimator = ValueAnimator.ofFloat(0f, -18f, 18f, -12f, 12f, -6f, 6f, 0f).apply {
|
||||
duration = 500
|
||||
interpolator = LinearInterpolator()
|
||||
addUpdateListener { lockShakeAngle = it.animatedValue as Float; invalidate() }
|
||||
addListener(object : AnimatorListenerAdapter() {
|
||||
override fun onAnimationEnd(a: Animator) { lockShakeAngle = 0f; invalidate() }
|
||||
})
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
fun unlockWheel() {
|
||||
isWheelLocked = false
|
||||
lockShakeAngle = 0f
|
||||
shakeAnimator?.cancel()
|
||||
invalidate()
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { pagerAdapter.rebuildAll() }
|
||||
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
(activity as? HomeActivity)?.loadAllContacts()
|
||||
}
|
||||
|
||||
private fun attachMediator(pages: List<TabDef>) {
|
||||
@@ -168,6 +168,14 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
|
||||
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
|
||||
when {
|
||||
accountNumber.startsWith("bmlqr:") -> {
|
||||
bundle.putString(KEY_SUBTITLE, "BML QR Merchant")
|
||||
bundle.putString(KEY_COLOR, "#0066A1")
|
||||
}
|
||||
accountNumber.startsWith("mfaisaqr:") -> {
|
||||
bundle.putString(KEY_SUBTITLE, "M-Faisa QR Merchant")
|
||||
bundle.putString(KEY_COLOR, "#ED1C24")
|
||||
}
|
||||
account != null -> {
|
||||
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
|
||||
bundle.putString(KEY_COLOR, "#FE860E")
|
||||
@@ -191,6 +199,17 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val items = mutableListOf<ContactPickerAdapter.PickerItem>()
|
||||
|
||||
val accounts = viewModel.accounts.value ?: emptyList()
|
||||
val contacts = viewModel.contacts.value ?: emptyList()
|
||||
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
|
||||
val fromCurrency = fromAccount?.currencyName ?: ""
|
||||
val fromLoginTag = fromAccount?.loginTag ?: ""
|
||||
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT" || fromAccount?.profileType == "BML_DEBIT"
|
||||
val fromIsMfaisa = fromAccount?.bank == "MFAISA"
|
||||
// TODO: when M-Faisa-supported contacts are wired up, swap this for a per-row check
|
||||
// (e.g. is the recipient also an M-Faisa wallet) instead of disabling everything.
|
||||
val mfaisaInactive = if (fromIsMfaisa) "Unsupported recipient from M-Faisa" else null
|
||||
|
||||
if (tabTag == RECENTS_TAG) {
|
||||
val recents = RecentsCache.load(requireContext())
|
||||
val filtered = if (search.isBlank()) recents else recents.filter {
|
||||
@@ -204,19 +223,15 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
subtitle = r.subtitle,
|
||||
colorHex = r.colorHex,
|
||||
isSameAsFrom = r.accountNumber == fromAccountNumber,
|
||||
imageHash = r.imageHash
|
||||
imageHash = r.imageHash,
|
||||
// A MFAISA-tagged recent is itself a valid M-Faisa recipient — don't grey it out
|
||||
// when the source is M-Faisa.
|
||||
inactiveReason = if (r.bank == "MFAISA") null else mfaisaInactive
|
||||
))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
val accounts = viewModel.accounts.value ?: emptyList()
|
||||
val contacts = viewModel.contacts.value ?: emptyList()
|
||||
val fromAccount = accounts.find { it.accountNumber == fromAccountNumber }
|
||||
val fromCurrency = fromAccount?.currencyName ?: ""
|
||||
val fromLoginTag = fromAccount?.loginTag ?: ""
|
||||
val fromIsCard = fromAccount?.profileType == "BML_PREPAID" || fromAccount?.profileType == "BML_CREDIT" || fromAccount?.profileType == "BML_DEBIT"
|
||||
|
||||
if (tabTag == MY_ACCOUNTS_TAG) {
|
||||
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" }
|
||||
@@ -247,9 +262,12 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
colorHex = "#FE860E",
|
||||
isSameAsFrom = isSame,
|
||||
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),
|
||||
inactiveReason = when {
|
||||
isSame -> null
|
||||
mfaisaInactive != null && acc.bank != "MFAISA" -> mfaisaInactive
|
||||
fromIsCard && acc.loginTag != fromLoginTag -> "Cards can only be used within the same BML account"
|
||||
else -> currencyMismatchReason(fromCurrency, acc.currencyName)
|
||||
},
|
||||
balance = balance,
|
||||
bankLogoRes = logoRes
|
||||
))
|
||||
@@ -279,10 +297,13 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
colorHex = "#FE860E",
|
||||
isSameAsFrom = isSame,
|
||||
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),
|
||||
inactiveReason = when {
|
||||
isSame -> null
|
||||
mfaisaInactive != null -> mfaisaInactive
|
||||
!isActive -> acc.statusDesc
|
||||
acc.loginTag != fromLoginTag -> "Cards can only be used within the same BML account"
|
||||
else -> currencyMismatchReason(fromCurrency, acc.currencyName)
|
||||
},
|
||||
balance = balance,
|
||||
bankLogoRes = logoRes
|
||||
))
|
||||
@@ -307,7 +328,7 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
||||
colorHex = contact.bankColor,
|
||||
isSameAsFrom = contact.benefAccount == fromAccountNumber,
|
||||
imageHash = contact.customerImgHash,
|
||||
inactiveReason = currencyMismatchReason(fromCurrency, contact.transferCyDesc)
|
||||
inactiveReason = mfaisaInactive ?: currencyMismatchReason(fromCurrency, contact.transferCyDesc)
|
||||
))
|
||||
}
|
||||
return items
|
||||
|
||||
@@ -178,7 +178,7 @@ class ContactsFragment : Fragment() {
|
||||
colorHex = contact.bankColor,
|
||||
imageHash = contact.imageHash
|
||||
)
|
||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
|
||||
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||
}
|
||||
|
||||
private fun confirmDelete(contact: ContactDisplay) {
|
||||
|
||||
@@ -4,13 +4,13 @@ import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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
|
||||
@@ -26,6 +26,8 @@ import sh.sar.basedbank.api.mib.MibCard
|
||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.NfcPaymentUtil
|
||||
import sh.sar.basedbank.util.PaymvQrParser
|
||||
import kotlin.math.abs
|
||||
import sh.sar.basedbank.databinding.FragmentDashboardBinding
|
||||
import sh.sar.basedbank.databinding.ItemForeignLimitBinding
|
||||
@@ -35,21 +37,35 @@ class DashboardFragment : Fragment() {
|
||||
private var _binding: FragmentDashboardBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private val viewModel: HomeViewModel by activityViewModels()
|
||||
|
||||
private var pendingQrAccountNumber: String? = null
|
||||
private var pendingQrCardNumber: 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/")) {
|
||||
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
||||
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||
if (bmlTarget != null) {
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, pendingQrAccountNumber)
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
|
||||
)
|
||||
} else {
|
||||
val qr = PaymvQrParser.parse(raw)
|
||||
if (qr?.accountNumber != null) {
|
||||
Toast.makeText(requireContext(), R.string.card_qr_paymv_unsupported, Toast.LENGTH_SHORT).show()
|
||||
val defaultFrom = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromQr(
|
||||
accountNumber = qr.accountNumber,
|
||||
displayName = qr.merchantName ?: qr.accountNumber,
|
||||
amount = qr.amount,
|
||||
remarks = qr.purpose,
|
||||
fromAccountNumber = defaultFrom
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
pendingQrAccountNumber = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
@@ -84,11 +100,11 @@ class DashboardFragment : Fragment() {
|
||||
}
|
||||
|
||||
binding.cardPendingFinances.setOnClickListener {
|
||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
|
||||
(activity as? HomeActivity)?.showWithBackStackAndNav(FinancingFragment(), R.id.nav_finances)
|
||||
}
|
||||
|
||||
binding.cardOverdue.setOnClickListener {
|
||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
|
||||
(activity as? HomeActivity)?.showWithBackStackAndNav(FinancingFragment(), R.id.nav_finances)
|
||||
}
|
||||
|
||||
val cardAdapter = DashboardCardAdapter()
|
||||
@@ -97,12 +113,16 @@ class DashboardFragment : Fragment() {
|
||||
LinearSnapHelper().attachToRecyclerView(binding.rvCards)
|
||||
|
||||
val updateCardList = {
|
||||
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
|
||||
val credStore = CredentialStore(requireContext())
|
||||
val hidden = credStore.getHiddenDashboardCardNumbers()
|
||||
val mibItems = (viewModel.mibCards.value ?: emptyList())
|
||||
.filter { CardsFragment.isMibCardActive(it.cardStatus) && !hidden.contains(it.maskedCardNumber) }
|
||||
.map { CardItem.Mib(it) }
|
||||
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
||||
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) }
|
||||
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
|
||||
.map { CardItem.Bml(it) }
|
||||
val all = mibItems + bmlItems
|
||||
val defaultNum = CredentialStore(requireContext()).getDefaultCardAccountNumber()
|
||||
val all = bmlItems + mibItems
|
||||
val defaultNum = credStore.getDefaultCardAccountNumber()
|
||||
val ordered = if (defaultNum != null) {
|
||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||
if (def != null) listOf(def) + all.filter { it !== def } else all
|
||||
@@ -115,7 +135,7 @@ class DashboardFragment : Fragment() {
|
||||
|
||||
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 isBottomNav = NavCustomization.getNavMode(requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE)) == NavCustomization.NAV_MODE_BOTTOM
|
||||
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)
|
||||
@@ -126,13 +146,32 @@ class DashboardFragment : Fragment() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val isBottom = NavCustomization.getNavMode(requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)) == NavCustomization.NAV_MODE_BOTTOM
|
||||
if (isBottom) {
|
||||
requireActivity().title = getString(R.string.app_name)
|
||||
val size = (28 * resources.displayMetrics.density).toInt()
|
||||
val gap = (8 * resources.displayMetrics.density).toInt()
|
||||
val icon = requireContext().packageManager.getApplicationIcon(requireContext().packageName)
|
||||
val bmp = android.graphics.Bitmap.createBitmap(size + gap, size, android.graphics.Bitmap.Config.ARGB_8888)
|
||||
val canvas = android.graphics.Canvas(bmp)
|
||||
icon.setBounds(0, 0, size, size)
|
||||
icon.draw(canvas)
|
||||
requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar).logo =
|
||||
android.graphics.drawable.BitmapDrawable(resources, bmp)
|
||||
} else {
|
||||
requireActivity().title = getString(R.string.nav_dashboard)
|
||||
}
|
||||
refreshQuickActions()
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar).logo = null
|
||||
}
|
||||
|
||||
private fun refreshQuickActions() {
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||
val isBottom = NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM
|
||||
if (isBottom) {
|
||||
binding.buttonBar.visibility = View.GONE
|
||||
return
|
||||
@@ -377,7 +416,7 @@ class DashboardFragment : Fragment() {
|
||||
if (isMib) {
|
||||
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
|
||||
pendingQrCardNumber = (item as CardItem.Bml).account.accountNumber
|
||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||
}
|
||||
}
|
||||
@@ -385,8 +424,17 @@ class DashboardFragment : Fragment() {
|
||||
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()
|
||||
if (isMib) {
|
||||
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
NfcPaymentUtil.checkAndProceed(requireContext()) {
|
||||
val accountNumber = (item as CardItem.Bml).account.accountNumber
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_pay_with_card,
|
||||
CardsFragment.newInstanceWithAutoTapMode(accountNumber)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ class HomeActivity : AppCompatActivity() {
|
||||
private val viewModel: HomeViewModel by viewModels()
|
||||
private lateinit var toggle: ActionBarDrawerToggle
|
||||
private var suppressBottomNavCallback = false
|
||||
private var cachedTransferFragment: TransferFragment? = null
|
||||
private val navBackStack = ArrayDeque<Int>()
|
||||
|
||||
private var backPressedOnce = false
|
||||
private val backPressHandler = Handler(Looper.getMainLooper())
|
||||
@@ -89,6 +91,10 @@ class HomeActivity : AppCompatActivity() {
|
||||
private val warningRunnable = Runnable { showAutolockWarning() }
|
||||
|
||||
private var isLocked = false
|
||||
private var pendingWheelUnlock = false
|
||||
|
||||
private var hasUnreadNotifications = false
|
||||
private var notifMenuItem: MenuItem? = null
|
||||
|
||||
private val autolockRunnable = Runnable {
|
||||
countdownTimer?.cancel(); countdownTimer = null
|
||||
@@ -98,6 +104,21 @@ class HomeActivity : AppCompatActivity() {
|
||||
if (securitySet) lock()
|
||||
}
|
||||
|
||||
fun lockApp() = lock()
|
||||
|
||||
fun notifyWheelLockTap() {
|
||||
val securitySet = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
.getString("security_method", null) != null
|
||||
if (securitySet) {
|
||||
pendingWheelUnlock = true
|
||||
lock()
|
||||
} else {
|
||||
// No security configured — unlock the wheel immediately
|
||||
(supportFragmentManager.findFragmentById(R.id.contentFrame) as? CircularNavFragment)
|
||||
?.unlockWheelLock()
|
||||
}
|
||||
}
|
||||
|
||||
private fun lock() {
|
||||
isLocked = true
|
||||
startActivity(
|
||||
@@ -156,7 +177,7 @@ class HomeActivity : AppCompatActivity() {
|
||||
R.id.nav_dashboard -> DashboardFragment()
|
||||
R.id.nav_accounts -> AccountsFragment()
|
||||
R.id.nav_contacts -> ContactsFragment()
|
||||
R.id.nav_transfer -> TransferFragment()
|
||||
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
|
||||
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
||||
R.id.nav_more -> MoreFragment()
|
||||
R.id.nav_activities -> ActivitiesFragment()
|
||||
@@ -181,9 +202,9 @@ class HomeActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
// Load data
|
||||
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty()) {
|
||||
if (app.mibAccounts.isNotEmpty() || app.bmlAccounts.isNotEmpty() || app.fahipayAccounts.isNotEmpty() || app.mfaisaAccounts.isNotEmpty()) {
|
||||
// Came from fresh manual login — accounts ready, rest fetched in background
|
||||
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts
|
||||
val merged = app.mibAccounts + app.bmlAccounts + app.fahipayAccounts + app.mfaisaAccounts
|
||||
viewModel.accounts.value = merged.filterVisibleAccounts()
|
||||
if (app.mibAccounts.isNotEmpty()) AccountCache.save(this, app.mibAccounts)
|
||||
if (app.bmlAccounts.isNotEmpty()) {
|
||||
@@ -194,6 +215,10 @@ class HomeActivity : AppCompatActivity() {
|
||||
val byLoginId = app.fahipayAccounts.groupBy { it.loginTag.removePrefix("fahipay_") }
|
||||
byLoginId.forEach { (loginId, accs) -> AccountCache.saveFahipay(this, loginId, accs) }
|
||||
}
|
||||
if (app.mfaisaAccounts.isNotEmpty()) {
|
||||
val byLoginId = app.mfaisaAccounts.groupBy { it.loginTag.removePrefix("mfaisa_") }
|
||||
byLoginId.forEach { (loginId, accs) -> AccountCache.saveMfaisa(this, loginId, accs) }
|
||||
}
|
||||
|
||||
val cachedCards = CardsCache.load(this)
|
||||
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
||||
@@ -217,7 +242,8 @@ class HomeActivity : AppCompatActivity() {
|
||||
val cachedMib = AccountCache.load(this)
|
||||
val cachedBml = AccountCache.loadBml(this, store.getBmlLoginIds())
|
||||
val cachedFahipay = AccountCache.loadFahipay(this, store.getFahipayLoginIds())
|
||||
val merged = cachedMib + cachedBml + cachedFahipay
|
||||
val cachedMfaisa = AccountCache.loadMfaisa(this, store.getMfaisaLoginIds())
|
||||
val merged = cachedMib + cachedBml + cachedFahipay + cachedMfaisa
|
||||
if (merged.isNotEmpty()) viewModel.accounts.value = merged
|
||||
val cachedCards = CardsCache.load(this)
|
||||
if (cachedCards.isNotEmpty()) viewModel.mibCards.value = cachedCards
|
||||
@@ -237,16 +263,33 @@ class HomeActivity : AppCompatActivity() {
|
||||
if (savedInstanceState == null) {
|
||||
val navDest = intent.getIntExtra("nav_destination", -1)
|
||||
val autoScan = intent.getBooleanExtra("auto_scan", false)
|
||||
if (navDest != -1) {
|
||||
val fragment = if (autoScan && navDest == R.id.nav_transfer)
|
||||
TransferFragment.newInstanceWithAutoScan()
|
||||
else null
|
||||
val autoTapMode = intent.getBooleanExtra("auto_tap_mode", false)
|
||||
val shareQrText = intent.getStringExtra("share_qr_text")
|
||||
when {
|
||||
shareQrText != null -> {
|
||||
show(DashboardFragment())
|
||||
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
||||
routeSharedQrText(shareQrText)
|
||||
}
|
||||
navDest != -1 -> {
|
||||
val fragment = when {
|
||||
autoScan && navDest == R.id.nav_transfer -> TransferFragment.newInstanceWithAutoScan()
|
||||
autoTapMode && navDest == R.id.nav_pay_with_card -> CardsFragment.newInstanceWithAutoTapMode()
|
||||
else -> null
|
||||
}
|
||||
navigateTo(navDest, fragment)
|
||||
}
|
||||
else -> {
|
||||
val initPrefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
if (NavCustomization.getNavMode(initPrefs) == NavCustomization.NAV_MODE_CIRCULAR) {
|
||||
show(CircularNavFragment())
|
||||
} else {
|
||||
show(DashboardFragment())
|
||||
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
@@ -258,14 +301,40 @@ class HomeActivity : AppCompatActivity() {
|
||||
// Let CardsFragment handle back if in manage mode
|
||||
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
|
||||
if (currentFrag is CardsFragment && currentFrag.onBackPressed()) return
|
||||
|
||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
val navMode = NavCustomization.getNavMode(prefs)
|
||||
|
||||
// Circular nav mode: back always returns to the wheel
|
||||
if (navMode == NavCustomization.NAV_MODE_CIRCULAR) {
|
||||
if (supportFragmentManager.backStackEntryCount > 0) {
|
||||
supportFragmentManager.popBackStack()
|
||||
navBackStack.removeLastOrNull()?.let { updateNavSelection(it) }
|
||||
return
|
||||
}
|
||||
if (currentFrag is CircularNavFragment) {
|
||||
if (backPressedOnce) {
|
||||
backPressHandler.removeCallbacks(resetBackPress)
|
||||
finish()
|
||||
} else {
|
||||
backPressedOnce = true
|
||||
Toast.makeText(this@HomeActivity, R.string.press_back_to_exit, Toast.LENGTH_SHORT).show()
|
||||
backPressHandler.postDelayed(resetBackPress, 2000)
|
||||
}
|
||||
} else {
|
||||
show(CircularNavFragment())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Pop fragment back stack if there's anything on it (e.g. showWithBackStack)
|
||||
if (supportFragmentManager.backStackEntryCount > 0) {
|
||||
supportFragmentManager.popBackStack()
|
||||
navBackStack.removeLastOrNull()?.let { updateNavSelection(it) }
|
||||
return
|
||||
}
|
||||
// In bottom nav mode, pressing back navigates up the hierarchy
|
||||
val isBottomNav = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||
if (isBottomNav && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
|
||||
if (navMode == NavCustomization.NAV_MODE_BOTTOM && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
|
||||
// Sub-page reached via More (e.g. Settings, Activities) — go back to More
|
||||
if (binding.bottomNavigation.selectedItemId == R.id.nav_more && currentFrag !is MoreFragment) {
|
||||
show(MoreFragment())
|
||||
@@ -321,23 +390,46 @@ class HomeActivity : AppCompatActivity() {
|
||||
.commit()
|
||||
}
|
||||
|
||||
private fun updateNavSelection(itemId: Int) {
|
||||
binding.navigationView.setCheckedItem(itemId)
|
||||
if (binding.bottomNavigation.visibility == View.VISIBLE) {
|
||||
val bottomNavIds = (0 until binding.bottomNavigation.menu.size())
|
||||
.map { binding.bottomNavigation.menu.getItem(it).itemId }.toSet()
|
||||
val selectId = if (itemId in bottomNavIds) itemId else if (R.id.nav_more in bottomNavIds) R.id.nav_more else null
|
||||
if (selectId != null) {
|
||||
suppressBottomNavCallback = true
|
||||
binding.bottomNavigation.selectedItemId = selectId
|
||||
suppressBottomNavCallback = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun applyNavMode() {
|
||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||
if (isBottom) {
|
||||
when (NavCustomization.getNavMode(prefs)) {
|
||||
NavCustomization.NAV_MODE_BOTTOM -> {
|
||||
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
||||
toggle.isDrawerIndicatorEnabled = false
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
||||
binding.bottomNavigation.visibility = View.VISIBLE
|
||||
rebuildBottomNav(prefs)
|
||||
applyNavLabelVisibility()
|
||||
} else {
|
||||
}
|
||||
NavCustomization.NAV_MODE_CIRCULAR -> {
|
||||
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
||||
toggle.isDrawerIndicatorEnabled = false
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
||||
binding.bottomNavigation.visibility = View.GONE
|
||||
}
|
||||
else -> {
|
||||
supportActionBar?.show()
|
||||
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
|
||||
toggle.isDrawerIndicatorEnabled = true
|
||||
toggle.syncState()
|
||||
binding.bottomNavigation.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun rebuildBottomNav(prefs: android.content.SharedPreferences = getSharedPreferences("prefs", MODE_PRIVATE)) {
|
||||
val slots = NavCustomization.getSlots(prefs)
|
||||
@@ -374,11 +466,15 @@ fun applyNavLabelVisibility() {
|
||||
}
|
||||
|
||||
fun navigateTo(itemId: Int, fragment: Fragment? = null) {
|
||||
// Restore action bar when leaving the circular wheel screen
|
||||
if (NavCustomization.getNavMode(getSharedPreferences("prefs", MODE_PRIVATE)) == NavCustomization.NAV_MODE_CIRCULAR) {
|
||||
supportActionBar?.show()
|
||||
}
|
||||
val dest = fragment ?: when (itemId) {
|
||||
R.id.nav_dashboard -> DashboardFragment()
|
||||
R.id.nav_accounts -> AccountsFragment()
|
||||
R.id.nav_contacts -> ContactsFragment()
|
||||
R.id.nav_transfer -> TransferFragment()
|
||||
R.id.nav_transfer -> cachedTransferFragment ?: TransferFragment().also { cachedTransferFragment = it }
|
||||
R.id.nav_pay_mv_qr -> PayMvQrFragment()
|
||||
R.id.nav_activities -> ActivitiesFragment()
|
||||
R.id.nav_transfer_history -> TransferHistoryFragment()
|
||||
@@ -386,25 +482,16 @@ fun applyNavLabelVisibility() {
|
||||
R.id.nav_otp -> OtpFragment()
|
||||
R.id.nav_settings -> SettingsFragment()
|
||||
R.id.nav_pay_with_card -> CardsFragment()
|
||||
R.id.nav_more -> MoreFragment()
|
||||
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
|
||||
}
|
||||
show(dest)
|
||||
binding.navigationView.setCheckedItem(itemId)
|
||||
if (binding.bottomNavigation.visibility == View.VISIBLE) {
|
||||
val bottomNavIds = (0 until binding.bottomNavigation.menu.size())
|
||||
.map { binding.bottomNavigation.menu.getItem(it).itemId }.toSet()
|
||||
val selectId = if (itemId in bottomNavIds) itemId else if (R.id.nav_more in bottomNavIds) R.id.nav_more else null
|
||||
if (selectId != null) {
|
||||
suppressBottomNavCallback = true
|
||||
binding.bottomNavigation.selectedItemId = selectId
|
||||
suppressBottomNavCallback = false
|
||||
}
|
||||
}
|
||||
updateNavSelection(itemId)
|
||||
}
|
||||
|
||||
fun setBottomNavVisible(visible: Boolean) {
|
||||
val isBottom = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||
if (isBottom) {
|
||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
if (NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM) {
|
||||
binding.bottomNavigation.visibility = if (visible) View.VISIBLE else View.GONE
|
||||
}
|
||||
}
|
||||
@@ -433,6 +520,33 @@ fun applyNavLabelVisibility() {
|
||||
.commit()
|
||||
}
|
||||
|
||||
fun showWithBackStackAndNav(fragment: Fragment, itemId: Int) {
|
||||
navBackStack.addLast(binding.bottomNavigation.selectedItemId)
|
||||
showWithBackStack(fragment)
|
||||
updateNavSelection(itemId)
|
||||
}
|
||||
|
||||
private fun routeSharedQrText(text: String) {
|
||||
val store = CredentialStore(this)
|
||||
val bmlTarget = sh.sar.basedbank.util.PaymvQrParser.bmlQrPayTarget(text)
|
||||
if (bmlTarget != null) {
|
||||
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, store.getDefaultCardAccountNumber()))
|
||||
return
|
||||
}
|
||||
val qr = sh.sar.basedbank.util.PaymvQrParser.parse(text)
|
||||
if (qr?.accountNumber != null) {
|
||||
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromQr(
|
||||
accountNumber = qr.accountNumber,
|
||||
displayName = qr.merchantName ?: qr.accountNumber,
|
||||
amount = qr.amount,
|
||||
remarks = qr.purpose,
|
||||
fromAccountNumber = store.getDefaultAccountNumber()
|
||||
))
|
||||
} else {
|
||||
Toast.makeText(this, R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Returning from LockActivity — refresh sessions since they may have expired.
|
||||
@@ -441,6 +555,11 @@ fun applyNavLabelVisibility() {
|
||||
pauseTime = 0L
|
||||
resetAutolockTimer()
|
||||
autoRefresh(CredentialStore(this))
|
||||
if (pendingWheelUnlock) {
|
||||
pendingWheelUnlock = false
|
||||
(supportFragmentManager.findFragmentById(R.id.contentFrame) as? CircularNavFragment)
|
||||
?.unlockWheelLock()
|
||||
}
|
||||
return
|
||||
}
|
||||
// If we were away long enough to have hit the autolock timeout (e.g. while
|
||||
@@ -523,9 +642,19 @@ fun applyNavLabelVisibility() {
|
||||
eyeItem?.isVisible = true
|
||||
val hidden = viewModel.hideAmounts.value ?: false
|
||||
eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility)
|
||||
notifMenuItem = menu.findItem(R.id.action_notifications)
|
||||
notifMenuItem?.setIcon(if (hasUnreadNotifications) R.drawable.ic_bell else R.drawable.ic_bell_read)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
|
||||
val onWheel = supportFragmentManager.findFragmentById(R.id.contentFrame) is CircularNavFragment
|
||||
menu.findItem(R.id.action_hide_amounts)?.isVisible = !onWheel
|
||||
menu.findItem(R.id.action_lock)?.isVisible = !onWheel
|
||||
menu.findItem(R.id.action_notifications)?.isVisible = !onWheel
|
||||
return super.onPrepareOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
if (item.itemId == R.id.action_lock) {
|
||||
val avd = getDrawable(R.drawable.avd_lock) as? android.graphics.drawable.AnimatedVectorDrawable
|
||||
@@ -538,6 +667,10 @@ fun applyNavLabelVisibility() {
|
||||
}
|
||||
return true
|
||||
}
|
||||
if (item.itemId == R.id.action_notifications) {
|
||||
openNotificationsSheet()
|
||||
return true
|
||||
}
|
||||
if (item.itemId == R.id.action_hide_amounts) {
|
||||
val newHidden = !(viewModel.hideAmounts.value ?: false)
|
||||
viewModel.hideAmounts.value = newHidden
|
||||
@@ -551,23 +684,36 @@ fun applyNavLabelVisibility() {
|
||||
return super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
fun setNotificationUnread(hasUnread: Boolean) {
|
||||
hasUnreadNotifications = hasUnread
|
||||
notifMenuItem?.setIcon(if (hasUnread) R.drawable.ic_bell else R.drawable.ic_bell_read)
|
||||
}
|
||||
|
||||
private fun openNotificationsSheet() {
|
||||
val sheet = NotificationsSheetFragment()
|
||||
sheet.onUnreadCountChanged = { hasUnread -> setNotificationUnread(hasUnread) }
|
||||
sheet.show(supportFragmentManager, "notifications")
|
||||
}
|
||||
|
||||
fun relogin() {
|
||||
val store = CredentialStore(this)
|
||||
val mibLoginIds = store.getMibLoginIds()
|
||||
val bmlLoginIds = store.getBmlLoginIds()
|
||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) {
|
||||
val mfaisaLoginIds = store.getMfaisaLoginIds()
|
||||
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty() && mfaisaLoginIds.isEmpty()) {
|
||||
startActivity(Intent(this, LoginActivity::class.java))
|
||||
finish()
|
||||
return
|
||||
}
|
||||
// Immediately drop accounts for logged-out logins from the displayed list
|
||||
val current = viewModel.accounts.value ?: emptyList()
|
||||
val mfaisaLoginIdsForFilter = store.getMfaisaLoginIds()
|
||||
viewModel.accounts.value = current.filter { acc ->
|
||||
if (acc.bank == "MIB") return@filter acc.loginTag.removePrefix("mib_") in mibLoginIds
|
||||
if (acc.bank == "BML") return@filter acc.loginTag.removePrefix("bml_") in bmlLoginIds
|
||||
if (acc.bank == "FAHIPAY") return@filter acc.loginTag.removePrefix("fahipay_") in fahipayLoginIds
|
||||
if (acc.bank == "MFAISA") return@filter acc.loginTag.removePrefix("mfaisa_") in mfaisaLoginIdsForFilter
|
||||
true
|
||||
}
|
||||
autoRefresh(store)
|
||||
@@ -586,7 +732,8 @@ fun applyNavLabelVisibility() {
|
||||
val mibLoginIds = store.getMibLoginIds()
|
||||
val bmlLoginIds = store.getBmlLoginIds()
|
||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty()) return
|
||||
val mfaisaLoginIds = store.getMfaisaLoginIds()
|
||||
if (mibLoginIds.isEmpty() && bmlLoginIds.isEmpty() && fahipayLoginIds.isEmpty() && mfaisaLoginIds.isEmpty()) return
|
||||
binding.refreshIndicator.visibility = View.VISIBLE
|
||||
hideConnectivityBanner()
|
||||
|
||||
@@ -760,17 +907,58 @@ fun applyNavLabelVisibility() {
|
||||
}
|
||||
}
|
||||
|
||||
// One async job per M-Faisa login, all run in parallel.
|
||||
// M-Faisa has no session refresh — sessions expire after ~240s — so we re-login each refresh.
|
||||
val mfaisaJobs = mfaisaLoginIds.mapNotNull { loginId ->
|
||||
val creds = store.loadMfaisaCredentials(loginId) ?: return@mapNotNull null
|
||||
loginId to async(Dispatchers.IO) {
|
||||
val loginTag = "mfaisa_$loginId"
|
||||
val flow = sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow(this@HomeActivity)
|
||||
try {
|
||||
flow.fetchSubscriber(creds.msisdn)
|
||||
val result = flow.doMobileLogin(creds.msisdn, creds.pin)
|
||||
val accounts = sh.sar.basedbank.api.mfaisa.MfaisaAccountClient.buildAccounts(result, loginTag)
|
||||
val app = application as BasedBankApp
|
||||
app.mfaisaSessions[loginId] = result.session
|
||||
store.saveMfaisaUserProfile(
|
||||
loginId,
|
||||
CredentialStore.MfaisaUserProfile(
|
||||
name = result.profile.name,
|
||||
email = result.profile.email,
|
||||
mdnId = result.profile.mdnId,
|
||||
subscriberId = result.profile.subscriberId,
|
||||
walletId = result.profile.walletId,
|
||||
roleId = result.profile.roleId,
|
||||
offerId = result.profile.offerId
|
||||
)
|
||||
)
|
||||
AccountCache.saveMfaisa(this@HomeActivity, loginId, accounts)
|
||||
accounts
|
||||
} catch (e: java.io.IOException) {
|
||||
refreshErrors.add("NO_INTERNET")
|
||||
AccountCache.loadMfaisa(this@HomeActivity, loginId)
|
||||
} catch (e: BankServerException) {
|
||||
refreshErrors.add("SERVER:${e.bankName}")
|
||||
AccountCache.loadMfaisa(this@HomeActivity, loginId)
|
||||
} catch (_: Exception) {
|
||||
AccountCache.loadMfaisa(this@HomeActivity, loginId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val mibResults = mibJobs.map { (loginId, job) -> loginId to job.await() }
|
||||
val mibAccounts = mibResults.flatMap { it.second }
|
||||
val bmlAccounts = bmlJobs.flatMap { (_, job) -> job.await() }
|
||||
val fahipayAccounts = fahipayJobs.flatMap { (_, job) -> job.await() }
|
||||
val mfaisaAccounts = mfaisaJobs.flatMap { (_, job) -> job.await() }
|
||||
|
||||
val app = application as BasedBankApp
|
||||
app.mibAccounts = mibAccounts
|
||||
AccountCache.save(this@HomeActivity, mibAccounts)
|
||||
app.bmlAccounts = bmlAccounts
|
||||
app.fahipayAccounts = fahipayAccounts
|
||||
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts).filterVisibleAccounts())
|
||||
app.mfaisaAccounts = mfaisaAccounts
|
||||
viewModel.accounts.postValue((mibAccounts + bmlAccounts + fahipayAccounts + mfaisaAccounts).filterVisibleAccounts())
|
||||
binding.refreshIndicator.visibility = View.GONE
|
||||
|
||||
val noInternet = refreshErrors.any { it == "NO_INTERNET" }
|
||||
@@ -817,6 +1005,11 @@ fun applyNavLabelVisibility() {
|
||||
val hidden = store.getHiddenBmlProfileIds(loginId)
|
||||
hidden.isEmpty() || acc.profileId !in hidden
|
||||
}
|
||||
"MFAISA" -> {
|
||||
val loginId = acc.loginTag.removePrefix("mfaisa_")
|
||||
val hidden = store.getHiddenMfaisaPocketIds(loginId)
|
||||
hidden.isEmpty() || acc.accountNumber !in hidden
|
||||
}
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
@@ -992,14 +1185,14 @@ fun applyNavLabelVisibility() {
|
||||
val fresh = withContext(Dispatchers.IO) {
|
||||
val sess = app.bmlSessionFor(src) ?: return@withContext null
|
||||
try {
|
||||
val accounts = BmlAccountClient().fetchAccounts(sess, src.loginTag)
|
||||
val accounts = BmlAccountClient().fetchAccounts(sess, src.loginTag, src.profileName, src.profileId)
|
||||
AccountCache.saveBml(this@HomeActivity, loginId, accounts)
|
||||
val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag }
|
||||
val otherBml = app.bmlAccounts.filter { it.loginTag != src.loginTag || it.profileId != src.profileId }
|
||||
app.bmlAccounts = otherBml + accounts
|
||||
accounts
|
||||
} catch (_: Exception) { null }
|
||||
} ?: return@launch
|
||||
val otherAccounts = current.filter { it.bank != "BML" || it.loginTag != src.loginTag }
|
||||
val otherAccounts = current.filter { it.bank != "BML" || it.loginTag != src.loginTag || it.profileId != src.profileId }
|
||||
viewModel.accounts.postValue(otherAccounts + fresh)
|
||||
} else {
|
||||
val loginId = src.loginTag.removePrefix("mib_")
|
||||
@@ -1082,7 +1275,7 @@ fun applyNavLabelVisibility() {
|
||||
for (profile in profiles) {
|
||||
try {
|
||||
flow.switchProfile(session, profile)
|
||||
for (card in client.fetchCards(session, "mib_$loginId")) {
|
||||
for (card in client.fetchCards(session, "mib_$loginId", profile.profileId)) {
|
||||
if (seen.add(card.cardId)) result += card
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
|
||||
@@ -7,6 +7,20 @@ import sh.sar.basedbank.R
|
||||
|
||||
object NavCustomization {
|
||||
|
||||
const val NAV_MODE_DRAWER = "drawer"
|
||||
const val NAV_MODE_BOTTOM = "bottom"
|
||||
const val NAV_MODE_CIRCULAR = "circular"
|
||||
|
||||
fun getNavMode(prefs: SharedPreferences): String {
|
||||
val explicit = prefs.getString("nav_mode", null)
|
||||
if (explicit != null) return explicit
|
||||
return if (prefs.getBoolean("bottom_nav", false)) NAV_MODE_BOTTOM else NAV_MODE_DRAWER
|
||||
}
|
||||
|
||||
fun saveNavMode(prefs: SharedPreferences, mode: String) {
|
||||
prefs.edit().putString("nav_mode", mode).apply()
|
||||
}
|
||||
|
||||
data class NavItemDef(
|
||||
val id: Int,
|
||||
val key: String,
|
||||
@@ -62,8 +76,31 @@ object NavCustomization {
|
||||
}
|
||||
|
||||
/** Items that belong in the "More" screen — those not occupying a bottom nav slot. */
|
||||
fun getCircularSlots(prefs: SharedPreferences): List<Int> = listOf(
|
||||
keyToId(prefs.getString("circular_slot_1_key", null), R.id.nav_transfer),
|
||||
keyToId(prefs.getString("circular_slot_2_key", null), R.id.nav_pay_with_card),
|
||||
keyToId(prefs.getString("circular_slot_3_key", null), R.id.nav_contacts),
|
||||
keyToId(prefs.getString("circular_slot_4_key", null), R.id.nav_accounts),
|
||||
)
|
||||
|
||||
fun saveCircularSlots(prefs: SharedPreferences, slots: List<Int>) {
|
||||
prefs.edit()
|
||||
.putString("circular_slot_1_key", idToKey(slots[0]) ?: "nav_transfer")
|
||||
.putString("circular_slot_2_key", idToKey(slots[1]) ?: "nav_pay_with_card")
|
||||
.putString("circular_slot_3_key", idToKey(slots[2]) ?: "nav_contacts")
|
||||
.putString("circular_slot_4_key", idToKey(slots[3]) ?: "nav_accounts")
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getMoreItems(prefs: SharedPreferences): List<NavItemDef> {
|
||||
if (getNavMode(prefs) == NAV_MODE_CIRCULAR) return getCircularMoreItems(prefs)
|
||||
val slots = getSlots(prefs).toSet()
|
||||
return ALL_SWAPPABLE.filter { it.id !in slots }
|
||||
}
|
||||
|
||||
/** Items shown in More when circular nav is active — everything not in the saved wheel slots. */
|
||||
private fun getCircularMoreItems(prefs: SharedPreferences): List<NavItemDef> {
|
||||
val slotIds = getCircularSlots(prefs).toSet()
|
||||
return ALL_SWAPPABLE.filter { it.id !in slotIds }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.app.Dialog
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.graphics.Typeface
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Bundle
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialog
|
||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.tabs.TabLayout
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlNotificationsClient
|
||||
import sh.sar.basedbank.api.mib.MibActivityHistoryClient
|
||||
import sh.sar.basedbank.util.NotificationsCache
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
// ── Sealed list item for date-grouped lists ───────────────────────────────────
|
||||
private sealed class NotifListItem {
|
||||
data class Header(val label: String) : NotifListItem()
|
||||
data class Entry(val n: AppNotification) : NotifListItem()
|
||||
}
|
||||
|
||||
private val headerSdf = SimpleDateFormat("EEEE, d MMMM yyyy", Locale.US)
|
||||
private val dateKeySdf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
|
||||
private fun toGroupedList(notifications: List<AppNotification>): List<NotifListItem> {
|
||||
val result = mutableListOf<NotifListItem>()
|
||||
var lastKey = ""
|
||||
for (n in notifications) {
|
||||
val key = dateKeySdf.format(Date(n.timestampMs))
|
||||
if (key != lastKey) {
|
||||
result.add(NotifListItem.Header(headerSdf.format(Date(n.timestampMs))))
|
||||
lastKey = key
|
||||
}
|
||||
result.add(NotifListItem.Entry(n))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private class NotifDiff(
|
||||
private val old: List<NotifListItem>,
|
||||
private val new: List<NotifListItem>
|
||||
) : DiffUtil.Callback() {
|
||||
override fun getOldListSize() = old.size
|
||||
override fun getNewListSize() = new.size
|
||||
override fun areItemsTheSame(oldPos: Int, newPos: Int): Boolean {
|
||||
val o = old[oldPos]; val n = new[newPos]
|
||||
return when {
|
||||
o is NotifListItem.Header && n is NotifListItem.Header -> o.label == n.label
|
||||
o is NotifListItem.Entry && n is NotifListItem.Entry -> o.n.id == n.n.id
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
override fun areContentsTheSame(oldPos: Int, newPos: Int) = old[oldPos] == new[newPos]
|
||||
}
|
||||
|
||||
class NotificationsSheetFragment : BottomSheetDialogFragment() {
|
||||
|
||||
var onUnreadCountChanged: ((hasUnread: Boolean) -> Unit)? = null
|
||||
|
||||
private val allNotifications = mutableListOf<AppNotification>()
|
||||
|
||||
private val bmlNextPage = mutableMapOf<String, Int>()
|
||||
private val bmlDone = mutableMapOf<String, Boolean>()
|
||||
private val mibNextStart = mutableMapOf<String, Int>()
|
||||
private val mibDone = mutableMapOf<String, Boolean>()
|
||||
|
||||
private var isLoadingMore = false
|
||||
private var mediator: TabLayoutMediator? = null
|
||||
|
||||
private val tabAdapters = arrayOfNulls<NotifPageAdapter>(3)
|
||||
private val tabLabels = listOf("All", "Alerts", "Information")
|
||||
private val tabGroupFilters = listOf<String?>(null, "ALERTS", "INFORMATION")
|
||||
|
||||
private lateinit var viewPager: ViewPager2
|
||||
private lateinit var btnMarkAllRead: TextView
|
||||
|
||||
private val app get() = requireActivity().application as BasedBankApp
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||
val d = super.onCreateDialog(savedInstanceState) as BottomSheetDialog
|
||||
d.setOnShowListener {
|
||||
val sheet = d.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet)!!
|
||||
BottomSheetBehavior.from(sheet).apply {
|
||||
state = BottomSheetBehavior.STATE_EXPANDED
|
||||
skipCollapsed = true
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
||||
inflater.inflate(R.layout.sheet_notifications, container, false)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val tabLayout = view.findViewById<TabLayout>(R.id.notifTabs)
|
||||
viewPager = view.findViewById(R.id.notifPager)
|
||||
btnMarkAllRead = view.findViewById(R.id.btnMarkAllRead)
|
||||
|
||||
tabAdapters[0] = NotifPageAdapter(null)
|
||||
tabAdapters[1] = NotifPageAdapter("ALERTS")
|
||||
tabAdapters[2] = NotifPageAdapter("INFORMATION")
|
||||
|
||||
viewPager.adapter = PageAdapter()
|
||||
viewPager.offscreenPageLimit = 2
|
||||
|
||||
mediator = TabLayoutMediator(tabLayout, viewPager) { tab, pos ->
|
||||
tab.text = tabLabels[pos]
|
||||
}.also { it.attach() }
|
||||
|
||||
btnMarkAllRead.setOnClickListener { markAllRead() }
|
||||
|
||||
loadFromCache()
|
||||
refreshFromNetwork()
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
mediator?.detach()
|
||||
mediator = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
// ── Data loading ──────────────────────────────────────────────────────────────
|
||||
|
||||
private fun loadFromCache() {
|
||||
val ctx = requireContext()
|
||||
val readIds = NotificationsCache.getMibReadIds(ctx)
|
||||
val cached = mutableListOf<AppNotification>()
|
||||
app.bmlSessions.forEach { (loginId, _) ->
|
||||
cached.addAll(NotificationsCache.loadBml(ctx, loginId))
|
||||
}
|
||||
app.mibSessions.forEach { (loginId, _) ->
|
||||
cached.addAll(NotificationsCache.loadMib(ctx, loginId, readIds))
|
||||
}
|
||||
if (cached.isNotEmpty()) {
|
||||
mergeInto(allNotifications, cached)
|
||||
refreshAdapters()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setSpinner(show: Boolean) {
|
||||
tabAdapters.forEach { it?.showLoadingSpinner = show }
|
||||
}
|
||||
|
||||
private fun refreshFromNetwork() {
|
||||
val bmlSessions = app.bmlSessions.toMap()
|
||||
val mibSessions = app.mibSessions.toMap()
|
||||
|
||||
lifecycleScope.launch {
|
||||
setSpinner(true)
|
||||
val bmlClient = BmlNotificationsClient()
|
||||
bmlSessions.forEach { (loginId, session) ->
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
bmlClient.fetchNotifications(session, loginId, page = 1)
|
||||
}
|
||||
if (result.items.isNotEmpty() && isAdded) {
|
||||
allNotifications.removeAll { it.bank == "BML" && it.loginId == loginId }
|
||||
allNotifications.addAll(result.items)
|
||||
allNotifications.sortByDescending { it.timestampMs }
|
||||
bmlNextPage[loginId] = 2
|
||||
bmlDone[loginId] = result.items.size >= result.total
|
||||
NotificationsCache.saveBml(requireContext(), loginId, result.items)
|
||||
refreshAdapters()
|
||||
broadcastUnread()
|
||||
}
|
||||
}
|
||||
|
||||
val mibClient = MibActivityHistoryClient()
|
||||
mibSessions.forEach { (loginId, session) ->
|
||||
val cachedIds = allNotifications
|
||||
.filter { it.bank == "MIB" && it.loginId == loginId }
|
||||
.map { it.id }.toSet()
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
mibClient.fetchActivity(session, loginId, 1, 100)
|
||||
}
|
||||
if (isAdded) {
|
||||
val readIds = NotificationsCache.getMibReadIds(requireContext())
|
||||
val hasOverlap = cachedIds.isNotEmpty() && result.items.any { it.id in cachedIds }
|
||||
val newItems = result.items
|
||||
.filter { it.id !in cachedIds }
|
||||
.map { it.copy(isRead = it.id in readIds) }
|
||||
if (newItems.isNotEmpty()) {
|
||||
allNotifications.addAll(newItems)
|
||||
allNotifications.sortByDescending { it.timestampMs }
|
||||
val allForLogin = allNotifications.filter { it.bank == "MIB" && it.loginId == loginId }
|
||||
NotificationsCache.saveMib(requireContext(), loginId, allForLogin)
|
||||
refreshAdapters()
|
||||
broadcastUnread()
|
||||
}
|
||||
mibNextStart[loginId] = result.nextStart
|
||||
mibDone[loginId] = hasOverlap || result.nextStart > result.totalCount
|
||||
}
|
||||
}
|
||||
|
||||
if (isAdded) setSpinner(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadMore() {
|
||||
if (isLoadingMore) return
|
||||
val bmlSessions = app.bmlSessions.toMap()
|
||||
val mibSessions = app.mibSessions.toMap()
|
||||
val anyLeft = bmlSessions.keys.any { bmlDone[it] != true } ||
|
||||
mibSessions.keys.any { mibDone[it] != true }
|
||||
if (!anyLeft) return
|
||||
|
||||
isLoadingMore = true
|
||||
setSpinner(true)
|
||||
lifecycleScope.launch {
|
||||
val bmlClient = BmlNotificationsClient()
|
||||
bmlSessions.forEach { (loginId, session) ->
|
||||
if (bmlDone[loginId] == true) return@forEach
|
||||
val page = bmlNextPage[loginId] ?: 2
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
bmlClient.fetchNotifications(session, loginId, page = page)
|
||||
}
|
||||
if (result.items.isNotEmpty() && isAdded) {
|
||||
allNotifications.addAll(result.items.filter { n -> allNotifications.none { it.id == n.id } })
|
||||
allNotifications.sortByDescending { it.timestampMs }
|
||||
bmlNextPage[loginId] = page + 1
|
||||
bmlDone[loginId] = allNotifications.count { it.bank == "BML" && it.loginId == loginId } >= result.total
|
||||
val allForLogin = allNotifications.filter { it.bank == "BML" && it.loginId == loginId }
|
||||
NotificationsCache.saveBml(requireContext(), loginId, allForLogin)
|
||||
}
|
||||
}
|
||||
|
||||
val mibClient = MibActivityHistoryClient()
|
||||
mibSessions.forEach { (loginId, session) ->
|
||||
if (mibDone[loginId] == true) return@forEach
|
||||
while (mibDone[loginId] != true && isAdded) {
|
||||
val start = mibNextStart[loginId] ?: 101
|
||||
val cachedIds = allNotifications
|
||||
.filter { it.bank == "MIB" && it.loginId == loginId }
|
||||
.map { it.id }.toSet()
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
mibClient.fetchActivity(session, loginId, start, start + 99)
|
||||
}
|
||||
if (result.rawCount == 0) break
|
||||
val readIds = NotificationsCache.getMibReadIds(requireContext())
|
||||
val newItems = result.items
|
||||
.filter { it.id !in cachedIds }
|
||||
.map { it.copy(isRead = it.id in readIds) }
|
||||
if (newItems.isNotEmpty()) {
|
||||
allNotifications.addAll(newItems)
|
||||
allNotifications.sortByDescending { it.timestampMs }
|
||||
val allForLogin = allNotifications.filter { it.bank == "MIB" && it.loginId == loginId }
|
||||
NotificationsCache.saveMib(requireContext(), loginId, allForLogin)
|
||||
}
|
||||
mibNextStart[loginId] = result.nextStart
|
||||
mibDone[loginId] = result.nextStart > result.totalCount
|
||||
if (newItems.isNotEmpty()) break
|
||||
}
|
||||
}
|
||||
|
||||
isLoadingMore = false
|
||||
if (isAdded) {
|
||||
setSpinner(false)
|
||||
refreshAdapters()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mark all read ─────────────────────────────────────────────────────────────
|
||||
|
||||
private fun markAllRead() {
|
||||
val bmlSessions = app.bmlSessions.toMap()
|
||||
val mibIds = allNotifications.filter { it.bank == "MIB" && !it.isRead }.map { it.id }
|
||||
|
||||
lifecycleScope.launch {
|
||||
var bmlOk = true
|
||||
bmlSessions.forEach { (_, session) ->
|
||||
val ok = withContext(Dispatchers.IO) { BmlNotificationsClient().markAllRead(session) }
|
||||
if (!ok) bmlOk = false
|
||||
}
|
||||
if (mibIds.isNotEmpty()) NotificationsCache.addMibReadIds(requireContext(), mibIds)
|
||||
|
||||
val updated = allNotifications.map { it.copy(isRead = true) }
|
||||
allNotifications.clear()
|
||||
allNotifications.addAll(updated)
|
||||
refreshAdapters()
|
||||
broadcastUnread()
|
||||
|
||||
if (isAdded) {
|
||||
val msg = if (bmlOk) "All notifications marked as read"
|
||||
else "Marked read locally — some accounts had a network error"
|
||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
private fun mergeInto(target: MutableList<AppNotification>, incoming: List<AppNotification>) {
|
||||
val existingIds = target.map { it.id }.toSet()
|
||||
target.addAll(incoming.filter { it.id !in existingIds })
|
||||
target.sortByDescending { it.timestampMs }
|
||||
}
|
||||
|
||||
private fun refreshAdapters() {
|
||||
tabGroupFilters.forEachIndexed { i, filter ->
|
||||
val filtered = if (filter == null) allNotifications
|
||||
else allNotifications.filter { it.group == filter }
|
||||
tabAdapters[i]?.update(filtered)
|
||||
}
|
||||
}
|
||||
|
||||
private fun broadcastUnread() {
|
||||
onUnreadCountChanged?.invoke(allNotifications.any { !it.isRead })
|
||||
}
|
||||
|
||||
private fun onNotificationTapped(item: AppNotification) {
|
||||
val idx = allNotifications.indexOfFirst { it.id == item.id }
|
||||
if (idx >= 0 && !allNotifications[idx].isRead) {
|
||||
allNotifications[idx] = allNotifications[idx].copy(isRead = true)
|
||||
if (item.bank == "MIB") NotificationsCache.addMibReadIds(requireContext(), listOf(item.id))
|
||||
refreshAdapters()
|
||||
broadcastUnread()
|
||||
}
|
||||
val detail = item.detailFields.joinToString("\n\n") { (k, v) -> "$k\n$v" }
|
||||
MaterialAlertDialogBuilder(requireActivity())
|
||||
.setTitle(item.title)
|
||||
.setMessage(detail.ifBlank { item.message })
|
||||
.setPositiveButton("OK", null)
|
||||
.show()
|
||||
}
|
||||
|
||||
// ── ViewPager2 page adapter ───────────────────────────────────────────────────
|
||||
|
||||
private inner class PageAdapter : RecyclerView.Adapter<PageAdapter.VH>() {
|
||||
inner class VH(val rv: RecyclerView) : RecyclerView.ViewHolder(rv)
|
||||
|
||||
override fun getItemCount() = 3
|
||||
override fun getItemViewType(position: Int) = position
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
|
||||
val rv = RecyclerView(parent.context).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
adapter = tabAdapters[viewType]
|
||||
addOnScrollListener(object : RecyclerView.OnScrollListener() {
|
||||
override fun onScrolled(rv: RecyclerView, dx: Int, dy: Int) {
|
||||
val lm = rv.layoutManager as LinearLayoutManager
|
||||
if (lm.findLastVisibleItemPosition() >= lm.itemCount - 4) loadMore()
|
||||
}
|
||||
})
|
||||
}
|
||||
return VH(rv)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: VH, position: Int) {}
|
||||
}
|
||||
|
||||
// ── Per-tab list adapter ──────────────────────────────────────────────────────
|
||||
|
||||
private inner class NotifPageAdapter(private val groupFilter: String?) :
|
||||
RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
|
||||
private val displayItems = mutableListOf<NotifListItem>()
|
||||
|
||||
var showLoadingSpinner: Boolean = false
|
||||
set(value) {
|
||||
if (field == value) return
|
||||
field = value
|
||||
if (displayItems.isEmpty()) {
|
||||
notifyItemChanged(0)
|
||||
} else if (value) {
|
||||
notifyItemInserted(displayItems.size)
|
||||
} else {
|
||||
notifyItemRemoved(displayItems.size)
|
||||
}
|
||||
}
|
||||
|
||||
fun update(filtered: List<AppNotification>) {
|
||||
val newItems = toGroupedList(filtered)
|
||||
val diff = DiffUtil.calculateDiff(NotifDiff(displayItems.toList(), newItems))
|
||||
displayItems.clear()
|
||||
displayItems.addAll(newItems)
|
||||
diff.dispatchUpdatesTo(this)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int {
|
||||
if (displayItems.isEmpty()) return 1
|
||||
return displayItems.size + if (showLoadingSpinner) 1 else 0
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
if (displayItems.isEmpty()) return if (showLoadingSpinner) 3 else 2
|
||||
if (showLoadingSpinner && position == displayItems.size) return 3
|
||||
return when (displayItems[position]) {
|
||||
is NotifListItem.Header -> 0
|
||||
is NotifListItem.Entry -> 1
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder =
|
||||
when (viewType) {
|
||||
0 -> HeaderVH(buildHeaderView(parent.context))
|
||||
1 -> ItemVH(buildRowView(parent.context))
|
||||
3 -> SpinnerVH(buildSpinnerView(parent.context))
|
||||
else -> EmptyVH(buildEmptyView(parent.context))
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||
when (holder) {
|
||||
is HeaderVH -> holder.bind((displayItems[position] as NotifListItem.Header).label)
|
||||
is ItemVH -> holder.bind((displayItems[position] as NotifListItem.Entry).n)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Date header ───────────────────────────────────────────────────────────
|
||||
|
||||
inner class HeaderVH(private val tv: TextView) : RecyclerView.ViewHolder(tv) {
|
||||
fun bind(label: String) { tv.text = label }
|
||||
}
|
||||
|
||||
private fun buildHeaderView(ctx: android.content.Context): TextView {
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
return TextView(ctx).apply {
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
|
||||
setTextColor(MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimary, Color.CYAN))
|
||||
setPadding((16 * dp).toInt(), (20 * dp).toInt(), (16 * dp).toInt(), (6 * dp).toInt())
|
||||
}
|
||||
}
|
||||
|
||||
// ── Empty state ───────────────────────────────────────────────────────────
|
||||
|
||||
inner class EmptyVH(v: View) : RecyclerView.ViewHolder(v)
|
||||
|
||||
private fun buildEmptyView(ctx: android.content.Context): View {
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
return LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
(300 * dp).toInt()
|
||||
)
|
||||
addView(ImageView(ctx).apply {
|
||||
setImageResource(R.drawable.ic_bell_read)
|
||||
val s = (48 * dp).toInt()
|
||||
layoutParams = LinearLayout.LayoutParams(s, s).apply {
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
bottomMargin = (12 * dp).toInt()
|
||||
}
|
||||
alpha = 0.35f
|
||||
})
|
||||
addView(TextView(ctx).apply {
|
||||
text = "No notifications"
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||
alpha = 0.5f
|
||||
gravity = Gravity.CENTER
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Loading spinner ───────────────────────────────────────────────────────
|
||||
|
||||
inner class SpinnerVH(v: View) : RecyclerView.ViewHolder(v)
|
||||
|
||||
private fun buildSpinnerView(ctx: android.content.Context): View {
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val pad = (16 * dp).toInt()
|
||||
val size = (28 * dp).toInt()
|
||||
return LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
setPadding(pad, pad, pad, pad)
|
||||
addView(ProgressBar(ctx).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(size, size)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Notification row ──────────────────────────────────────────────────────
|
||||
|
||||
inner class ItemVH(v: View) : RecyclerView.ViewHolder(v) {
|
||||
val iconBg: View = v.findViewWithTag("iconBg")
|
||||
val iconIv: ImageView = v.findViewWithTag("icon")
|
||||
val unreadBadge: View = v.findViewWithTag("badge")
|
||||
val titleTv: TextView = v.findViewWithTag("title")
|
||||
val messageTv: TextView = v.findViewWithTag("message")
|
||||
val bankBadge: TextView = v.findViewWithTag("bank")
|
||||
|
||||
fun bind(item: AppNotification) {
|
||||
titleTv.text = item.title
|
||||
messageTv.text = item.message
|
||||
bankBadge.text = item.bank
|
||||
unreadBadge.isVisible = !item.isRead
|
||||
|
||||
val (iconRes, colorHex) = iconAndColor(item)
|
||||
iconIv.setImageResource(iconRes)
|
||||
iconIv.imageTintList = ColorStateList.valueOf(Color.parseColor(colorHex))
|
||||
(iconBg.background as? GradientDrawable)
|
||||
?.setColor(Color.parseColor(colorHex.replace("#", "#22")))
|
||||
|
||||
itemView.alpha = if (item.isRead) 0.65f else 1f
|
||||
itemView.setOnClickListener { onNotificationTapped(item) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildRowView(ctx: android.content.Context): View {
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val surfaceColor = MaterialColors.getColor(ctx, com.google.android.material.R.attr.colorSurface, Color.BLACK)
|
||||
|
||||
return LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||
background = ta.getDrawable(0); ta.recycle()
|
||||
isClickable = true; isFocusable = true
|
||||
setPadding((16 * dp).toInt(), (12 * dp).toInt(), (16 * dp).toInt(), (12 * dp).toInt())
|
||||
|
||||
// Icon circle + badge overlay
|
||||
val frameSize = (44 * dp).toInt()
|
||||
val iconFrame = FrameLayout(ctx).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(frameSize, frameSize).apply {
|
||||
marginEnd = (12 * dp).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
// Circle background (fills the frame)
|
||||
val circleSize = (40 * dp).toInt()
|
||||
iconFrame.addView(View(ctx).apply {
|
||||
tag = "iconBg"
|
||||
layoutParams = FrameLayout.LayoutParams(circleSize, circleSize, Gravity.CENTER)
|
||||
background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.OVAL
|
||||
setColor(Color.parseColor("#33FFFFFF"))
|
||||
}
|
||||
})
|
||||
|
||||
// Icon
|
||||
val iconSize = (22 * dp).toInt()
|
||||
iconFrame.addView(ImageView(ctx).apply {
|
||||
tag = "icon"
|
||||
layoutParams = FrameLayout.LayoutParams(iconSize, iconSize, Gravity.CENTER)
|
||||
})
|
||||
|
||||
// Unread badge — bottom-right corner
|
||||
val badgeSize = (12 * dp).toInt()
|
||||
iconFrame.addView(View(ctx).apply {
|
||||
tag = "badge"
|
||||
layoutParams = FrameLayout.LayoutParams(badgeSize, badgeSize, Gravity.BOTTOM or Gravity.END)
|
||||
background = GradientDrawable().apply {
|
||||
shape = GradientDrawable.OVAL
|
||||
setColor(Color.parseColor("#EF5350"))
|
||||
setStroke((2 * dp).toInt(), surfaceColor)
|
||||
}
|
||||
})
|
||||
|
||||
addView(iconFrame)
|
||||
|
||||
// Text column
|
||||
val textCol = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
|
||||
// Title + bank badge row
|
||||
val titleRow = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
titleRow.addView(TextView(ctx).apply {
|
||||
tag = "title"
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||
setTypeface(null, Typeface.BOLD)
|
||||
maxLines = 1
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
})
|
||||
titleRow.addView(TextView(ctx).apply {
|
||||
tag = "bank"
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelSmall)
|
||||
alpha = 0.55f
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
).apply { marginStart = (6 * dp).toInt() }
|
||||
})
|
||||
|
||||
textCol.addView(titleRow)
|
||||
textCol.addView(TextView(ctx).apply {
|
||||
tag = "message"
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||
alpha = 0.7f
|
||||
maxLines = 2
|
||||
})
|
||||
addView(textCol)
|
||||
}
|
||||
}
|
||||
|
||||
private fun iconAndColor(item: AppNotification): Pair<Int, String> {
|
||||
if (item.bank == "MIB") return when {
|
||||
item.title.contains("Transfer", ignoreCase = true) ||
|
||||
item.title.contains("Payment", ignoreCase = true) -> R.drawable.ic_send to "#4CAF50"
|
||||
item.title.contains("Log in", ignoreCase = true) -> R.drawable.ic_lock_open to "#2196F3"
|
||||
else -> R.drawable.ic_receipt_check to "#9C27B0"
|
||||
}
|
||||
return when {
|
||||
item.group == "INFORMATION" -> R.drawable.ic_receipt_check to "#2196F3"
|
||||
item.title.contains("Received", ignoreCase = true) ||
|
||||
item.title.contains("Sent", ignoreCase = true) ||
|
||||
item.title.contains("Transfer", ignoreCase = true) ||
|
||||
item.title.contains("Payment", ignoreCase = true) ||
|
||||
item.title.contains("Paid", ignoreCase = true) ||
|
||||
item.title.contains("Funds", ignoreCase = true) -> R.drawable.ic_send to "#4CAF50"
|
||||
else -> R.drawable.ic_lock to "#EF5350"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,10 @@ 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
|
||||
@@ -39,7 +36,6 @@ import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||
import sh.sar.basedbank.util.AccountListParser
|
||||
import sh.sar.basedbank.util.PaymvQrParser
|
||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||
import java.io.File
|
||||
@@ -56,31 +52,6 @@ class PayMvQrFragment : Fragment() {
|
||||
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 {
|
||||
@@ -105,9 +76,6 @@ class PayMvQrFragment : Fragment() {
|
||||
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() {
|
||||
@@ -115,6 +83,7 @@ class PayMvQrFragment : Fragment() {
|
||||
val eligible = accounts.filter {
|
||||
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" &&
|
||||
it.bank != "MIB" && // TODO: MIB does not support PayMV QR
|
||||
it.bank != "MFAISA" && // TODO: M-Faisa PayMV QR not implemented yet
|
||||
!(it.bank == "BML" && it.currencyName.contains("USD", ignoreCase = true)) // TODO: BML USD not supported by MMA
|
||||
}
|
||||
val adapter = QrAccountAdapter(requireContext(), eligible)
|
||||
@@ -124,6 +93,20 @@ class PayMvQrFragment : Fragment() {
|
||||
selectedAccount = picked
|
||||
scheduleGenerate()
|
||||
}
|
||||
|
||||
// Auto-select default account if none is selected yet
|
||||
if (selectedAccount == null) {
|
||||
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||
if (defaultNum != null) {
|
||||
val defaultAcc = eligible.firstOrNull { it.accountNumber == defaultNum }
|
||||
if (defaultAcc != null) {
|
||||
selectedAccount = defaultAcc
|
||||
val prefix = if (defaultAcc.bank == "BML" && defaultAcc.profileName.isNotBlank()) "${defaultAcc.profileName} · " else ""
|
||||
binding.actvAccount.setText("$prefix${defaultAcc.accountBriefName}", false)
|
||||
scheduleGenerate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package sh.sar.basedbank.ui.home
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.os.Bundle
|
||||
@@ -13,7 +14,6 @@ import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import android.view.animation.AccelerateInterpolator
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import androidx.core.view.ViewCompat
|
||||
@@ -24,13 +24,39 @@ import androidx.fragment.app.activityViewModels
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.PagerSnapHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import android.animation.ValueAnimator
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.view.Gravity
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import com.google.android.material.color.MaterialColors
|
||||
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.BmlCardClient
|
||||
import sh.sar.basedbank.api.bml.BmlTapToPayClient
|
||||
import sh.sar.basedbank.api.mib.MibCardsClient
|
||||
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
|
||||
import sh.sar.basedbank.api.mib.MibCard
|
||||
import android.text.InputType
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import sh.sar.basedbank.databinding.FragmentCardsBinding
|
||||
import sh.sar.basedbank.util.CardsCache
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.Totp
|
||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||
import sh.sar.basedbank.util.NfcPaymentUtil
|
||||
import sh.sar.basedbank.util.PaymvQrParser
|
||||
import kotlin.math.abs
|
||||
|
||||
class CardsFragment : Fragment() {
|
||||
@@ -42,8 +68,42 @@ class CardsFragment : Fragment() {
|
||||
private var cards: List<CardItem> = emptyList()
|
||||
private var currentCardPosition: Int = 0
|
||||
private var cardWidth: Int = 0
|
||||
private var pendingQrAccountNumber: String? = null
|
||||
private var pendingQrCardNumber: String? = null
|
||||
private var isManageMode: Boolean = false
|
||||
private var managedCardKey: String? = null
|
||||
private var freezeInFlight: Boolean = false
|
||||
|
||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
||||
val bmlTarget = PaymvQrParser.bmlQrPayTarget(raw)
|
||||
if (bmlTarget != null) {
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlTarget, cardNumber)
|
||||
)
|
||||
} else {
|
||||
val qr = PaymvQrParser.parse(raw)
|
||||
if (qr?.accountNumber != null) {
|
||||
Toast.makeText(requireContext(), R.string.card_qr_paymv_unsupported, Toast.LENGTH_SHORT).show()
|
||||
val defaultFrom = store.getDefaultAccountNumber()
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromQr(
|
||||
accountNumber = qr.accountNumber,
|
||||
displayName = qr.merchantName ?: qr.accountNumber,
|
||||
amount = qr.amount,
|
||||
remarks = qr.purpose,
|
||||
fromAccountNumber = defaultFrom
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
private var isTapMode: Boolean = false
|
||||
private var tapAnimView: NfcTapAnimationView? = null
|
||||
private var autoTapModeTriggered = false
|
||||
|
||||
// Carousel snapshot captured on enter, used to reverse the exit animation
|
||||
private var carouselCardLayoutTop = 0f // card layout top relative to contentLayout
|
||||
@@ -57,20 +117,6 @@ class CardsFragment : Fragment() {
|
||||
private lateinit var stackAdapter: CardStackAdapter
|
||||
private val store by lazy { CredentialStore(requireContext()) }
|
||||
|
||||
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 = FragmentCardsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
@@ -109,17 +155,22 @@ class CardsFragment : Fragment() {
|
||||
}
|
||||
})
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { 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(0, 0, 0, (16 * resources.displayMetrics.density).toInt() + extraBottom)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { 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.setPadding(0, 0, 0, (16 * resources.displayMetrics.density).toInt() + navBarBottom)
|
||||
insets
|
||||
}
|
||||
|
||||
viewModel.mibCards.observe(viewLifecycleOwner) { rebuildCards() }
|
||||
viewModel.accounts.observe(viewLifecycleOwner) { rebuildCards() }
|
||||
viewModel.mibCards.observe(viewLifecycleOwner) {
|
||||
rebuildCards()
|
||||
rebindManagedCardIfNeeded()
|
||||
}
|
||||
viewModel.accounts.observe(viewLifecycleOwner) {
|
||||
rebuildCards()
|
||||
rebindManagedCardIfNeeded()
|
||||
}
|
||||
|
||||
val cached = CardsCache.load(requireContext())
|
||||
if (cached.isNotEmpty()) {
|
||||
@@ -135,7 +186,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
|
||||
// Swipe-down on the manage card to dismiss manage mode
|
||||
binding.manageCardView.root.setOnTouchListener { _, event ->
|
||||
if (!isManageMode) return@setOnTouchListener false
|
||||
if (!isManageMode && !isTapMode) return@setOnTouchListener false
|
||||
val mgr = binding.manageCardView.root
|
||||
when (event.action) {
|
||||
android.view.MotionEvent.ACTION_DOWN -> {
|
||||
@@ -162,7 +213,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
val dy = (event.rawY - swipeDragStartRawY).coerceAtLeast(0f)
|
||||
swipeIsDragging = false
|
||||
if (dy > 130f) {
|
||||
setManageMode(false)
|
||||
if (isTapMode) setTapMode(false) else setManageMode(false)
|
||||
} else {
|
||||
// Snap back
|
||||
mgr.animate().translationY(0f).scaleX(1f).scaleY(1f)
|
||||
@@ -182,7 +233,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
if (item is CardItem.Mib) {
|
||||
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
|
||||
pendingQrCardNumber = (item as CardItem.Bml).account.accountNumber
|
||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||
}
|
||||
}
|
||||
@@ -191,28 +242,180 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
binding.btnTapToPay.isEnabled = nfcAvailable
|
||||
binding.btnTapToPay.setOnClickListener {
|
||||
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
|
||||
val msg = if (item is CardItem.Mib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
|
||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
|
||||
if (item is CardItem.Mib) {
|
||||
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||
return@setOnClickListener
|
||||
}
|
||||
val bmlItem = item as CardItem.Bml
|
||||
NfcPaymentUtil.checkAndProceed(requireContext()) {
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
|
||||
showBiometricPromptForTap(bmlItem)
|
||||
} else {
|
||||
setTapMode(true, bmlItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val wip = View.OnClickListener {
|
||||
Toast.makeText(requireContext(), R.string.work_in_progress, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
binding.btnChangePin.setOnClickListener(wip)
|
||||
binding.btnFreeze.setOnClickListener(wip)
|
||||
binding.btnFreeze.setOnClickListener {
|
||||
when (val item = cards.getOrNull(currentCardPosition)) {
|
||||
is CardItem.Bml -> confirmBmlFreezeToggle(item)
|
||||
is CardItem.Mib -> confirmMibFreezeToggle(item)
|
||||
null -> {}
|
||||
}
|
||||
}
|
||||
binding.btnBlock.setOnClickListener(wip)
|
||||
}
|
||||
|
||||
private fun confirmBmlFreezeToggle(item: CardItem.Bml) {
|
||||
if (freezeInFlight) return
|
||||
val frozen = isBmlFrozen(item.account.statusDesc)
|
||||
val titleRes = if (frozen) R.string.card_unfreeze_confirm_title else R.string.card_freeze_confirm_title
|
||||
val messageRes = if (frozen) R.string.card_unfreeze_confirm_message else R.string.card_freeze_confirm_message
|
||||
val confirmRes = if (frozen) R.string.card_action_unfreeze else R.string.card_action_freeze
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(titleRes)
|
||||
.setMessage(messageRes)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setPositiveButton(confirmRes) { _, _ -> performBmlFreezeToggle(item, freeze = !frozen) }
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun confirmMibFreezeToggle(item: CardItem.Mib) {
|
||||
if (freezeInFlight) return
|
||||
val frozen = isMibCardFrozen(item.card.cardStatus)
|
||||
val titleRes = if (frozen) R.string.card_unfreeze_confirm_title else R.string.card_freeze_confirm_title
|
||||
val messageRes = if (frozen) R.string.card_unfreeze_confirm_message else R.string.card_freeze_confirm_message
|
||||
val confirmRes = if (frozen) R.string.card_action_unfreeze else R.string.card_action_freeze
|
||||
|
||||
val ctx = requireContext()
|
||||
val dp = resources.displayMetrics.density
|
||||
val inputLayout = TextInputLayout(ctx).apply {
|
||||
hint = getString(R.string.card_freeze_comments_hint)
|
||||
val pad = (16 * dp).toInt()
|
||||
setPadding(pad, pad / 2, pad, 0)
|
||||
}
|
||||
val input = TextInputEditText(ctx).apply {
|
||||
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
|
||||
maxLines = 3
|
||||
}
|
||||
inputLayout.addView(input)
|
||||
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(titleRes)
|
||||
.setMessage(messageRes)
|
||||
.setView(inputLayout)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setPositiveButton(confirmRes) { _, _ ->
|
||||
val comments = input.text?.toString()?.trim().orEmpty()
|
||||
performMibFreezeToggle(item, freeze = !frozen, comments = comments)
|
||||
}
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun performMibFreezeToggle(item: CardItem.Mib, freeze: Boolean, comments: String) {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val action = if (freeze) "freeze" else "unfreeze"
|
||||
val successRes = if (freeze) R.string.card_freeze_success else R.string.card_unfreeze_success
|
||||
val loginId = item.card.loginTag.removePrefix("mib_")
|
||||
val session = app.mibSessions[loginId]
|
||||
if (session == null) {
|
||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||
val ownerProfile = profiles.firstOrNull { it.profileId == item.card.profileId }
|
||||
?: profiles.firstOrNull { it.customerId == item.card.customerId }
|
||||
freezeInFlight = true
|
||||
binding.btnFreeze.isEnabled = false
|
||||
(activity as? HomeActivity)?.setRefreshing(true)
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
app.mibMutex.withLock {
|
||||
if (ownerProfile != null) {
|
||||
app.mibFlowFor(loginId).switchProfile(session, ownerProfile)
|
||||
}
|
||||
MibCardsClient().setCardFreezeState(session, item.card.cardId, action, comments)
|
||||
}
|
||||
}
|
||||
}
|
||||
freezeInFlight = false
|
||||
if (!isAdded || _binding == null) {
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
return@launch
|
||||
}
|
||||
binding.btnFreeze.isEnabled = true
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
val response = result.getOrNull()
|
||||
if (response?.success == true) {
|
||||
Toast.makeText(requireContext(), successRes, Toast.LENGTH_SHORT).show()
|
||||
(activity as? HomeActivity)?.triggerRefreshCards()
|
||||
} else {
|
||||
val msg = response?.message?.takeIf { it.isNotBlank() }
|
||||
?: result.exceptionOrNull()?.message
|
||||
?: getString(R.string.card_freeze_failed)
|
||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun performBmlFreezeToggle(item: CardItem.Bml, freeze: Boolean) {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val action = if (freeze) "freeze" else "unfreeze"
|
||||
val successRes = if (freeze) R.string.card_freeze_success else R.string.card_unfreeze_success
|
||||
freezeInFlight = true
|
||||
binding.btnFreeze.isEnabled = false
|
||||
(activity as? HomeActivity)?.setRefreshing(true)
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val session = app.bmlSessionFor(item.account)
|
||||
if (session == null) {
|
||||
freezeInFlight = false
|
||||
if (_binding != null) binding.btnFreeze.isEnabled = true
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return@launch
|
||||
}
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching { BmlCardClient().setCardFreezeState(session, item.account.internalId, action) }
|
||||
}
|
||||
freezeInFlight = false
|
||||
if (!isAdded || _binding == null) {
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
return@launch
|
||||
}
|
||||
binding.btnFreeze.isEnabled = true
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
val response = result.getOrNull()
|
||||
if (response?.success == true) {
|
||||
Toast.makeText(requireContext(), successRes, Toast.LENGTH_SHORT).show()
|
||||
(activity as? HomeActivity)?.refreshBalances(item.account)
|
||||
} else {
|
||||
val msg = response?.message?.takeIf { it.isNotBlank() }
|
||||
?: result.exceptionOrNull()?.message
|
||||
?: getString(R.string.card_freeze_failed)
|
||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setManageMode(enabled: Boolean) {
|
||||
isManageMode = enabled
|
||||
if (!enabled) managedCardKey = null
|
||||
requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card)
|
||||
if (enabled) enterManageMode() else exitManageMode()
|
||||
}
|
||||
|
||||
private fun enterManageMode() {
|
||||
val item = cards.getOrNull(currentCardPosition) ?: return
|
||||
private fun cardItemKey(item: CardItem): String = when (item) {
|
||||
is CardItem.Bml -> "bml:${item.account.accountNumber}"
|
||||
is CardItem.Mib -> "mib:${item.card.cardId}"
|
||||
}
|
||||
|
||||
// Bind card data
|
||||
private fun bindManageCardData(item: CardItem) {
|
||||
val cv = binding.manageCardView
|
||||
when (item) {
|
||||
is CardItem.Mib -> {
|
||||
@@ -222,7 +425,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
if (assetPath != null) loadCardImage(cv.ivCardImage, assetPath)
|
||||
else cv.ivCardImage.setImageDrawable(null)
|
||||
bindCardStatus(cv.tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
|
||||
cv.root.alpha = 1f
|
||||
cv.root.alpha = if (isMibCardActive(item.card.cardStatus)) 1f else 0.45f
|
||||
}
|
||||
is CardItem.Bml -> {
|
||||
cv.tvCardOwner.text = item.account.accountBriefName
|
||||
@@ -233,6 +436,37 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
cv.root.alpha = if (isActive) 1f else 0.45f
|
||||
}
|
||||
}
|
||||
val isFrozen = when (item) {
|
||||
is CardItem.Bml -> isBmlFrozen(item.account.statusDesc)
|
||||
is CardItem.Mib -> isMibCardFrozen(item.card.cardStatus)
|
||||
}
|
||||
binding.btnFreeze.setText(if (isFrozen) R.string.card_action_unfreeze else R.string.card_action_freeze)
|
||||
// MIB doesn't allow change PIN / block while a card is frozen; BML still does.
|
||||
val mibFrozen = item is CardItem.Mib && isMibCardFrozen(item.card.cardStatus)
|
||||
binding.btnChangePin.isEnabled = !mibFrozen
|
||||
binding.btnBlock.isEnabled = !mibFrozen
|
||||
}
|
||||
|
||||
private fun rebindManagedCardIfNeeded() {
|
||||
if (!isManageMode) return
|
||||
val key = managedCardKey ?: return
|
||||
val newPos = cards.indexOfFirst { cardItemKey(it) == key }
|
||||
if (newPos < 0) return
|
||||
if (newPos != currentCardPosition) {
|
||||
currentCardPosition = newPos
|
||||
binding.rvCards.scrollToPosition(newPos)
|
||||
}
|
||||
bindManageCardData(cards[newPos])
|
||||
}
|
||||
|
||||
private fun isBmlFrozen(statusDesc: String): Boolean =
|
||||
statusDesc.equals("Block Plastic", ignoreCase = true)
|
||||
|
||||
private fun enterManageMode() {
|
||||
val item = cards.getOrNull(currentCardPosition) ?: return
|
||||
managedCardKey = cardItemKey(item)
|
||||
|
||||
bindManageCardData(item)
|
||||
|
||||
// Capture positions BEFORE layout changes (for enter animation + exit animation later)
|
||||
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
|
||||
@@ -253,6 +487,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
binding.llPayButtons.visibility = View.GONE
|
||||
binding.llManageButtons.visibility = View.VISIBLE
|
||||
binding.llDefaultCardRow.visibility = View.VISIBLE
|
||||
binding.llHideDashboardRow.visibility = View.VISIBLE
|
||||
binding.manageCardView.root.visibility = View.VISIBLE
|
||||
|
||||
// Set switch state (clear listener first to avoid triggering on programmatic set)
|
||||
@@ -273,6 +508,27 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
}
|
||||
}
|
||||
|
||||
val accountNumber = (item as? CardItem.Bml)?.account?.accountNumber
|
||||
?: (item as? CardItem.Mib)?.card?.maskedCardNumber
|
||||
binding.switchHideFromDashboard.setOnCheckedChangeListener(null)
|
||||
binding.switchHideFromDashboard.isChecked = accountNumber != null &&
|
||||
store.getHiddenDashboardCardNumbers().contains(accountNumber)
|
||||
binding.switchHideFromDashboard.setOnCheckedChangeListener { _, isChecked ->
|
||||
if (accountNumber != null) {
|
||||
store.setCardHiddenFromDashboard(accountNumber, isChecked)
|
||||
}
|
||||
}
|
||||
|
||||
val isInactiveBml = item is CardItem.Bml && !item.account.statusDesc.equals("Active", ignoreCase = true)
|
||||
binding.switchDefaultCard.isEnabled = !isInactiveBml
|
||||
if (isInactiveBml) {
|
||||
binding.switchHideFromDashboard.setOnCheckedChangeListener(null)
|
||||
binding.switchHideFromDashboard.isChecked = true
|
||||
binding.switchHideFromDashboard.isEnabled = false
|
||||
} else {
|
||||
binding.switchHideFromDashboard.isEnabled = true
|
||||
}
|
||||
|
||||
// After layout pass, compute offsets, save carousel snapshot, and animate
|
||||
binding.contentLayout.doOnNextLayout {
|
||||
val mgr = binding.manageCardView.root
|
||||
@@ -364,7 +620,9 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
binding.llPayButtons.visibility = View.VISIBLE
|
||||
binding.llManageButtons.visibility = View.GONE
|
||||
binding.llDefaultCardRow.visibility = View.GONE
|
||||
binding.llHideDashboardRow.visibility = View.GONE
|
||||
binding.switchDefaultCard.setOnCheckedChangeListener(null)
|
||||
binding.switchHideFromDashboard.setOnCheckedChangeListener(null)
|
||||
buildDots(cards.size, currentCardPosition)
|
||||
}
|
||||
.start()
|
||||
@@ -377,6 +635,262 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
.start()
|
||||
}
|
||||
|
||||
// ── Tap-to-pay mode ────────────────────────────────────────────────────────
|
||||
|
||||
private fun setTapMode(enabled: Boolean, item: CardItem.Bml? = null) {
|
||||
isTapMode = enabled
|
||||
requireActivity().title = getString(if (enabled) R.string.card_pay_nfc else R.string.nav_pay_with_card)
|
||||
if (enabled) enterTapMode(item!!) else exitTapMode()
|
||||
}
|
||||
|
||||
private fun showBiometricPromptForTap(item: CardItem.Bml) {
|
||||
val bmgr = BiometricManager.from(requireContext())
|
||||
if (bmgr.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) != BiometricManager.BIOMETRIC_SUCCESS) {
|
||||
setTapMode(true, item)
|
||||
return
|
||||
}
|
||||
val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()),
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
setTapMode(true, item)
|
||||
}
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { }
|
||||
})
|
||||
prompt.authenticate(
|
||||
BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(getString(R.string.card_pay_nfc))
|
||||
.setSubtitle(item.account.accountBriefName)
|
||||
.setNegativeButtonText(getString(R.string.cancel))
|
||||
.build()
|
||||
)
|
||||
}
|
||||
|
||||
private fun enterTapMode(item: CardItem.Bml) {
|
||||
// Bind card data to the shared manage card view
|
||||
val cv = binding.manageCardView
|
||||
cv.tvCardOwner.text = item.account.accountBriefName
|
||||
cv.tvCardNumber.text = formatMasked(item.account.accountNumber)
|
||||
loadCardImage(cv.ivCardImage, BmlCardParser.cardImageAsset(item.account))
|
||||
val isActive = item.account.statusDesc.equals("Active", ignoreCase = true)
|
||||
bindCardStatus(cv.tvCardStatus, item.account.statusDesc.takeUnless { isActive })
|
||||
cv.root.alpha = if (isActive) 1f else 0.45f
|
||||
|
||||
// Snapshot carousel card position before layout changes (for animation)
|
||||
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
|
||||
val lm = binding.rvCards.layoutManager as? LinearLayoutManager
|
||||
val srcView = lm?.findViewByPosition(currentCardPosition)
|
||||
val srcLoc = IntArray(2).also {
|
||||
srcView?.getLocationOnScreen(it) ?: run { it[0] = contentLoc[0]; it[1] = contentLoc[1] }
|
||||
}
|
||||
val srcScreenTop = (srcLoc[1] - contentLoc[1]).toFloat()
|
||||
val srcCenterX = (srcLoc[0] - contentLoc[0]).toFloat() + cardWidth / 2f
|
||||
val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
|
||||
val textSrcScreenTop = (textLoc[1] - contentLoc[1]).toFloat()
|
||||
|
||||
carouselCardLayoutTop = srcScreenTop
|
||||
carouselCardCenterX = srcCenterX
|
||||
carouselTextLayoutTop = textSrcScreenTop
|
||||
|
||||
// Apply layout changes
|
||||
binding.btnManageCard.visibility = View.GONE
|
||||
binding.topSpacer.visibility = View.GONE
|
||||
binding.rvCards.visibility = View.GONE
|
||||
binding.pageIndicator.visibility = View.GONE
|
||||
binding.divider.visibility = View.GONE
|
||||
binding.llPayButtons.visibility = View.GONE
|
||||
binding.llManageButtons.visibility = View.GONE
|
||||
binding.llDefaultCardRow.visibility = View.GONE
|
||||
binding.manageCardView.root.visibility = View.VISIBLE
|
||||
binding.flTapMode.visibility = View.VISIBLE
|
||||
|
||||
// Build tap mode content: animation view + cancel button
|
||||
binding.flTapMode.removeAllViews()
|
||||
val animView = NfcTapAnimationView(requireContext())
|
||||
tapAnimView = animView
|
||||
|
||||
val dp = resources.displayMetrics.density
|
||||
val cancelBtn = (layoutInflater.inflate(R.layout.view_cancel_button, null, false) as MaterialButton).apply {
|
||||
setOnClickListener { setTapMode(false) }
|
||||
}
|
||||
|
||||
val colorOutlineVariant = MaterialColors.getColor(
|
||||
requireContext(), com.google.android.material.R.attr.colorOutlineVariant, android.graphics.Color.LTGRAY
|
||||
)
|
||||
val tapDivider = View(requireContext()).apply {
|
||||
setBackgroundColor(colorOutlineVariant)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, dp.toInt().coerceAtLeast(1)
|
||||
).also {
|
||||
it.marginStart = (24 * dp).toInt()
|
||||
it.marginEnd = (24 * dp).toInt()
|
||||
it.bottomMargin = (4 * dp).toInt()
|
||||
}
|
||||
}
|
||||
|
||||
val baseCancelPaddingBottom = (24 * dp).toInt()
|
||||
val cancelWrapper = LinearLayout(requireContext()).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
setPadding((16 * dp).toInt(), (8 * dp).toInt(), (16 * dp).toInt(), baseCancelPaddingBottom)
|
||||
addView(cancelBtn, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT))
|
||||
}
|
||||
ViewCompat.setOnApplyWindowInsetsListener(cancelWrapper) { 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.setPadding((16 * dp).toInt(), (8 * dp).toInt(), (16 * dp).toInt(), baseCancelPaddingBottom + navBarBottom)
|
||||
insets
|
||||
}
|
||||
val container = LinearLayout(requireContext()).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
addView(View(requireContext()).apply { // spacer pushes content below card
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)
|
||||
})
|
||||
addView(animView.apply {
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 3f)
|
||||
})
|
||||
addView(tapDivider)
|
||||
addView(cancelWrapper.apply {
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
|
||||
})
|
||||
}
|
||||
binding.flTapMode.addView(container)
|
||||
|
||||
// Animate card up from carousel position (same as manage mode)
|
||||
binding.contentLayout.doOnNextLayout {
|
||||
val mgr = binding.manageCardView.root
|
||||
val dstLoc = IntArray(2).also { mgr.getLocationOnScreen(it) }
|
||||
val dstTop = (dstLoc[1] - contentLoc[1]).toFloat()
|
||||
val dstCenterX = (dstLoc[0] - contentLoc[0]).toFloat() + mgr.width / 2f
|
||||
|
||||
mgr.pivotX = mgr.width / 2f
|
||||
mgr.pivotY = 0f
|
||||
mgr.scaleX = if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f
|
||||
mgr.scaleY = mgr.scaleX
|
||||
mgr.translationX = srcCenterX - dstCenterX
|
||||
mgr.translationY = srcScreenTop - dstTop
|
||||
|
||||
mgr.animate()
|
||||
.scaleX(1f).scaleY(1f)
|
||||
.translationX(0f).translationY(0f)
|
||||
.setDuration(380).setInterpolator(DecelerateInterpolator()).start()
|
||||
|
||||
val textDstLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
|
||||
binding.tvSelectedCardType.translationY = textSrcScreenTop - (textDstLoc[1] - contentLoc[1]).toFloat()
|
||||
binding.tvSelectedCardType.animate()
|
||||
.translationY(0f)
|
||||
.setDuration(380).setInterpolator(DecelerateInterpolator()).start()
|
||||
}
|
||||
|
||||
fetchAndArmToken(item)
|
||||
}
|
||||
|
||||
private fun exitTapMode() {
|
||||
tapAnimView?.stopAnimation()
|
||||
tapAnimView = null
|
||||
BmlHostCardEmulatorService.clearToken()
|
||||
BmlHostCardEmulatorService.onTransactionComplete = null
|
||||
|
||||
binding.manageCardView.root.animate().cancel()
|
||||
binding.tvSelectedCardType.animate().cancel()
|
||||
|
||||
val mgr = binding.manageCardView.root
|
||||
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
|
||||
val mgrLoc = IntArray(2).also { mgr.getLocationOnScreen(it) }
|
||||
val mgrLayoutTop = (mgrLoc[1] - contentLoc[1]).toFloat() - mgr.translationY
|
||||
val mgrLayoutCenterX = (mgrLoc[0] - contentLoc[0]).toFloat() - mgr.translationX + mgr.width / 2f
|
||||
|
||||
val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
|
||||
val textLayoutTop = (textLoc[1] - contentLoc[1]).toFloat() - binding.tvSelectedCardType.translationY
|
||||
|
||||
mgr.pivotX = mgr.width / 2f
|
||||
mgr.pivotY = 0f
|
||||
|
||||
mgr.animate()
|
||||
.scaleX(if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f)
|
||||
.scaleY(if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f)
|
||||
.translationX(carouselCardCenterX - mgrLayoutCenterX)
|
||||
.translationY(carouselCardLayoutTop - mgrLayoutTop)
|
||||
.setDuration(320)
|
||||
.setInterpolator(AccelerateInterpolator())
|
||||
.withEndAction {
|
||||
mgr.scaleX = 1f; mgr.scaleY = 1f
|
||||
mgr.translationX = 0f; mgr.translationY = 0f
|
||||
mgr.visibility = View.GONE
|
||||
binding.tvSelectedCardType.translationY = 0f
|
||||
binding.flTapMode.visibility = View.GONE
|
||||
binding.flTapMode.removeAllViews()
|
||||
binding.btnManageCard.visibility = View.VISIBLE
|
||||
binding.topSpacer.visibility = View.VISIBLE
|
||||
binding.rvCards.visibility = View.VISIBLE
|
||||
binding.divider.visibility = View.VISIBLE
|
||||
binding.llPayButtons.visibility = View.VISIBLE
|
||||
buildDots(cards.size, currentCardPosition)
|
||||
}
|
||||
.start()
|
||||
|
||||
binding.tvSelectedCardType.animate()
|
||||
.translationY(carouselTextLayoutTop - textLayoutTop)
|
||||
.setDuration(320)
|
||||
.setInterpolator(AccelerateInterpolator())
|
||||
.withEndAction { binding.tvSelectedCardType.translationY = 0f }
|
||||
.start()
|
||||
}
|
||||
|
||||
private fun fetchAndArmToken(item: CardItem.Bml) {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val loginId = item.account.loginTag.removePrefix("bml_")
|
||||
val session = app.bmlSessionFor(item.account)
|
||||
val otpSeed = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
||||
|
||||
if (session == null || otpSeed == null) {
|
||||
if (isTapMode) {
|
||||
Toast.makeText(requireContext(),
|
||||
if (session == null) getString(R.string.transfer_session_unavailable)
|
||||
else "OTP unavailable",
|
||||
Toast.LENGTH_SHORT).show()
|
||||
setTapMode(false)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
(activity as? HomeActivity)?.setRefreshing(true)
|
||||
val otp = Totp.generate(otpSeed)
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching { BmlTapToPayClient().fetchTokens(session, item.account.internalId, otp) }
|
||||
}
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
val token = result.getOrNull()?.firstOrNull()
|
||||
|
||||
if (!isTapMode) return@launch // user cancelled while we were fetching
|
||||
|
||||
if (token == null) {
|
||||
if (isTapMode) {
|
||||
Toast.makeText(requireContext(),
|
||||
result.exceptionOrNull()?.message ?: "Failed to get payment token",
|
||||
Toast.LENGTH_SHORT).show()
|
||||
setTapMode(false)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
BmlHostCardEmulatorService.setToken(token)
|
||||
BmlHostCardEmulatorService.onTransactionComplete = { success ->
|
||||
view?.post {
|
||||
if (!isTapMode) return@post
|
||||
setTapMode(false)
|
||||
if (success) {
|
||||
Toast.makeText(requireContext(), "Payment complete", Toast.LENGTH_SHORT).show()
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rebuildCards() {
|
||||
// Remember which card is currently selected by identity so we can restore position after reorder
|
||||
val currentCard = cards.getOrNull(currentCardPosition)
|
||||
@@ -386,7 +900,11 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
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: List<CardItem> = mibItems + bmlItems
|
||||
val bmlActive = bmlItems.filter { it.account.statusDesc.equals("Active", ignoreCase = true) }
|
||||
val bmlInactive = bmlItems.filter { !it.account.statusDesc.equals("Active", ignoreCase = true) }
|
||||
val mibActive = mibItems.filter { isMibCardActive(it.card.cardStatus) }
|
||||
val mibInactive = mibItems.filter { !isMibCardActive(it.card.cardStatus) }
|
||||
val all: List<CardItem> = bmlActive + mibActive + bmlInactive + mibInactive
|
||||
// Move default BML card to front
|
||||
cards = if (defaultNum != null) {
|
||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||
@@ -411,6 +929,32 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
buildDots(cards.size, currentCardPosition)
|
||||
updateCardInfo(currentCardPosition)
|
||||
}
|
||||
|
||||
// Auto-enter tap mode when launched from shortcut, NFC prompt, or dashboard
|
||||
if (!autoTapModeTriggered && arguments?.getBoolean(ARG_AUTO_TAP_MODE) == true) {
|
||||
val targetAccount = arguments?.getString(ARG_AUTO_TAP_ACCOUNT)
|
||||
val targetCard = if (targetAccount != null)
|
||||
cards.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == targetAccount }
|
||||
else
|
||||
cards.filterIsInstance<CardItem.Bml>().firstOrNull()
|
||||
if (targetCard != null) {
|
||||
autoTapModeTriggered = true
|
||||
// Scroll to the target card first
|
||||
val pos = cards.indexOf(targetCard)
|
||||
if (pos >= 0) {
|
||||
currentCardPosition = pos
|
||||
binding.rvCards.scrollToPosition(pos)
|
||||
}
|
||||
NfcPaymentUtil.checkAndProceed(requireContext()) {
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
|
||||
showBiometricPromptForTap(targetCard)
|
||||
} else {
|
||||
setTapMode(true, targetCard)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyCardScales() {
|
||||
@@ -432,7 +976,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
}
|
||||
|
||||
private fun buildDots(count: Int, selected: Int) {
|
||||
if (isManageMode) return
|
||||
if (isManageMode || isTapMode) return
|
||||
binding.pageIndicator.removeAllViews()
|
||||
if (count <= 1) {
|
||||
binding.pageIndicator.visibility = View.GONE
|
||||
@@ -465,9 +1009,17 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
is CardItem.Mib -> item.card.cardTypeDesc
|
||||
is CardItem.Bml -> item.account.accountTypeName
|
||||
}
|
||||
val isInactiveBml = item is CardItem.Bml && !item.account.statusDesc.equals("Active", ignoreCase = true)
|
||||
val nfcAvailable = android.nfc.NfcAdapter.getDefaultAdapter(requireContext()) != null
|
||||
binding.btnTapToPay.isEnabled = !isInactiveBml && nfcAvailable
|
||||
binding.btnScanToPay.isEnabled = !isInactiveBml
|
||||
}
|
||||
|
||||
fun onBackPressed(): Boolean {
|
||||
if (isTapMode) {
|
||||
setTapMode(false)
|
||||
return true
|
||||
}
|
||||
if (isManageMode) {
|
||||
setManageMode(false)
|
||||
return true
|
||||
@@ -475,12 +1027,28 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
return false
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
if (isTapMode) {
|
||||
BmlHostCardEmulatorService.clearToken()
|
||||
BmlHostCardEmulatorService.onTransactionComplete = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = getString(R.string.nav_pay_with_card)
|
||||
requireActivity().title = getString(when {
|
||||
isTapMode -> R.string.card_pay_nfc
|
||||
isManageMode -> R.string.card_manage
|
||||
else -> R.string.nav_pay_with_card
|
||||
})
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
tapAnimView?.stopAnimation()
|
||||
tapAnimView = null
|
||||
BmlHostCardEmulatorService.clearToken()
|
||||
BmlHostCardEmulatorService.onTransactionComplete = null
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
@@ -527,7 +1095,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
if (assetPath != null) loadCardImage(ivCardImage, assetPath)
|
||||
else ivCardImage.setImageDrawable(null)
|
||||
bindCardStatus(tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
|
||||
itemView.alpha = 1f
|
||||
itemView.alpha = if (isMibCardActive(item.card.cardStatus)) 1f else 0.45f
|
||||
}
|
||||
is CardItem.Bml -> {
|
||||
tvCardOwner.text = item.account.accountBriefName
|
||||
@@ -542,7 +1110,103 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
}
|
||||
}
|
||||
|
||||
// ── NFC animation view ─────────────────────────────────────────────────────
|
||||
|
||||
private inner class NfcTapAnimationView(context: Context) : View(context) {
|
||||
|
||||
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||
private val animator = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||
duration = 1600
|
||||
repeatCount = ValueAnimator.INFINITE
|
||||
repeatMode = ValueAnimator.RESTART
|
||||
addUpdateListener { invalidate() }
|
||||
start()
|
||||
}
|
||||
|
||||
fun stopAnimation() = animator.cancel()
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
val w = width.toFloat(); val h = height.toFloat()
|
||||
if (w <= 0f || h <= 0f) return
|
||||
|
||||
val dp = resources.displayMetrics.density
|
||||
val progress = animator.animatedFraction
|
||||
val cx = w / 2f; val cy = h / 2f + 24 * dp
|
||||
|
||||
val colorOnSurface = MaterialColors.getColor(this,
|
||||
com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK)
|
||||
val colorPrimary = MaterialColors.getColor(this,
|
||||
com.google.android.material.R.attr.colorPrimary, android.graphics.Color.BLUE)
|
||||
val colorSurfaceVariant = MaterialColors.getColor(this,
|
||||
com.google.android.material.R.attr.colorSurfaceVariant, android.graphics.Color.LTGRAY)
|
||||
|
||||
// POS terminal (top center)
|
||||
val posW = 44 * dp; val posH = 72 * dp
|
||||
val posX = cx - posW / 2f; val posY = cy - 170 * dp
|
||||
|
||||
// Phone (bottom center)
|
||||
val phoneW = 52 * dp; val phoneH = 90 * dp
|
||||
val phoneX = cx - phoneW / 2f; val phoneY = cy + 30 * dp
|
||||
|
||||
// POS terminal body
|
||||
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
|
||||
canvas.drawRoundRect(posX, posY, posX + posW, posY + posH, 7 * dp, 7 * dp, paint)
|
||||
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * dp; paint.color = colorOnSurface
|
||||
canvas.drawRoundRect(posX, posY, posX + posW, posY + posH, 7 * dp, 7 * dp, paint)
|
||||
|
||||
// POS screen
|
||||
paint.style = Paint.Style.FILL; paint.color = colorPrimary; paint.alpha = 70
|
||||
canvas.drawRoundRect(posX + 4 * dp, posY + 6 * dp,
|
||||
posX + posW - 4 * dp, posY + posH * 0.45f, 4 * dp, 4 * dp, paint)
|
||||
paint.alpha = 255
|
||||
|
||||
// POS card slot
|
||||
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2 * dp; paint.color = colorOnSurface
|
||||
canvas.drawLine(posX + 6 * dp, posY + posH * 0.72f, posX + posW - 6 * dp, posY + posH * 0.72f, paint)
|
||||
|
||||
// Phone body
|
||||
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
|
||||
canvas.drawRoundRect(phoneX, phoneY, phoneX + phoneW, phoneY + phoneH, 8 * dp, 8 * dp, paint)
|
||||
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * dp; paint.color = colorOnSurface
|
||||
canvas.drawRoundRect(phoneX, phoneY, phoneX + phoneW, phoneY + phoneH, 8 * dp, 8 * dp, paint)
|
||||
|
||||
// Phone screen
|
||||
paint.style = Paint.Style.FILL; paint.color = colorPrimary; paint.alpha = 70
|
||||
canvas.drawRoundRect(phoneX + 4 * dp, phoneY + 10 * dp,
|
||||
phoneX + phoneW - 4 * dp, phoneY + phoneH - 15 * dp, 4 * dp, 4 * dp, paint)
|
||||
paint.alpha = 255
|
||||
|
||||
// Animated NFC rings originating from phone top, travelling upward toward POS
|
||||
val gapTop = posY + posH + 4 * dp
|
||||
val originY = phoneY
|
||||
val maxR = (originY - gapTop) - 4 * dp
|
||||
paint.style = Paint.Style.STROKE; paint.strokeWidth = 3 * dp
|
||||
for (i in 0..2) {
|
||||
val p = ((progress + i / 3f) % 1f)
|
||||
val r = (p * maxR + 6 * dp).coerceAtMost(maxR)
|
||||
paint.color = colorPrimary; paint.alpha = ((1f - p) * 200).toInt().coerceIn(0, 255)
|
||||
canvas.drawArc(RectF(cx - r, originY - r, cx + r, originY + r), -160f, 140f, false, paint)
|
||||
}
|
||||
paint.alpha = 255
|
||||
|
||||
// Label
|
||||
paint.style = Paint.Style.FILL; paint.color = colorOnSurface; paint.alpha = 160
|
||||
paint.textSize = 15 * dp; paint.textAlign = Paint.Align.CENTER
|
||||
canvas.drawText(context.getString(R.string.card_pay_nfc), cx, phoneY + phoneH + 28 * dp, paint)
|
||||
paint.alpha = 255; paint.textAlign = Paint.Align.LEFT
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_AUTO_TAP_MODE = "auto_tap_mode"
|
||||
private const val ARG_AUTO_TAP_ACCOUNT = "auto_tap_account"
|
||||
fun newInstanceWithAutoTapMode(accountNumber: String? = null) = CardsFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putBoolean(ARG_AUTO_TAP_MODE, true)
|
||||
if (accountNumber != null) putString(ARG_AUTO_TAP_ACCOUNT, accountNumber)
|
||||
}
|
||||
}
|
||||
|
||||
fun cardImageAsset(card: MibCard): String? = when (card.cardType) {
|
||||
"51" -> "cards/mib/faisa_card.png"
|
||||
"53" -> "cards/mib/visa_black_platinum.png"
|
||||
@@ -566,9 +1230,13 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
|
||||
fun mibCardStatusLabel(cardStatus: String): String? = when (cardStatus) {
|
||||
"CHST0" -> null
|
||||
"CHST20" -> "Temporary blocked by client"
|
||||
else -> cardStatus
|
||||
}
|
||||
|
||||
fun isMibCardActive(cardStatus: String): Boolean = cardStatus == "CHST0"
|
||||
fun isMibCardFrozen(cardStatus: String): Boolean = cardStatus == "CHST20"
|
||||
|
||||
fun bindCardStatus(tv: TextView, statusLabel: String?) {
|
||||
if (statusLabel == null) { tv.visibility = View.GONE; return }
|
||||
tv.visibility = View.VISIBLE
|
||||
|
||||
@@ -106,6 +106,8 @@ class QrScannerActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||
binding = ActivityQrScannerBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
// Black camera background — always use light (white) system bar icons
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import sh.sar.basedbank.BuildConfig
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentSettingsAboutBinding
|
||||
|
||||
class SettingsAboutFragment : Fragment() {
|
||||
|
||||
private var _binding: FragmentSettingsAboutBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentSettingsAboutBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
|
||||
val basePaddingBottom = binding.root.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
|
||||
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||
insets
|
||||
}
|
||||
binding.tvAppName.text = getString(R.string.app_name)
|
||||
binding.tvVersion.text = getString(R.string.about_version, BuildConfig.VERSION_NAME)
|
||||
|
||||
binding.rowMibTerms.setOnClickListener { openUrl("https://faisanet.mib.com.mv/terms") }
|
||||
binding.rowBmlTerms.setOnClickListener { openUrl("https://www.bankofmaldives.com.mv/storage/file/121/10289/terms-conditions-online-banking-en.pdf") }
|
||||
binding.rowFahipayTerms.setOnClickListener { openUrl("https://fahipay.mv/tos/") }
|
||||
|
||||
val hasMvr = BuildConfig.ACCOUNT_MVR.isNotEmpty()
|
||||
val hasUsd = BuildConfig.ACCOUNT_USD.isNotEmpty()
|
||||
|
||||
if (!hasMvr && !hasUsd) {
|
||||
binding.sectionDonate.visibility = View.GONE
|
||||
} else {
|
||||
if (!hasMvr) binding.btnDonateMvr.visibility = View.GONE
|
||||
else binding.btnDonateMvr.setOnClickListener { openDonate(BuildConfig.ACCOUNT_MVR) }
|
||||
if (!hasUsd) binding.btnDonateUsd.visibility = View.GONE
|
||||
else binding.btnDonateUsd.setOnClickListener { openDonate(BuildConfig.ACCOUNT_USD) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun openUrl(url: String) {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
|
||||
}
|
||||
|
||||
private fun openDonate(accountNumber: String) {
|
||||
val fragment = TransferFragment.newInstance(
|
||||
accountNumber = accountNumber,
|
||||
displayName = getString(R.string.app_name),
|
||||
subtitle = accountNumber,
|
||||
colorHex = "#607D8B",
|
||||
imageHash = null
|
||||
)
|
||||
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = getString(R.string.settings_about)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.text.InputType
|
||||
@@ -16,6 +17,8 @@ import android.widget.Toast
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
|
||||
import androidx.core.os.LocaleListCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
@@ -36,8 +39,10 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
private lateinit var prefs: SharedPreferences
|
||||
private val slots = mutableListOf<Int>()
|
||||
private val quickActions = mutableListOf<Int>()
|
||||
private val circularSlots = mutableListOf<Int>()
|
||||
private lateinit var slotAdapter: NavItemAdapter
|
||||
private lateinit var quickActionAdapter: NavItemAdapter
|
||||
private lateinit var circularSlotAdapter: NavItemAdapter
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false)
|
||||
@@ -46,13 +51,30 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
|
||||
val basePaddingBottom = binding.root.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
|
||||
val isBottomNav = prefs.getBoolean("bottom_nav", false)
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||
insets
|
||||
}
|
||||
|
||||
// Navigation mode
|
||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer)
|
||||
val currentMode = NavCustomization.getNavMode(prefs)
|
||||
binding.navModeToggle.check(when (currentMode) {
|
||||
NavCustomization.NAV_MODE_BOTTOM -> R.id.btnNavBottom
|
||||
NavCustomization.NAV_MODE_CIRCULAR -> R.id.btnNavCircular
|
||||
else -> R.id.btnNavDrawer
|
||||
})
|
||||
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||
if (!isChecked) return@addOnButtonCheckedListener
|
||||
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
|
||||
val mode = when (checkedId) {
|
||||
R.id.btnNavBottom -> NavCustomization.NAV_MODE_BOTTOM
|
||||
R.id.btnNavCircular -> NavCustomization.NAV_MODE_CIRCULAR
|
||||
else -> NavCustomization.NAV_MODE_DRAWER
|
||||
}
|
||||
NavCustomization.saveNavMode(prefs, mode)
|
||||
(activity as? HomeActivity)?.applyNavMode()
|
||||
updateShortcutsVisibility()
|
||||
}
|
||||
@@ -63,10 +85,22 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
quickActionAdapter = NavItemAdapter(
|
||||
items = quickActions,
|
||||
onSave = { NavCustomization.saveQuickActions(prefs, quickActions) },
|
||||
isEnabled = { !prefs.getBoolean("bottom_nav", false) }
|
||||
isEnabled = { NavCustomization.getNavMode(prefs) != NavCustomization.NAV_MODE_BOTTOM }
|
||||
)
|
||||
setupNavItemRecyclerView(binding.rvQuickActions, quickActionAdapter, quickActions) {
|
||||
!prefs.getBoolean("bottom_nav", false)
|
||||
NavCustomization.getNavMode(prefs) != NavCustomization.NAV_MODE_BOTTOM
|
||||
}
|
||||
|
||||
// Circular nav shortcuts
|
||||
circularSlots.clear()
|
||||
circularSlots.addAll(NavCustomization.getCircularSlots(prefs))
|
||||
circularSlotAdapter = NavItemAdapter(
|
||||
items = circularSlots,
|
||||
onSave = { NavCustomization.saveCircularSlots(prefs, circularSlots) },
|
||||
isEnabled = { NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_CIRCULAR }
|
||||
)
|
||||
setupNavItemRecyclerView(binding.rvCircularSlots, circularSlotAdapter, circularSlots) {
|
||||
NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_CIRCULAR
|
||||
}
|
||||
|
||||
// Bottom bar shortcuts
|
||||
@@ -78,10 +112,10 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
NavCustomization.saveSlots(prefs, slots)
|
||||
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
|
||||
},
|
||||
isEnabled = { prefs.getBoolean("bottom_nav", false) }
|
||||
isEnabled = { NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM }
|
||||
)
|
||||
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) {
|
||||
prefs.getBoolean("bottom_nav", false)
|
||||
NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM
|
||||
}
|
||||
// Show labels toggle
|
||||
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
|
||||
@@ -102,6 +136,7 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
})
|
||||
binding.themeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||
if (!isChecked) return@addOnButtonCheckedListener
|
||||
val previousKey = prefs.getString("theme", "system")
|
||||
val (key, mode) = when (checkedId) {
|
||||
R.id.btnThemeLight -> "light" to AppCompatDelegate.MODE_NIGHT_NO
|
||||
R.id.btnThemeDark -> "dark" to AppCompatDelegate.MODE_NIGHT_YES
|
||||
@@ -111,6 +146,16 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
AppCompatDelegate.setDefaultNightMode(mode)
|
||||
updateAccentState(key == "system")
|
||||
updatePitchBlackState(key == "dark")
|
||||
if (key == "system") {
|
||||
requireActivity().recreate()
|
||||
} else if (previousKey == "system") {
|
||||
// setDefaultNightMode only recreates if the effective mode changes.
|
||||
// If system was already dark and we switch to dark (or light→light),
|
||||
// no recreation is triggered and the custom accent never gets applied.
|
||||
val currentIsNight = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
||||
val newIsNight = mode == AppCompatDelegate.MODE_NIGHT_YES
|
||||
if (currentIsNight == newIsNight) requireActivity().recreate()
|
||||
}
|
||||
}
|
||||
|
||||
// Pitch black
|
||||
@@ -191,11 +236,15 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun updateShortcutsVisibility() {
|
||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||
binding.sectionQuickActions.alpha = if (isBottom) 0.38f else 1f
|
||||
val mode = NavCustomization.getNavMode(prefs)
|
||||
val isBottom = mode == NavCustomization.NAV_MODE_BOTTOM
|
||||
val isCircular = mode == NavCustomization.NAV_MODE_CIRCULAR
|
||||
binding.sectionQuickActions.alpha = if (!isBottom) 1f else 0.38f
|
||||
binding.sectionCircularSlots.alpha = if (isCircular) 1f else 0.38f
|
||||
binding.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
|
||||
binding.switchShowLabels.isClickable = isBottom
|
||||
quickActionAdapter.notifyDataSetChanged()
|
||||
circularSlotAdapter.notifyDataSetChanged()
|
||||
slotAdapter.notifyDataSetChanged()
|
||||
}
|
||||
|
||||
@@ -262,9 +311,10 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
|
||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||
if (items === slots && !isBottom) return
|
||||
if (items === quickActions && isBottom) return
|
||||
val mode = NavCustomization.getNavMode(prefs)
|
||||
if (items === slots && mode != NavCustomization.NAV_MODE_BOTTOM) return
|
||||
if (items === quickActions && mode == NavCustomization.NAV_MODE_BOTTOM) return
|
||||
if (items === circularSlots && mode != NavCustomization.NAV_MODE_CIRCULAR) return
|
||||
val ctx = requireContext()
|
||||
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
|
||||
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
|
||||
|
||||
@@ -9,6 +9,8 @@ import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import sh.sar.basedbank.R
|
||||
|
||||
@@ -25,13 +27,23 @@ class SettingsFragment : Fragment() {
|
||||
SettingsItem(R.drawable.ic_contacts, R.string.settings_logins, R.string.settings_desc_logins) { SettingsLoginsFragment() },
|
||||
SettingsItem(R.drawable.ic_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_bell_filled, R.string.settings_notifications, R.string.settings_desc_notifications) { SettingsNotificationsFragment() },
|
||||
SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage, R.string.settings_desc_storage) { SettingsStorageFragment() },
|
||||
SettingsItem(R.drawable.ic_info, R.string.settings_about, R.string.settings_desc_about) { SettingsAboutFragment() },
|
||||
)
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
||||
inflater.inflate(R.layout.fragment_settings, container, false)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
(view as? android.widget.ScrollView)?.clipToPadding = false
|
||||
val basePaddingBottom = view.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
||||
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||
insets
|
||||
}
|
||||
val list = view.findViewById<LinearLayout>(R.id.settingsList)
|
||||
val inflater = LayoutInflater.from(requireContext())
|
||||
for (item in items) {
|
||||
|
||||
@@ -20,6 +20,8 @@ import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
@@ -333,6 +335,14 @@ class SettingsLoginsFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
|
||||
val basePaddingBottom = binding.root.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
|
||||
val isBottomNav = requireContext().getSharedPreferences("prefs", android.content.Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||
insets
|
||||
}
|
||||
binding.btnAddAccount.setOnClickListener {
|
||||
startActivity(Intent(requireContext(), LoginActivity::class.java))
|
||||
}
|
||||
@@ -349,46 +359,87 @@ class SettingsLoginsFragment : Fragment() {
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private data class LoginEntry(val logoRes: Int, val displayName: String, val onClick: () -> Unit)
|
||||
|
||||
private fun buildLoginsSection() {
|
||||
val ctx = requireContext()
|
||||
val store = CredentialStore(ctx)
|
||||
val container = binding.loginsContainer
|
||||
container.removeAllViews()
|
||||
|
||||
val mibLoginIds = store.getMibLoginIds()
|
||||
val bmlLoginIds = store.getBmlLoginIds()
|
||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||
var anyLogins = false
|
||||
|
||||
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
|
||||
fun addGroup(loginIds: List<String>, entryFor: (String) -> LoginEntry, persistOrder: (List<String>) -> Unit) {
|
||||
if (loginIds.isEmpty()) return
|
||||
anyLogins = true
|
||||
val groupContainer = LinearLayout(ctx).apply { orientation = LinearLayout.VERTICAL }
|
||||
loginIds.forEachIndexed { index, loginId ->
|
||||
val entry = entryFor(loginId)
|
||||
fun swapAndPersist(otherIndex: Int) {
|
||||
val newOrder = loginIds.toMutableList().apply {
|
||||
val tmp = this[index]; this[index] = this[otherIndex]; this[otherIndex] = tmp
|
||||
}
|
||||
persistOrder(newOrder)
|
||||
buildLoginsSection()
|
||||
}
|
||||
addLoginRow(
|
||||
groupContainer, entry.logoRes, entry.displayName, entry.onClick,
|
||||
canMoveUp = index > 0,
|
||||
canMoveDown = index < loginIds.size - 1,
|
||||
onMoveUp = { swapAndPersist(index - 1) },
|
||||
onMoveDown = { swapAndPersist(index + 1) }
|
||||
)
|
||||
}
|
||||
container.addView(groupContainer)
|
||||
}
|
||||
|
||||
for (loginId in mibLoginIds) {
|
||||
addGroup(store.getMibLoginIds(), { loginId ->
|
||||
val profile = store.loadMibUserProfile(loginId)
|
||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.mib_name)
|
||||
val mibProfiles = store.loadMibProfiles(loginId)
|
||||
addLoginRow(container, R.drawable.mib_logo, displayName) {
|
||||
LoginEntry(R.drawable.mib_logo, displayName) {
|
||||
showMibLoginDetails(store, loginId, profile, mibProfiles)
|
||||
}
|
||||
}
|
||||
}, store::setMibLoginIds)
|
||||
|
||||
for (loginId in bmlLoginIds) {
|
||||
addGroup(store.getBmlLoginIds(), { loginId ->
|
||||
val profile = store.loadBmlUserProfile(loginId)
|
||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.bml_name)
|
||||
val bmlProfiles = store.loadBmlProfiles(loginId)
|
||||
addLoginRow(container, R.drawable.bml_logo_vector, displayName) {
|
||||
LoginEntry(R.drawable.bml_logo_vector, displayName) {
|
||||
showBmlLoginDetails(store, loginId, profile, bmlProfiles)
|
||||
}
|
||||
}
|
||||
}, store::setBmlLoginIds)
|
||||
|
||||
for (loginId in fahipayLoginIds) {
|
||||
addGroup(store.getFahipayLoginIds(), { loginId ->
|
||||
val profile = store.loadFahipayUserProfile(loginId)
|
||||
val displayName = profile?.fullName?.takeIf { it.isNotBlank() } ?: getString(R.string.fahipay_name)
|
||||
addLoginRow(container, R.drawable.fahipay_logo, displayName) {
|
||||
LoginEntry(R.drawable.fahipay_logo, displayName) {
|
||||
showFahipayLoginDetails(store, loginId, profile)
|
||||
}
|
||||
}, store::setFahipayLoginIds)
|
||||
|
||||
addGroup(store.getMfaisaLoginIds(), { loginId ->
|
||||
val profile = store.loadMfaisaUserProfile(loginId)
|
||||
val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name)
|
||||
LoginEntry(R.drawable.ooredoo_logo, displayName) {
|
||||
showMfaisaLoginDetails(store, loginId, profile)
|
||||
}
|
||||
}, store::setMfaisaLoginIds)
|
||||
|
||||
binding.tvLoginsTitle.visibility = if (anyLogins) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun addLoginRow(container: LinearLayout, logoRes: Int, displayName: String, onClick: () -> Unit) {
|
||||
private fun addLoginRow(
|
||||
container: LinearLayout,
|
||||
logoRes: Int,
|
||||
displayName: String,
|
||||
onClick: () -> Unit,
|
||||
canMoveUp: Boolean,
|
||||
canMoveDown: Boolean,
|
||||
onMoveUp: () -> Unit,
|
||||
onMoveDown: () -> Unit
|
||||
) {
|
||||
val ctx = requireContext()
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val row = LinearLayout(ctx).apply {
|
||||
@@ -410,10 +461,31 @@ class SettingsLoginsFragment : Fragment() {
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
row.addView(logo); row.addView(tvName)
|
||||
val btnUp = makeMoveButton(ctx, rotationDeg = -90f, enabled = canMoveUp, onClick = onMoveUp)
|
||||
val btnDown = makeMoveButton(ctx, rotationDeg = 90f, enabled = canMoveDown, onClick = onMoveDown)
|
||||
row.addView(logo); row.addView(tvName); row.addView(btnUp); row.addView(btnDown)
|
||||
container.addView(row)
|
||||
}
|
||||
|
||||
private fun makeMoveButton(ctx: Context, rotationDeg: Float, enabled: Boolean, onClick: () -> Unit): ImageView {
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val size = (32 * dp).toInt()
|
||||
return ImageView(ctx).apply {
|
||||
setImageResource(R.drawable.ic_arrow_right)
|
||||
rotation = rotationDeg
|
||||
scaleType = ImageView.ScaleType.CENTER_INSIDE
|
||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackgroundBorderless))
|
||||
background = ta.getDrawable(0); ta.recycle()
|
||||
val iconColor = com.google.android.material.color.MaterialColors.getColor(
|
||||
ctx, com.google.android.material.R.attr.colorOnSurfaceVariant, android.graphics.Color.GRAY)
|
||||
imageTintList = android.content.res.ColorStateList.valueOf(iconColor)
|
||||
alpha = if (enabled) 1f else 0.3f
|
||||
isClickable = enabled; isFocusable = enabled
|
||||
layoutParams = LinearLayout.LayoutParams(size, size).apply { marginStart = (4 * dp).toInt() }
|
||||
if (enabled) setOnClickListener { onClick() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun showMibLoginDetails(
|
||||
store: CredentialStore,
|
||||
loginId: String,
|
||||
@@ -1055,6 +1127,152 @@ class SettingsLoginsFragment : Fragment() {
|
||||
buildLoginsSection()
|
||||
}
|
||||
|
||||
private fun showMfaisaLoginDetails(
|
||||
store: CredentialStore,
|
||||
loginId: String,
|
||||
profile: CredentialStore.MfaisaUserProfile?
|
||||
) {
|
||||
val ctx = requireContext()
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val hide = viewModel.hideAmounts.value ?: false
|
||||
val masked = "••••••"
|
||||
|
||||
val pockets = sh.sar.basedbank.util.AccountCache.loadMfaisa(ctx, loginId)
|
||||
val hidden = store.getHiddenMfaisaPocketIds(loginId).toMutableSet()
|
||||
val originalHidden = hidden.toSet()
|
||||
|
||||
// The user-visible "profiles" are: M-Faisa (every non-PayPal pocket) and PayPal (if linked).
|
||||
// Each toggle covers the set of pocket account numbers that belong to that profile.
|
||||
data class MfaisaProfileRow(val label: String, val pocketIds: Set<String>)
|
||||
val mfaisaPockets = pockets.filter { it.profileType != "MFAISA_PAYPAL" }
|
||||
val paypalPockets = pockets.filter { it.profileType == "MFAISA_PAYPAL" }
|
||||
val profileRows = buildList {
|
||||
if (mfaisaPockets.isNotEmpty()) {
|
||||
add(MfaisaProfileRow("M-Faisa", mfaisaPockets.map { it.accountNumber }.toSet()))
|
||||
}
|
||||
if (paypalPockets.isNotEmpty()) {
|
||||
add(MfaisaProfileRow("PayPal", paypalPockets.map { it.accountNumber }.toSet()))
|
||||
}
|
||||
}
|
||||
|
||||
val scroll = android.widget.ScrollView(ctx)
|
||||
val container = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
val pad = (16 * dp).toInt()
|
||||
setPadding(pad, (8 * dp).toInt(), pad, pad)
|
||||
}
|
||||
scroll.addView(container)
|
||||
|
||||
listOfNotNull(
|
||||
profile?.name?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_name)}: $it" },
|
||||
profile?.email?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_email)}: ${if (hide) masked else it}" },
|
||||
profile?.mdnId?.takeIf { it.isNotBlank() }?.let { "${getString(R.string.login_detail_mobile)}: ${if (hide) masked else it}" }
|
||||
).forEach { line ->
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = line
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
bottomMargin = (4 * dp).toInt()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (profileRows.isNotEmpty()) {
|
||||
if (profile != null) {
|
||||
container.addView(View(ctx).apply {
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).also {
|
||||
it.topMargin = (12 * dp).toInt(); it.bottomMargin = (12 * dp).toInt()
|
||||
}
|
||||
setBackgroundColor(0x1F000000)
|
||||
})
|
||||
}
|
||||
container.addView(TextView(ctx).apply {
|
||||
text = getString(R.string.login_detail_profiles)
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||
it.bottomMargin = (8 * dp).toInt()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
val toggleRows = profileRows.map { row ->
|
||||
val v = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).also {
|
||||
it.bottomMargin = (4 * dp).toInt()
|
||||
}
|
||||
}
|
||||
val label = TextView(ctx).apply {
|
||||
text = row.label
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyMedium)
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
val toggle = MaterialSwitch(ctx).apply {
|
||||
isChecked = row.pocketIds.any { it !in hidden }
|
||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
marginStart = (4 * dp).toInt()
|
||||
}
|
||||
}
|
||||
v.addView(label)
|
||||
v.addView(toggle)
|
||||
container.addView(v)
|
||||
row to toggle
|
||||
}
|
||||
|
||||
fun updateToggleStates(saveBtn: android.widget.Button) {
|
||||
val visibleCount = toggleRows.count { (row, _) -> row.pocketIds.any { it !in hidden } }
|
||||
toggleRows.forEach { (_, toggle) ->
|
||||
toggle.isEnabled = !(toggle.isChecked && visibleCount == 1)
|
||||
}
|
||||
saveBtn.isEnabled = hidden != originalHidden && visibleCount >= 1
|
||||
}
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(getString(R.string.ooredoo_name))
|
||||
.setView(scroll)
|
||||
.apply {
|
||||
if (profileRows.isNotEmpty()) setPositiveButton(R.string.save, null)
|
||||
setNeutralButton(R.string.close, null)
|
||||
setNegativeButton(R.string.settings_logout) { _, _ ->
|
||||
confirmLogout(getString(R.string.ooredoo_name)) { logoutMfaisa(store, loginId) }
|
||||
}
|
||||
}
|
||||
.show()
|
||||
|
||||
if (profileRows.isNotEmpty()) {
|
||||
val saveBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||
saveBtn.isEnabled = false
|
||||
updateToggleStates(saveBtn)
|
||||
|
||||
toggleRows.forEach { (row, toggle) ->
|
||||
toggle.setOnCheckedChangeListener { _, checked ->
|
||||
if (checked) hidden.removeAll(row.pocketIds) else hidden.addAll(row.pocketIds)
|
||||
updateToggleStates(saveBtn)
|
||||
}
|
||||
}
|
||||
|
||||
saveBtn.setOnClickListener {
|
||||
store.setHiddenMfaisaPocketIds(loginId, hidden)
|
||||
clearAllCaches(ctx)
|
||||
dialog.dismiss()
|
||||
(activity as? HomeActivity)?.relogin()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun logoutMfaisa(store: CredentialStore, loginId: String) {
|
||||
val ctx = requireContext()
|
||||
store.clearMfaisaCredentials(loginId)
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
app.mfaisaSessions.remove(loginId)
|
||||
app.mfaisaAccounts = app.mfaisaAccounts.filter { it.loginTag != "mfaisa_$loginId" }
|
||||
app.accounts = app.accounts.filter { it.loginTag != "mfaisa_$loginId" }
|
||||
clearAllCaches(ctx)
|
||||
(activity as HomeActivity).relogin()
|
||||
buildLoginsSection()
|
||||
}
|
||||
|
||||
private fun clearAllCaches(ctx: Context) {
|
||||
AccountCache.clear(ctx); ContactsCache.clear(ctx); FinancingCache.clear(ctx)
|
||||
ForeignLimitsCache.clear(ctx); RecentsCache.clear(ctx)
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
package sh.sar.basedbank.ui.home
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.PowerManager
|
||||
import android.provider.Settings
|
||||
import android.view.Gravity
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.widget.SwitchCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.service.NotificationPollingService
|
||||
|
||||
class SettingsNotificationsFragment : Fragment() {
|
||||
|
||||
private var switchView: SwitchCompat? = null
|
||||
|
||||
// Step 1: notification permission — on grant, proceed to battery opt check
|
||||
private val permissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
) { granted ->
|
||||
if (granted) checkBatteryOptimization() else switchView?.isChecked = false
|
||||
}
|
||||
|
||||
// Step 2: battery optimization — proceed to enableService regardless of user choice
|
||||
private val batteryOptLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
enableService()
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val ctx = requireContext()
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val prefs = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
|
||||
val scroll = ScrollView(ctx).apply { clipToPadding = false }
|
||||
|
||||
val col = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
val p = (20 * dp).toInt()
|
||||
setPadding(p, p, p, p)
|
||||
}
|
||||
|
||||
// Section header
|
||||
col.addView(TextView(ctx).apply {
|
||||
setText(R.string.settings_notif_section)
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_LabelMedium)
|
||||
setTextColor(MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimary, Color.CYAN))
|
||||
setPadding(0, 0, 0, (12 * dp).toInt())
|
||||
})
|
||||
|
||||
// Enable toggle row
|
||||
val sw = SwitchCompat(ctx).apply {
|
||||
isChecked = prefs.getBoolean(PREF_ENABLED, false)
|
||||
}
|
||||
switchView = sw
|
||||
sw.setOnCheckedChangeListener { _, on -> if (on) requestEnableNotifications() else disableService() }
|
||||
|
||||
val toggleRow = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
|
||||
val vp = (10 * dp).toInt()
|
||||
setPadding(0, vp, 0, vp)
|
||||
}
|
||||
val textCol = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)
|
||||
}
|
||||
textCol.addView(TextView(ctx).apply {
|
||||
setText(R.string.settings_notif_enable)
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
||||
})
|
||||
textCol.addView(TextView(ctx).apply {
|
||||
setText(R.string.settings_notif_enable_desc)
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||
alpha = 0.65f
|
||||
})
|
||||
toggleRow.addView(textCol)
|
||||
toggleRow.addView(sw.apply {
|
||||
layoutParams = (layoutParams as? LinearLayout.LayoutParams ?: LinearLayout.LayoutParams(
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
)).also { it.marginStart = (12 * dp).toInt() }
|
||||
})
|
||||
col.addView(toggleRow)
|
||||
|
||||
// Description
|
||||
col.addView(TextView(ctx).apply {
|
||||
setText(R.string.settings_notif_description)
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||
alpha = 0.65f
|
||||
setPadding(0, (4 * dp).toInt(), 0, (20 * dp).toInt())
|
||||
})
|
||||
|
||||
// Notification channels nav row — same style as settings menu items
|
||||
val colPad = (20 * dp).toInt()
|
||||
val navRow = inflater.inflate(R.layout.item_more_nav, col, false).apply {
|
||||
layoutParams = (layoutParams as LinearLayout.LayoutParams).apply {
|
||||
marginStart = -colPad
|
||||
marginEnd = -colPad
|
||||
topMargin = (8 * dp).toInt()
|
||||
}
|
||||
findViewById<ImageView>(R.id.ivIcon).setImageResource(R.drawable.ic_bell_filled)
|
||||
findViewById<TextView>(R.id.tvLabel).setText(R.string.settings_notif_open_system)
|
||||
findViewById<TextView>(R.id.tvDescription).setText(R.string.settings_notif_channels_desc)
|
||||
setOnClickListener {
|
||||
startActivity(Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, ctx.packageName)
|
||||
})
|
||||
}
|
||||
}
|
||||
col.addView(navRow)
|
||||
|
||||
scroll.addView(col)
|
||||
return scroll
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val basePad = view.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
|
||||
val isBottom = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
.getBoolean("bottom_nav", false)
|
||||
val nav = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePad + if (isBottom) 0 else nav.bottom)
|
||||
insets
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
requireActivity().title = getString(R.string.settings_notifications)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
switchView = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
// ── Enable flow ───────────────────────────────────────────────────────────────
|
||||
|
||||
private fun requestEnableNotifications() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||
ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
return
|
||||
}
|
||||
checkBatteryOptimization()
|
||||
}
|
||||
|
||||
private fun checkBatteryOptimization() {
|
||||
val ctx = requireContext()
|
||||
val pm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
if (!pm.isIgnoringBatteryOptimizations(ctx.packageName)) {
|
||||
batteryOptLauncher.launch(
|
||||
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
|
||||
data = Uri.parse("package:${ctx.packageName}")
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
enableService()
|
||||
}
|
||||
|
||||
private fun enableService() {
|
||||
val ctx = requireContext()
|
||||
ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
.edit().putBoolean(PREF_ENABLED, true).apply()
|
||||
ctx.startForegroundService(Intent(ctx, NotificationPollingService::class.java))
|
||||
switchView?.isChecked = true
|
||||
}
|
||||
|
||||
private fun disableService() {
|
||||
val ctx = requireContext()
|
||||
ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
.edit().putBoolean(PREF_ENABLED, false).apply()
|
||||
ctx.stopService(Intent(ctx, NotificationPollingService::class.java))
|
||||
switchView?.isChecked = false
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PREF_ENABLED = "notifications_enabled"
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentSettingsSecurityBinding
|
||||
@@ -22,6 +24,14 @@ class SettingsSecurityFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
|
||||
val basePaddingBottom = binding.root.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
|
||||
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||
insets
|
||||
}
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
|
||||
// Change lock
|
||||
|
||||
@@ -6,6 +6,8 @@ import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
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 sh.sar.basedbank.R
|
||||
@@ -31,6 +33,14 @@ class SettingsStorageFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
(binding.root as? android.widget.ScrollView)?.clipToPadding = false
|
||||
val basePaddingBottom = binding.root.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v, insets ->
|
||||
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||
insets
|
||||
}
|
||||
binding.btnClearCache.setOnClickListener {
|
||||
val ctx = requireContext()
|
||||
clearAllCaches(ctx)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
||||
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaHistoryClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||
@@ -61,12 +62,18 @@ class TransferHistoryFragment : Fragment() {
|
||||
var bmlTotalPages: Int = -1,
|
||||
var cardMonthOffset: Int = 0,
|
||||
var fahipayNextStart: Int = 0,
|
||||
var fahipayTotal: Int = -1
|
||||
var fahipayTotal: Int = -1,
|
||||
var mfaisaNextPage: Int = 1,
|
||||
var mfaisaHasMore: Boolean = true
|
||||
) {
|
||||
// PayPal pockets have no known history endpoint, so they don't paginate here.
|
||||
private val isMfaisaPaypal get() = account.profileType == "MFAISA_PAYPAL"
|
||||
|
||||
fun hasMore(): Boolean = when {
|
||||
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
|
||||
account.bank == "MFAISA" -> !isMfaisaPaypal && mfaisaHasMore
|
||||
else -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||
}
|
||||
}
|
||||
@@ -209,13 +216,14 @@ class TransferHistoryFragment : Fragment() {
|
||||
cal.add(Calendar.MONTH, -state.cardMonthOffset)
|
||||
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
||||
state.cardMonthOffset++
|
||||
BmlHistoryClient().fetchCardHistory(
|
||||
val cardResult = BmlHistoryClient().fetchCardHistory(
|
||||
session = session,
|
||||
cardId = state.account.internalId,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
accountNumber = state.account.accountNumber,
|
||||
month = month
|
||||
)
|
||||
cardResult.statement + cardResult.outstanding + cardResult.unbilled
|
||||
}
|
||||
else -> {
|
||||
val session = app.bmlSessionFor(state.account) ?: return@async emptyList()
|
||||
@@ -235,6 +243,36 @@ class TransferHistoryFragment : Fragment() {
|
||||
}
|
||||
}.awaitAll().flatten())
|
||||
|
||||
// M-Faisa accounts (PayPal pockets are skipped by hasMore())
|
||||
val mfaisaStates = activeStates.filter { it.account.bank == "MFAISA" }
|
||||
for (state in mfaisaStates) {
|
||||
var session = app.mfaisaSessionFor(state.account) ?: continue
|
||||
try {
|
||||
val page = try {
|
||||
MfaisaHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountNumber = state.account.accountNumber,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
pageNo = state.mfaisaNextPage,
|
||||
recordSize = 70
|
||||
)
|
||||
} catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) {
|
||||
val loginId = state.account.loginTag.removePrefix("mfaisa_")
|
||||
session = app.refreshMfaisaSession(loginId) ?: continue
|
||||
MfaisaHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountNumber = state.account.accountNumber,
|
||||
accountDisplayName = state.account.accountBriefName,
|
||||
pageNo = state.mfaisaNextPage,
|
||||
recordSize = 70
|
||||
)
|
||||
}
|
||||
state.mfaisaHasMore = page.hasMore
|
||||
state.mfaisaNextPage++
|
||||
results.addAll(page.transactions)
|
||||
} catch (e: Exception) { trackError(e) }
|
||||
}
|
||||
|
||||
// Fahipay accounts
|
||||
val fahipayStates = activeStates.filter { it.account.bank == "FAHIPAY" }
|
||||
for (state in fahipayStates) {
|
||||
|
||||
@@ -19,4 +19,10 @@ data class TransferReceiptData(
|
||||
val bmlReference: String = "",
|
||||
val bmlTimestamp: String = "",
|
||||
val bmlMessage: String = "",
|
||||
// M-Faisa receipt fields
|
||||
val mfaisaTransactionType: String = "",
|
||||
val mfaisaFromName: String = "",
|
||||
val mfaisaFromMsisdn: String = "",
|
||||
val mfaisaToMsisdn: String = "",
|
||||
val mfaisaTimestamp: Long = 0L,
|
||||
)
|
||||
|
||||
@@ -11,21 +11,19 @@ import android.graphics.BitmapFactory
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Rect
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.MediaStore
|
||||
import android.util.Base64
|
||||
import android.view.LayoutInflater
|
||||
import android.view.PixelCopy
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.button.MaterialButton
|
||||
@@ -36,6 +34,7 @@ import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.databinding.FragmentReceiptBmlBinding
|
||||
import sh.sar.basedbank.databinding.FragmentReceiptMfaisaBinding
|
||||
import sh.sar.basedbank.databinding.FragmentReceiptMibBinding
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
@@ -66,6 +65,11 @@ class TransferReceiptFragment : Fragment() {
|
||||
private const val ARG_BML_REFERENCE = "bml_reference"
|
||||
private const val ARG_BML_TIMESTAMP = "bml_timestamp"
|
||||
private const val ARG_BML_MESSAGE = "bml_message"
|
||||
private const val ARG_MFAISA_TXN_TYPE = "mfaisa_txn_type"
|
||||
private const val ARG_MFAISA_FROM_NAME = "mfaisa_from_name"
|
||||
private const val ARG_MFAISA_FROM_MSISDN = "mfaisa_from_msisdn"
|
||||
private const val ARG_MFAISA_TO_MSISDN = "mfaisa_to_msisdn"
|
||||
private const val ARG_MFAISA_TIMESTAMP = "mfaisa_timestamp"
|
||||
|
||||
// Holds the already-rendered to-avatar bitmap from TransferFragment
|
||||
var pendingToAvatarBitmap: Bitmap? = null
|
||||
@@ -89,31 +93,68 @@ class TransferReceiptFragment : Fragment() {
|
||||
putString(ARG_BML_REFERENCE, data.bmlReference)
|
||||
putString(ARG_BML_TIMESTAMP, data.bmlTimestamp)
|
||||
putString(ARG_BML_MESSAGE, data.bmlMessage)
|
||||
putString(ARG_MFAISA_TXN_TYPE, data.mfaisaTransactionType)
|
||||
putString(ARG_MFAISA_FROM_NAME, data.mfaisaFromName)
|
||||
putString(ARG_MFAISA_FROM_MSISDN, data.mfaisaFromMsisdn)
|
||||
putString(ARG_MFAISA_TO_MSISDN, data.mfaisaToMsisdn)
|
||||
putLong(ARG_MFAISA_TIMESTAMP, data.mfaisaTimestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
val bank = arguments?.getString(ARG_BANK, "MIB") ?: "MIB"
|
||||
return if (bank == "MIB") {
|
||||
return when (bank) {
|
||||
"MIB" -> {
|
||||
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
|
||||
bindMib(binding)
|
||||
_receiptCard = binding.receiptCard
|
||||
binding.root
|
||||
} else {
|
||||
}
|
||||
"MFAISA" -> {
|
||||
val binding = FragmentReceiptMfaisaBinding.inflate(inflater, container, false)
|
||||
bindMfaisa(binding)
|
||||
_receiptCard = binding.receiptCard
|
||||
binding.root
|
||||
}
|
||||
else -> {
|
||||
val binding = FragmentReceiptBmlBinding.inflate(inflater, container, false)
|
||||
bindBml(binding)
|
||||
_receiptCard = binding.receiptCard
|
||||
binding.root
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
receiptCard.setOnClickListener { showFullScreenReceipt() }
|
||||
|
||||
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
|
||||
parentFragmentManager.popBackStack()
|
||||
val btnRow = view.findViewById<View>(R.id.btnRow)
|
||||
val basePaddingBottom = btnRow.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(btnRow) { v, insets ->
|
||||
val isBottomNav = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, basePaddingBottom + if (isBottomNav) 0 else navBar.bottom)
|
||||
insets
|
||||
}
|
||||
|
||||
val receiptContainer = view.findViewById<android.widget.ScrollView>(R.id.receiptContainer)
|
||||
receiptContainer.setOnTouchListener { _, _ -> true }
|
||||
receiptContainer.viewTreeObserver.addOnGlobalLayoutListener(object : android.view.ViewTreeObserver.OnGlobalLayoutListener {
|
||||
override fun onGlobalLayout() {
|
||||
receiptContainer.viewTreeObserver.removeOnGlobalLayoutListener(this)
|
||||
val available = receiptContainer.height
|
||||
val natural = receiptCard.height
|
||||
if (natural > available && available > 0) {
|
||||
val scale = available.toFloat() / natural
|
||||
receiptCard.scaleX = scale
|
||||
receiptCard.scaleY = scale
|
||||
receiptCard.pivotX = receiptCard.width / 2f
|
||||
receiptCard.pivotY = 0f
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
view.findViewById<MaterialButton>(R.id.btnShare).setOnClickListener {
|
||||
shareReceipt()
|
||||
}
|
||||
@@ -222,6 +263,49 @@ class TransferReceiptFragment : Fragment() {
|
||||
)
|
||||
}
|
||||
|
||||
private fun bindMfaisa(binding: FragmentReceiptMfaisaBinding) {
|
||||
val args = requireArguments()
|
||||
val currency = args.getString(ARG_CURRENCY, "MVR")
|
||||
val amountStr = args.getString(ARG_AMOUNT, "")
|
||||
|
||||
val formattedAmount = try {
|
||||
val d = amountStr.toDouble()
|
||||
val intFmt = NumberFormat.getNumberInstance(Locale.US).apply { maximumFractionDigits = 0 }
|
||||
intFmt.format(d.toLong()) + "%.2f".format(d).takeLast(3)
|
||||
} catch (_: Exception) { amountStr }
|
||||
|
||||
binding.tvAmount.text = "$currency $formattedAmount"
|
||||
binding.tvTransactionType.text = args.getString(ARG_MFAISA_TXN_TYPE, "")
|
||||
.ifBlank { "Transfer to mobile" }
|
||||
binding.tvFromName.text = args.getString(ARG_MFAISA_FROM_NAME, "")
|
||||
.ifBlank { args.getString(ARG_FROM_LABEL, "") }
|
||||
binding.tvFromMsisdn.text = args.getString(ARG_MFAISA_FROM_MSISDN, "")
|
||||
binding.tvToName.text = args.getString(ARG_TO_LABEL, "")
|
||||
binding.tvToMsisdn.text = args.getString(ARG_MFAISA_TO_MSISDN, "")
|
||||
.ifBlank { args.getString(ARG_TO_ACCOUNT, "") }
|
||||
binding.tvDateTime.text = formatMfaisaTimestamp(args.getLong(ARG_MFAISA_TIMESTAMP, 0L))
|
||||
|
||||
val remarks = args.getString(ARG_REMARKS, "")
|
||||
if (!remarks.isNullOrBlank()) {
|
||||
binding.tvRemarks.text = remarks
|
||||
binding.remarksDivider.visibility = View.VISIBLE
|
||||
binding.remarksRow.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
copyOnLongClick(
|
||||
binding.tvAmount, binding.tvStatus, binding.tvTransactionType,
|
||||
binding.tvFromName, binding.tvFromMsisdn,
|
||||
binding.tvToName, binding.tvToMsisdn,
|
||||
binding.tvDateTime, binding.tvRemarks
|
||||
)
|
||||
}
|
||||
|
||||
private fun formatMfaisaTimestamp(millis: Long): String {
|
||||
val effective = if (millis > 0) millis else System.currentTimeMillis()
|
||||
val sdf = java.text.SimpleDateFormat("EEEE d MMMM yyyy HH:mm:ss z", Locale.US)
|
||||
return sdf.format(java.util.Date(effective))
|
||||
}
|
||||
|
||||
// ── Share / Save ──────────────────────────────────────────────────────────
|
||||
|
||||
private fun shareReceipt() {
|
||||
@@ -286,21 +370,19 @@ class TransferReceiptFragment : Fragment() {
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Captures the receipt card using PixelCopy, which correctly handles
|
||||
* hardware-accelerated views (avoids the black-square problem with view.draw()).
|
||||
* Draws the receipt card to an offscreen bitmap at its natural (unscaled)
|
||||
* dimensions, so the captured image isn't affected by the on-screen scale
|
||||
* applied to fit small viewports and doesn't pick up overlapping siblings.
|
||||
*/
|
||||
private fun captureReceiptBitmap(callback: (Bitmap?) -> Unit) {
|
||||
val view = _receiptCard ?: run { callback(null); return }
|
||||
if (view.width == 0 || view.height == 0) { callback(null); return }
|
||||
|
||||
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
|
||||
val location = IntArray(2)
|
||||
view.getLocationInWindow(location)
|
||||
val srcRect = Rect(location[0], location[1], location[0] + view.width, location[1] + view.height)
|
||||
|
||||
PixelCopy.request(requireActivity().window, srcRect, bitmap, { result ->
|
||||
callback(if (result == PixelCopy.SUCCESS) bitmap else null)
|
||||
}, Handler(Looper.getMainLooper()))
|
||||
val canvas = Canvas(bitmap)
|
||||
canvas.drawColor(Color.WHITE)
|
||||
view.draw(canvas)
|
||||
callback(bitmap)
|
||||
}
|
||||
|
||||
private fun formatBmlTimestamp(raw: String): String {
|
||||
@@ -338,15 +420,23 @@ class TransferReceiptFragment : Fragment() {
|
||||
setBackgroundColor(Color.BLACK)
|
||||
}
|
||||
|
||||
val cardView = if (bank == "MIB") {
|
||||
val cardView = when (bank) {
|
||||
"MIB" -> {
|
||||
val binding = FragmentReceiptMibBinding.inflate(layoutInflater)
|
||||
bindMib(binding)
|
||||
binding.receiptCard
|
||||
} else {
|
||||
}
|
||||
"MFAISA" -> {
|
||||
val binding = FragmentReceiptMfaisaBinding.inflate(layoutInflater)
|
||||
bindMfaisa(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)
|
||||
@@ -395,13 +485,9 @@ class TransferReceiptFragment : Fragment() {
|
||||
(activity as? HomeActivity)?.setBottomNavVisible(false)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
super.onPause()
|
||||
(activity as? HomeActivity)?.setBottomNavVisible(true)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
(activity as? HomeActivity)?.setBottomNavVisible(true)
|
||||
_receiptCard = null
|
||||
pendingToAvatarBitmap = null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,739 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.bml.BmlAccountClient
|
||||
import sh.sar.basedbank.api.bml.BmlOtpChannel
|
||||
import sh.sar.basedbank.api.bml.BmlQrPayClient
|
||||
import sh.sar.basedbank.api.bml.BmlQrPayInfo
|
||||
import sh.sar.basedbank.api.bml.BmlQrPayLookupException
|
||||
import sh.sar.basedbank.api.bml.BmlQrPayResult
|
||||
import sh.sar.basedbank.api.bml.BmlSession
|
||||
import sh.sar.basedbank.api.bml.BmlTransferClient
|
||||
import sh.sar.basedbank.api.bml.BmlValidateClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.models.BankContact
|
||||
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||
import sh.sar.basedbank.ui.home.HomeActivity
|
||||
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||
import sh.sar.basedbank.ui.home.TransferFragment
|
||||
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.PaymvQrParser
|
||||
import sh.sar.basedbank.util.RecentPick
|
||||
import sh.sar.basedbank.util.RecentsCache
|
||||
import sh.sar.basedbank.util.Totp
|
||||
|
||||
/**
|
||||
* Owns the BML-only parts of the Transfer screen: session lookup, the CASA/card transfer itself,
|
||||
* the business-profile OTP state machine (channel pick → initiate → verify), and merchant QR
|
||||
* payment (card/gateway QR lookup + pre-initiate/initiate/confirm).
|
||||
*
|
||||
* Lives alongside [TransferFragment], which dispatches to it whenever the selected source's
|
||||
* [BankAccount.bank] is "BML", and mirrors [MfaisaTransferHandler]'s shape: the fragment keeps
|
||||
* the shared confirm dialog, the "To" lookup and the form state; the handler keeps everything
|
||||
* BML-specific.
|
||||
*
|
||||
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
|
||||
* [androidx.fragment.app.Fragment.viewLifecycleOwner] and Context) — and must be re-created when
|
||||
* the view is recreated.
|
||||
*/
|
||||
class BmlTransferHandler(
|
||||
private val fragment: TransferFragment,
|
||||
private val binding: FragmentTransferBinding,
|
||||
private val viewModel: HomeViewModel,
|
||||
/** Reads the fragment's currently-selected source account. */
|
||||
private val currentSource: () -> BankAccount?,
|
||||
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
||||
private val selectSource: (BankAccount) -> Unit,
|
||||
/** Hook called whenever handler state changes in a way that affects the Send button. */
|
||||
private val onStateChanged: () -> Unit,
|
||||
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes balances. */
|
||||
private val onTransferSuccess: (TransferReceiptData, Bitmap?) -> Unit,
|
||||
) {
|
||||
|
||||
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||
private val ctx get() = fragment.requireContext()
|
||||
private val host get() = fragment.activity as? HomeActivity
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Business-profile OTP flow. NONE means the Send button behaves normally. */
|
||||
private enum class OtpState { NONE, SELECTING_CHANNEL, AWAITING_OTP }
|
||||
private var otpState = OtpState.NONE
|
||||
private var otpChannel: String? = null
|
||||
|
||||
private data class PendingTransfer(
|
||||
val src: BankAccount,
|
||||
val debitAccount: String,
|
||||
val creditAccount: String,
|
||||
val amount: Double,
|
||||
val amountStr: String,
|
||||
val remarks: String,
|
||||
val transferType: String,
|
||||
val currency: String,
|
||||
val bank: String?,
|
||||
val destDisplay: String,
|
||||
val destAccount: String,
|
||||
val toBank: String,
|
||||
val toAvatar: Bitmap?
|
||||
)
|
||||
private var pendingTransfer: PendingTransfer? = null
|
||||
|
||||
/** Merchant QR payment mode (set when navigated from a card/gateway QR scan). */
|
||||
var qrInfo: BmlQrPayInfo? = null
|
||||
private set
|
||||
/** True for pay.bml.com.mv QRs, which need an extra pre-initiate step. */
|
||||
private var gatewayQr = false
|
||||
/** Prevents re-running the lookup after the user clears the merchant. */
|
||||
var qrLookupAttempted = false
|
||||
private set
|
||||
|
||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||
|
||||
/** Whether the business OTP flow is mid-way — the fragment freezes the Send button while it is. */
|
||||
val isOtpFlowActive: Boolean get() = otpState != OtpState.NONE
|
||||
|
||||
/** Whether the Send button should verify an OTP instead of starting a new transfer. */
|
||||
val isAwaitingOtp: Boolean get() = otpState == OtpState.AWAITING_OTP
|
||||
|
||||
/** Whether a merchant QR is loaded — counts as a resolved recipient for the Send button. */
|
||||
val hasQrMerchant: Boolean get() = qrInfo != null
|
||||
|
||||
fun sessionFor(account: BankAccount?): BmlSession? =
|
||||
account?.let { app.bmlSessionFor(it) } ?: app.anyBmlSession()
|
||||
|
||||
fun isBusinessProfile(account: BankAccount): Boolean {
|
||||
val loginId = account.loginTag.removePrefix("bml_")
|
||||
val profiles = app.bmlProfilesMap[loginId] ?: return false
|
||||
return profiles.firstOrNull { it.profileId == account.profileId }?.profileType == "business"
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a destination through BML — `verifyMibAccount` when [verifyAsMib], otherwise the
|
||||
* regular alias/account validation. Blocking; call from IO. Returns null when BML can't
|
||||
* resolve it, so the caller can fall back to the MIB IPS lookup.
|
||||
*
|
||||
* The result is mapped onto [MibIpsAccountInfo] because that is the Transfer screen's common
|
||||
* "resolved recipient" shape, whichever bank did the resolving. Note that the MIB-verify
|
||||
* endpoint leaves `currency` blank — the caller enriches it via
|
||||
* [MibTransferHandler.lookupCurrency] when a MIB session is available.
|
||||
*/
|
||||
fun validateDestination(
|
||||
session: BmlSession,
|
||||
accountNumber: String,
|
||||
verifyAsMib: Boolean
|
||||
): MibIpsAccountInfo? {
|
||||
val result = try {
|
||||
if (verifyAsMib) BmlValidateClient().verifyMibAccount(session, accountNumber)
|
||||
else BmlValidateClient().validateAccount(session, accountNumber)
|
||||
} catch (_: Exception) { null } ?: return null
|
||||
val bankId = when (result.trnType) {
|
||||
"IAT" -> "MALBMVMV"
|
||||
else -> result.agnt ?: result.account
|
||||
}
|
||||
return MibIpsAccountInfo(
|
||||
accountName = result.name,
|
||||
accountNumber = result.account,
|
||||
bankId = bankId,
|
||||
currency = result.currency
|
||||
)
|
||||
}
|
||||
|
||||
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
|
||||
fun clearQrMerchant() {
|
||||
if (qrInfo == null) return
|
||||
qrInfo = null
|
||||
gatewayQr = false
|
||||
binding.tilAmount.isEnabled = true
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
binding.etAmount.setText("")
|
||||
}
|
||||
|
||||
/** Called when the view tears down — an in-flight OTP flow cannot sensibly resume. */
|
||||
fun clearState() {
|
||||
otpState = OtpState.NONE
|
||||
otpChannel = null
|
||||
pendingTransfer = null
|
||||
}
|
||||
|
||||
// ─── Merchant QR ─────────────────────────────────────────────────────────
|
||||
|
||||
fun lookupQrMerchant(qrUrl: String) {
|
||||
qrLookupAttempted = true
|
||||
// Gateway QRs and POS QRs (the raw EMV payload, not a URL) both carry a preset amount and
|
||||
// need the extra pre-initiate POST; ebanking qrpay URLs do not.
|
||||
gatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/") || !qrUrl.startsWith("https://")
|
||||
val payTarget = PaymvQrParser.bmlPayRequestKey(qrUrl)
|
||||
val session = app.anyBmlSession() ?: return
|
||||
|
||||
// Lock the "To" input row while loading
|
||||
binding.tilTo.visibility = View.GONE
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
host?.setRefreshing(true)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching { BmlQrPayClient().lookupPayRequest(session, payTarget) }
|
||||
}
|
||||
host?.setRefreshing(false)
|
||||
val info = result.getOrNull()
|
||||
if (info == null) {
|
||||
// An expired or rejected QR is BML telling us something specific — show its own
|
||||
// wording and stay put with the To row restored, rather than bouncing the user out
|
||||
// of the screen they just scanned from.
|
||||
val message = (result.exceptionOrNull() as? BmlQrPayLookupException)?.message
|
||||
?: ctx.getString(R.string.bml_qr_lookup_failed)
|
||||
Toast.makeText(ctx, message, Toast.LENGTH_LONG).show()
|
||||
fragment.resetToFieldVisibility()
|
||||
onStateChanged()
|
||||
return@launch
|
||||
}
|
||||
qrInfo = info
|
||||
if (info.amount == 0.0) {
|
||||
RecentsCache.save(ctx, RecentPick(
|
||||
accountNumber = "bmlqr:$qrUrl",
|
||||
displayName = info.merchantName,
|
||||
subtitle = info.merchantAddress.ifBlank { "BML Merchant" },
|
||||
colorHex = "#0066A1",
|
||||
imageHash = null,
|
||||
isProfileImage = false
|
||||
))
|
||||
}
|
||||
|
||||
// Auto-select the user's default BML card if no card was pre-selected
|
||||
if (currentSource() == null) {
|
||||
val defaultNum = CredentialStore(ctx).getDefaultCardAccountNumber()
|
||||
if (defaultNum != null) {
|
||||
val allAccounts = viewModel.accounts.value ?: emptyList()
|
||||
val defaultCard = allAccounts.firstOrNull {
|
||||
it.accountNumber == defaultNum && isCard(it) &&
|
||||
it.statusDesc.equals("Active", ignoreCase = true)
|
||||
}
|
||||
if (defaultCard != null) selectSource(defaultCard)
|
||||
}
|
||||
}
|
||||
|
||||
// Show merchant in the "To" card — clear button hidden (can't change recipient for QR)
|
||||
binding.tvToAccountName.text = info.merchantName
|
||||
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
|
||||
binding.tvToAccountDetails.visibility = View.GONE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
binding.ivToPhoto.setImageBitmap(fragment.makeInitialsBitmap(info.merchantName, "#0066A1"))
|
||||
binding.cardToInfo.visibility = View.VISIBLE
|
||||
|
||||
// Pre-fill amount if dynamic QR
|
||||
if (info.amount > 0.0) {
|
||||
binding.etAmount.setText("%.2f".format(info.amount))
|
||||
binding.tilAmount.isEnabled = false
|
||||
}
|
||||
|
||||
// Remarks not applicable for merchant QR payments
|
||||
binding.tilRemarks.isEnabled = false
|
||||
binding.tilRemarks.alpha = 0.4f
|
||||
|
||||
onStateChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog and
|
||||
* reports the outcome inside it — there is no receipt screen for merchant payments.
|
||||
*/
|
||||
fun submitQrPayment() {
|
||||
val info = qrInfo ?: return
|
||||
val src = currentSource() ?: run {
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
|
||||
val amount = amountStr.toDoubleOrNull()
|
||||
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
|
||||
binding.tilAmount.error = null
|
||||
val debitAccount = src.internalId.ifBlank {
|
||||
Toast.makeText(ctx, R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val fromTypeLabel = sh.sar.basedbank.util.AccountListParser.from(src)?.typeLabel
|
||||
?: sh.sar.basedbank.util.bmlapi.BmlDashboardParser.productLabel(src.accountTypeName)
|
||||
val fromDetail = listOfNotNull("BML", fromTypeLabel.ifBlank { null }).joinToString(" · ")
|
||||
val confirmView = fragment.buildTransferConfirmView(
|
||||
amountCurrency = info.currency,
|
||||
amountValue = "%.2f".format(amount),
|
||||
fromName = src.accountBriefName,
|
||||
fromNumber = src.accountNumber,
|
||||
fromDetail = fromDetail,
|
||||
toName = info.merchantName,
|
||||
toNumber = "",
|
||||
toDetail = info.merchantAddress.ifBlank { "BML Merchant" }
|
||||
)
|
||||
fragment.showConfirmWithBiometric(
|
||||
title = ctx.getString(R.string.transfer),
|
||||
customView = confirmView,
|
||||
biometricSubtitle = "${info.currency} ${"%.2f".format(amount)} → ${info.merchantName}",
|
||||
onConfirmed = { dialog, frame ->
|
||||
fragment.showProcessingInDialog(dialog, frame)
|
||||
executeQrPayment(src, debitAccount, info, amount, dialog, frame)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun executeQrPayment(
|
||||
src: BankAccount,
|
||||
debitAccount: String,
|
||||
info: BmlQrPayInfo,
|
||||
amount: Double,
|
||||
dialog: AlertDialog,
|
||||
frame: android.widget.FrameLayout
|
||||
) {
|
||||
val loginId = src.loginTag.removePrefix("bml_")
|
||||
val session = sessionFor(src) ?: run {
|
||||
dialog.dismiss()
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val otp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) }
|
||||
?: run { dialog.dismiss(); Toast.makeText(ctx, "OTP unavailable", Toast.LENGTH_SHORT).show(); return }
|
||||
|
||||
binding.btnTransfer.isEnabled = false
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
if (gatewayQr) {
|
||||
val preOk = BmlQrPayClient().preInitiatePayment(
|
||||
session, debitAccount, info.requestId, amount, info.currency)
|
||||
if (!preOk) return@withContext null
|
||||
}
|
||||
val initiated = BmlQrPayClient().initiatePayment(
|
||||
session, debitAccount, info.requestId, amount, info.currency)
|
||||
if (!initiated) return@withContext null
|
||||
val confirmOtp = CredentialStore(ctx).loadBmlCredentials(loginId)
|
||||
?.otpSeed?.let { Totp.generate(it) } ?: otp
|
||||
BmlQrPayClient().confirmPayment(
|
||||
session, debitAccount, info.requestId, amount, info.currency, confirmOtp)
|
||||
} catch (e: Exception) {
|
||||
BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
|
||||
}
|
||||
}
|
||||
if (fragment.view == null) return@launch
|
||||
|
||||
if (result == null) {
|
||||
dialog.dismiss()
|
||||
binding.btnTransfer.isEnabled = true
|
||||
Toast.makeText(ctx, "Failed to initiate payment", Toast.LENGTH_LONG).show()
|
||||
return@launch
|
||||
}
|
||||
if (result.success) {
|
||||
fragment.showSuccessInDialog(
|
||||
dialog, frame,
|
||||
amountCurrency = result.currency.ifBlank { info.currency },
|
||||
amountValue = result.amount.ifBlank { "%.2f".format(amount) },
|
||||
fromName = src.accountBriefName,
|
||||
toName = result.merchant.ifBlank { info.merchantName }
|
||||
) {
|
||||
fragment.clearForm()
|
||||
host?.triggerRefresh()
|
||||
}
|
||||
} else {
|
||||
dialog.dismiss()
|
||||
binding.btnTransfer.isEnabled = true
|
||||
Toast.makeText(ctx, result.errorMessage, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Personal-profile transfer (token OTP, no user interaction) ──────────
|
||||
|
||||
/**
|
||||
* Runs initiate + confirm on the calling (IO) thread and returns
|
||||
* `(ok, message, receipt)`. Message is "CONNECTIVITY" when the network was unreachable.
|
||||
*/
|
||||
fun doTransfer(
|
||||
src: BankAccount,
|
||||
destAccount: String,
|
||||
destDisplay: String,
|
||||
amount: Double,
|
||||
amountStr: String,
|
||||
remarks: String,
|
||||
isSrcCard: Boolean,
|
||||
isDestMib: Boolean,
|
||||
currency: String,
|
||||
allAccounts: List<BankAccount>,
|
||||
allContacts: List<BankContact>
|
||||
): Triple<Boolean, String, TransferReceiptData?> {
|
||||
val loginId = src.loginTag.removePrefix("bml_")
|
||||
val sess = sessionFor(src) ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
|
||||
val otp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) }
|
||||
?: return Triple(false, "OTP unavailable", null)
|
||||
val debitAccount = src.internalId.ifBlank {
|
||||
return Triple(false, ctx.getString(R.string.transfer_missing_internal_id), null)
|
||||
}
|
||||
|
||||
val routed = routeTransfer(destAccount, isSrcCard, isDestMib, currency, allAccounts, allContacts)
|
||||
?: return Triple(false, "BML contact not found for this account", null)
|
||||
val (transferType, creditAccount, bank) = routed
|
||||
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
|
||||
|
||||
// Step 1: initiate
|
||||
val initiated = try {
|
||||
BmlTransferClient().initiateTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, bank)
|
||||
} catch (e: Exception) { return Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Initiation failed"), null) }
|
||||
|
||||
if (!initiated) return Triple(false, "Failed to initiate transfer — check your session", null)
|
||||
|
||||
// Step 2: confirm with fresh OTP
|
||||
val confirmOtp = CredentialStore(ctx).loadBmlCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) } ?: otp
|
||||
|
||||
return try {
|
||||
val result = BmlTransferClient().confirmTransfer(sess, debitAccount, creditAccount, amount, transferType, currency, confirmOtp, remarks, bank)
|
||||
if (result.success) {
|
||||
val receipt = TransferReceiptData(
|
||||
bank = "BML",
|
||||
amount = "%.2f".format(amount),
|
||||
currency = currency,
|
||||
fromLabel = src.accountBriefName,
|
||||
fromColorHex = "#0066A1",
|
||||
toLabel = destDisplay.ifBlank { destAccount },
|
||||
toAccount = destAccount,
|
||||
toBank = toBank,
|
||||
remarks = remarks,
|
||||
bmlFromName = src.accountBriefName,
|
||||
bmlReference = result.reference,
|
||||
bmlTimestamp = result.timestamp,
|
||||
bmlMessage = result.message
|
||||
)
|
||||
val time = result.timestamp.take(19).replace("T", " ")
|
||||
Triple(true, "Reference: ${result.reference}\n$time", receipt)
|
||||
} else {
|
||||
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Business profile OTP flow ───────────────────────────────────────────
|
||||
|
||||
fun startBusinessOtpFlow(
|
||||
src: BankAccount,
|
||||
destAccount: String,
|
||||
destDisplay: String,
|
||||
amount: Double,
|
||||
amountStr: String,
|
||||
remarks: String,
|
||||
isSrcCard: Boolean,
|
||||
isDestMib: Boolean,
|
||||
currency: String,
|
||||
allAccounts: List<BankAccount>,
|
||||
allContacts: List<BankContact>,
|
||||
toAvatar: Bitmap?
|
||||
) {
|
||||
val debitAccount = src.internalId.ifBlank {
|
||||
Toast.makeText(ctx, ctx.getString(R.string.transfer_missing_internal_id), Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val routed = routeTransfer(destAccount, isSrcCard, isDestMib, currency, allAccounts, allContacts)
|
||||
if (routed == null) {
|
||||
Toast.makeText(ctx, "BML contact not found for this account", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val (transferType, creditAccount, bank) = routed
|
||||
val toBank = bank ?: if (isDestMib) "MIB" else "BML"
|
||||
|
||||
pendingTransfer = PendingTransfer(
|
||||
src = src,
|
||||
debitAccount = debitAccount,
|
||||
creditAccount = creditAccount,
|
||||
amount = amount,
|
||||
amountStr = amountStr,
|
||||
remarks = remarks,
|
||||
transferType = transferType,
|
||||
currency = currency,
|
||||
bank = bank,
|
||||
destDisplay = destDisplay,
|
||||
destAccount = destAccount,
|
||||
toBank = toBank,
|
||||
toAvatar = toAvatar
|
||||
)
|
||||
|
||||
otpState = OtpState.SELECTING_CHANNEL
|
||||
binding.btnTransfer.isEnabled = false
|
||||
host?.setRefreshing(true)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val sess = sessionFor(src)
|
||||
val channels = if (sess != null) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try { BmlAccountClient().fetchTransferChannels(sess) }
|
||||
catch (_: Exception) { emptyList() }
|
||||
}
|
||||
} else emptyList<BmlOtpChannel>()
|
||||
|
||||
host?.setRefreshing(false)
|
||||
|
||||
if (channels.isEmpty()) {
|
||||
Toast.makeText(ctx, "Could not load OTP channels", Toast.LENGTH_SHORT).show()
|
||||
resetOtpState()
|
||||
onStateChanged()
|
||||
return@launch
|
||||
}
|
||||
|
||||
showChannelSelection(channels)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showChannelSelection(channels: List<BmlOtpChannel>) {
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
binding.containerBmlChannels.removeAllViews()
|
||||
|
||||
for (channel in channels) {
|
||||
val iconRes = when (channel.channel) {
|
||||
"email" -> R.drawable.ic_channel_email
|
||||
"mobile" -> R.drawable.ic_channel_sms
|
||||
else -> R.drawable.ic_channel_sms
|
||||
}
|
||||
val iconSize = (24 * dp).toInt()
|
||||
|
||||
val textCol = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f).apply {
|
||||
marginStart = (12 * dp).toInt()
|
||||
}
|
||||
}
|
||||
textCol.addView(TextView(ctx).apply {
|
||||
text = channel.description
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodyLarge)
|
||||
})
|
||||
textCol.addView(TextView(ctx).apply {
|
||||
text = channel.masked
|
||||
setTextAppearance(com.google.android.material.R.style.TextAppearance_Material3_BodySmall)
|
||||
alpha = 0.6f
|
||||
})
|
||||
|
||||
val row = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_VERTICAL
|
||||
val ta = ctx.obtainStyledAttributes(intArrayOf(android.R.attr.selectableItemBackground))
|
||||
background = ta.getDrawable(0); ta.recycle()
|
||||
isClickable = true; isFocusable = true
|
||||
val hp = (16 * dp).toInt(); val vp = (12 * dp).toInt()
|
||||
setPadding(hp, vp, hp, vp)
|
||||
}
|
||||
row.addView(ImageView(ctx).apply { setImageResource(iconRes) },
|
||||
LinearLayout.LayoutParams(iconSize, iconSize))
|
||||
row.addView(textCol)
|
||||
row.setOnClickListener { selectOtpChannel(channel) }
|
||||
binding.containerBmlChannels.addView(row)
|
||||
}
|
||||
|
||||
disableTransferFields()
|
||||
binding.layoutBmlChannelSelection.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun selectOtpChannel(channel: BmlOtpChannel) {
|
||||
otpChannel = channel.channel
|
||||
binding.layoutBmlChannelSelection.visibility = View.GONE
|
||||
|
||||
val pending = pendingTransfer ?: return
|
||||
val sess = sessionFor(pending.src) ?: run {
|
||||
Toast.makeText(ctx, ctx.getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
|
||||
resetOtpState()
|
||||
onStateChanged()
|
||||
return
|
||||
}
|
||||
|
||||
binding.btnTransfer.isEnabled = false
|
||||
host?.setRefreshing(true)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val initiated = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
BmlTransferClient().initiateTransfer(
|
||||
sess, pending.debitAccount, pending.creditAccount,
|
||||
pending.amount, pending.transferType, pending.currency,
|
||||
pending.bank, channel.channel
|
||||
)
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
host?.setRefreshing(false)
|
||||
|
||||
if (!initiated) {
|
||||
Toast.makeText(ctx, "Failed to initiate transfer — check your session", Toast.LENGTH_SHORT).show()
|
||||
resetOtpState()
|
||||
onStateChanged()
|
||||
return@launch
|
||||
}
|
||||
|
||||
otpState = OtpState.AWAITING_OTP
|
||||
binding.tvBmlOtpSentVia.text = "OTP code sent via: ${channel.description} (${channel.masked})"
|
||||
binding.tvBmlOtpSentVia.visibility = View.VISIBLE
|
||||
binding.tilBmlOtp.visibility = View.VISIBLE
|
||||
binding.etBmlOtp.requestFocus()
|
||||
binding.btnTransfer.text = ctx.getString(R.string.transfer_verify_payment)
|
||||
binding.btnTransfer.isEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
/** Send-button action while [isAwaitingOtp]: confirms the pending transfer with the typed code. */
|
||||
fun verifyOtp() {
|
||||
val otp = binding.etBmlOtp.text?.toString()?.trim() ?: ""
|
||||
if (otp.isEmpty()) {
|
||||
binding.tilBmlOtp.error = "Enter the verification code"
|
||||
return
|
||||
}
|
||||
binding.tilBmlOtp.error = null
|
||||
val pending = pendingTransfer ?: return
|
||||
val channel = otpChannel ?: return
|
||||
val sess = sessionFor(pending.src) ?: run {
|
||||
Toast.makeText(ctx, ctx.getString(R.string.transfer_session_unavailable), Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
binding.btnTransfer.isEnabled = false
|
||||
host?.setRefreshing(true)
|
||||
|
||||
val capturedToAvatar = pending.toAvatar
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val result = BmlTransferClient().confirmTransfer(
|
||||
sess, pending.debitAccount, pending.creditAccount,
|
||||
pending.amount, pending.transferType, pending.currency,
|
||||
otp, pending.remarks, pending.bank, channel
|
||||
)
|
||||
if (result.success) {
|
||||
val r = TransferReceiptData(
|
||||
bank = "BML",
|
||||
amount = "%.2f".format(pending.amount),
|
||||
currency = pending.currency,
|
||||
fromLabel = pending.src.accountBriefName,
|
||||
fromColorHex = "#0066A1",
|
||||
toLabel = pending.destDisplay.ifBlank { pending.destAccount },
|
||||
toAccount = pending.destAccount,
|
||||
toBank = pending.toBank,
|
||||
remarks = pending.remarks,
|
||||
bmlFromName = pending.src.accountBriefName,
|
||||
bmlReference = result.reference,
|
||||
bmlTimestamp = result.timestamp,
|
||||
bmlMessage = result.message
|
||||
)
|
||||
Triple(true, "", r)
|
||||
} else {
|
||||
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null as TransferReceiptData?)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null as TransferReceiptData?)
|
||||
}
|
||||
}
|
||||
host?.setRefreshing(false)
|
||||
|
||||
if (ok && receipt != null) {
|
||||
resetOtpState()
|
||||
onTransferSuccess(receipt, capturedToAvatar)
|
||||
} else {
|
||||
binding.btnTransfer.isEnabled = true
|
||||
if (msg == "CONNECTIVITY") {
|
||||
host?.showConnectivityBanner(ctx.getString(R.string.connectivity_no_internet))
|
||||
} else {
|
||||
binding.tilBmlOtp.error = msg
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Tears down the OTP UI and re-enables the form. Safe to call when no flow is running. */
|
||||
fun resetOtpState() {
|
||||
otpState = OtpState.NONE
|
||||
otpChannel = null
|
||||
pendingTransfer = null
|
||||
if (fragment.view == null) return
|
||||
binding.layoutBmlChannelSelection.visibility = View.GONE
|
||||
binding.tvBmlOtpSentVia.visibility = View.GONE
|
||||
binding.tilBmlOtp.visibility = View.GONE
|
||||
binding.etBmlOtp.setText("")
|
||||
binding.tilBmlOtp.error = null
|
||||
enableTransferFields()
|
||||
binding.btnTransfer.text = ctx.getString(R.string.transfer)
|
||||
}
|
||||
|
||||
private fun disableTransferFields() {
|
||||
binding.tilAmount.isEnabled = false
|
||||
binding.tilRemarks.isEnabled = false
|
||||
binding.cardFromInfo.alpha = 0.5f
|
||||
binding.btnClearFromInfo.isEnabled = false
|
||||
binding.cardToInfo.alpha = 0.5f
|
||||
binding.btnClearToInfo.isEnabled = false
|
||||
}
|
||||
|
||||
private fun enableTransferFields() {
|
||||
binding.tilAmount.isEnabled = true
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.cardFromInfo.alpha = 1f
|
||||
binding.btnClearFromInfo.isEnabled = true
|
||||
binding.cardToInfo.alpha = 1f
|
||||
binding.btnClearToInfo.isEnabled = true
|
||||
}
|
||||
|
||||
// ─── Shared routing ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Picks the BML transfer type and the credit-account identifier the API expects.
|
||||
* Returns null only for the USD → MIB case with no matching saved BML contact, which both
|
||||
* callers report as "BML contact not found".
|
||||
*/
|
||||
private fun routeTransfer(
|
||||
destAccount: String,
|
||||
isSrcCard: Boolean,
|
||||
isDestMib: Boolean,
|
||||
currency: String,
|
||||
allAccounts: List<BankAccount>,
|
||||
allContacts: List<BankContact>
|
||||
): Triple<String, String, String?>? {
|
||||
val isDestMyCard = allAccounts.any { isCard(it) && it.accountNumber == destAccount }
|
||||
return when {
|
||||
isSrcCard -> {
|
||||
// CAD: card → own BML account
|
||||
val destBml = allAccounts.firstOrNull { it.accountNumber == destAccount && it.profileType == "BML" }
|
||||
Triple("CAD", destBml?.internalId?.ifBlank { destAccount } ?: destAccount, null)
|
||||
}
|
||||
isDestMyCard -> {
|
||||
// CPA: BML CASA → own card top-up
|
||||
val card = allAccounts.first { isCard(it) && it.accountNumber == destAccount }
|
||||
Triple("CPA", card.internalId.ifBlank { destAccount }, null)
|
||||
}
|
||||
isDestMib && currency == "MVR" -> Triple("DOT", destAccount, "MIB")
|
||||
isDestMib -> {
|
||||
// USD DOT: requires BML contact numeric ID
|
||||
val contact = allContacts.firstOrNull { it.benefCategoryId == "BML" && it.benefAccount == destAccount }
|
||||
?: return null
|
||||
Triple("DOT", contact.benefNo.removePrefix("bml_"), null)
|
||||
}
|
||||
else -> Triple("IAT", destAccount, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCard(account: BankAccount) =
|
||||
account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.view.View
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.dhiraagu.DhiraaguClient
|
||||
import sh.sar.basedbank.api.fahipay.OoredooClient
|
||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||
import sh.sar.basedbank.ui.home.TransferFragment
|
||||
import sh.sar.basedbank.util.AccountInputParser
|
||||
|
||||
/**
|
||||
* A service a Fahipay wallet can pay out to. The carrier lookup decides which of these apply to
|
||||
* a given number; [label] names it in the recipient card, [destinationLabel] in the confirm
|
||||
* dialog's "To" block.
|
||||
*
|
||||
* Wallet-to-wallet Fahipay transfer is not here yet — there is no send path for it (see the
|
||||
* class KDoc on [FahipayTransferHandler]). Add it as a constant once that lands, and the
|
||||
* exhaustive `when`s over this enum will point at every site that needs updating.
|
||||
*/
|
||||
enum class FahipayService(val label: String, val destinationLabel: String) {
|
||||
RAASTAS("Raastas", "Ooredoo · Raastas"),
|
||||
OOREDOO_BILL("Ooredoo Bill Pay", "Ooredoo · Bill Pay"),
|
||||
DHIRAAGU_RELOAD("Dhiraagu Reload", "Dhiraagu · Reload"),
|
||||
DHIRAAGU_BILL("Dhiraagu Bill Pay", "Dhiraagu · Bill Pay"),
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the Fahipay-only parts of the Transfer screen: the carrier lookup that turns a phone
|
||||
* number into a set of payable services, the chip picker shown when more than one applies, and
|
||||
* the selected service that the confirm dialog labels the destination with.
|
||||
*
|
||||
* Mirrors [BmlTransferHandler] / [MfaisaTransferHandler]: the fragment keeps the shared confirm
|
||||
* dialog, the recipient card and the form state; the handler keeps everything Fahipay-specific.
|
||||
*
|
||||
* **There is no send path yet.** A Fahipay source currently falls through to the MIB branch of
|
||||
* `initiateTransfer`, which signs the request with a MIB session. When the real payout API is
|
||||
* wired up it belongs here, as a `doTransfer(...)` alongside the lookup — same shape as the
|
||||
* other handlers.
|
||||
*
|
||||
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment]
|
||||
* (for `viewLifecycleOwner` and Context) — and must be re-created when the view is recreated.
|
||||
*/
|
||||
class FahipayTransferHandler(
|
||||
private val fragment: TransferFragment,
|
||||
private val binding: FragmentTransferBinding,
|
||||
private val viewModel: HomeViewModel,
|
||||
) {
|
||||
|
||||
private val ctx get() = fragment.requireContext()
|
||||
|
||||
/** The service picked for the current recipient; null until a lookup resolves one. */
|
||||
var service: FahipayService? = null
|
||||
private set
|
||||
|
||||
/** How the confirm dialog names the destination, or "" when nothing is selected. */
|
||||
val destinationLabel: String get() = service?.destinationLabel.orEmpty()
|
||||
|
||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolves a destination for a Fahipay source. Only phone numbers are payable, so anything
|
||||
* else is rejected inline on the "To" field.
|
||||
*/
|
||||
fun lookupRecipient(rawInput: String) {
|
||||
if (AccountInputParser.detect(rawInput) != AccountInputParser.InputType.PHONE) {
|
||||
binding.tilTo.error = ctx.getString(R.string.transfer_fahipay_phone_only)
|
||||
return
|
||||
}
|
||||
lookupCarrier(rawInput)
|
||||
}
|
||||
|
||||
/** Clears the selected service and hides the chip picker. */
|
||||
fun clearState() {
|
||||
service = null
|
||||
binding.layoutServiceSelector.visibility = View.INVISIBLE
|
||||
}
|
||||
|
||||
// ─── Carrier lookup ──────────────────────────────────────────────────────
|
||||
|
||||
private data class CarrierResult(
|
||||
val dhiraagu: DhiraaguClient.Result,
|
||||
val ooredoo: OoredooClient.CustType
|
||||
)
|
||||
|
||||
private fun lookupCarrier(number: String) {
|
||||
fragment.startLookupLoading()
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) { queryCarriers(number) }
|
||||
fragment.stopLookupLoading()
|
||||
|
||||
val dhiraaguName = result.dhiraagu.ownerName.takeIf { it.isNotBlank() }
|
||||
val services = servicesFor(result)
|
||||
|
||||
if (services.isEmpty()) return@launch
|
||||
|
||||
// Only one option — auto-select, no chip UI needed
|
||||
if (services.size == 1) {
|
||||
selectService(services[0], number, dhiraaguName)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Multiple options (Ooredoo HYBRID) — show chips
|
||||
showServiceChips(services, number, dhiraaguName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the likelier carrier first based on the leading digit and only falls back to the
|
||||
* other when the first says it doesn't know the number. Blocking — call from IO.
|
||||
*/
|
||||
private fun queryCarriers(number: String): CarrierResult =
|
||||
if (number.startsWith("7")) {
|
||||
// Dhiraagu first, fall back to Ooredoo
|
||||
val d = dhiraagu(number)
|
||||
val o = if (d.type == DhiraaguClient.CustType.UNSUPPORTED) ooredoo(number)
|
||||
else OoredooClient.CustType.UNSUPPORTED
|
||||
CarrierResult(d, o)
|
||||
} else {
|
||||
// Ooredoo first, fall back to Dhiraagu
|
||||
val o = ooredoo(number)
|
||||
val d = if (o == OoredooClient.CustType.UNSUPPORTED) dhiraagu(number)
|
||||
else DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED)
|
||||
CarrierResult(d, o)
|
||||
}
|
||||
|
||||
private fun dhiraagu(number: String) =
|
||||
try { DhiraaguClient().validateNumber(number) }
|
||||
catch (_: Exception) { DhiraaguClient.Result(DhiraaguClient.CustType.UNSUPPORTED) }
|
||||
|
||||
private fun ooredoo(number: String) =
|
||||
try { OoredooClient().validateNumber(number) }
|
||||
catch (_: Exception) { OoredooClient.CustType.UNSUPPORTED }
|
||||
|
||||
private fun servicesFor(result: CarrierResult): List<FahipayService> = buildList {
|
||||
if (result.dhiraagu.type == DhiraaguClient.CustType.RELOAD) add(FahipayService.DHIRAAGU_RELOAD)
|
||||
if (result.dhiraagu.type == DhiraaguClient.CustType.BILL_PAY) add(FahipayService.DHIRAAGU_BILL)
|
||||
if (result.ooredoo == OoredooClient.CustType.PRE || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.RAASTAS)
|
||||
if (result.ooredoo == OoredooClient.CustType.POST || result.ooredoo == OoredooClient.CustType.HYBRID) add(FahipayService.OOREDOO_BILL)
|
||||
}
|
||||
|
||||
// ─── Service picker ──────────────────────────────────────────────────────
|
||||
|
||||
private fun showServiceChips(
|
||||
services: List<FahipayService>,
|
||||
number: String,
|
||||
dhiraaguName: String?
|
||||
) {
|
||||
binding.chipDhiraaguReload.visibility = visibilityFor(FahipayService.DHIRAAGU_RELOAD in services)
|
||||
binding.chipDhiraaguBill.visibility = visibilityFor(FahipayService.DHIRAAGU_BILL in services)
|
||||
binding.chipRaastas.visibility = visibilityFor(FahipayService.RAASTAS in services)
|
||||
binding.chipOoredooBill.visibility = visibilityFor(FahipayService.OOREDOO_BILL in services)
|
||||
binding.layoutServiceSelector.visibility = View.VISIBLE
|
||||
binding.chipGroupService.clearCheck()
|
||||
|
||||
// Dhiraagu is the only carrier that hands back an owner name, so the Ooredoo chips
|
||||
// resolve their display name from saved contacts instead.
|
||||
bindChip(binding.chipDhiraaguReload, FahipayService.DHIRAAGU_RELOAD, number, dhiraaguName)
|
||||
bindChip(binding.chipDhiraaguBill, FahipayService.DHIRAAGU_BILL, number, dhiraaguName)
|
||||
bindChip(binding.chipRaastas, FahipayService.RAASTAS, number, null)
|
||||
bindChip(binding.chipOoredooBill, FahipayService.OOREDOO_BILL, number, null)
|
||||
}
|
||||
|
||||
private fun bindChip(
|
||||
chip: com.google.android.material.chip.Chip,
|
||||
picked: FahipayService,
|
||||
number: String,
|
||||
ownerName: String?
|
||||
) {
|
||||
chip.setOnCheckedChangeListener { _, checked ->
|
||||
if (checked) {
|
||||
selectService(picked, number, ownerName)
|
||||
binding.layoutServiceSelector.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun visibilityFor(shown: Boolean) = if (shown) View.VISIBLE else View.GONE
|
||||
|
||||
private fun selectService(picked: FahipayService, number: String, ownerName: String?) {
|
||||
service = picked
|
||||
val contacts = viewModel.contacts.value ?: emptyList()
|
||||
val displayName = ownerName
|
||||
?: contacts.firstOrNull { it.benefAccount == number }?.benefNickName
|
||||
?: number
|
||||
fragment.prefillToDirectly(
|
||||
accountNumber = number,
|
||||
displayName = displayName,
|
||||
subtitle = "${picked.label} · $number",
|
||||
colorHex = "#FF6B00",
|
||||
imageHash = null
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,653 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.biometric.BiometricManager
|
||||
import androidx.biometric.BiometricPrompt
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaInvalidOtpException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaQrPayClient
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaRecipientNotFoundException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaTransferClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.databinding.FragmentTransferBinding
|
||||
import sh.sar.basedbank.ui.home.HomeActivity
|
||||
import sh.sar.basedbank.ui.home.HomeViewModel
|
||||
import sh.sar.basedbank.ui.home.TransferFragment
|
||||
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||
import sh.sar.basedbank.util.AccountInputParser
|
||||
import sh.sar.basedbank.util.RecentPick
|
||||
import sh.sar.basedbank.util.RecentsCache
|
||||
|
||||
/**
|
||||
* Owns the M-Faisa-only parts of the Transfer screen: phone-based recipient lookup,
|
||||
* initiate-with-OTP and confirm-with-OTP for wallet transfers, plus merchant QR pay
|
||||
* (qrCodeId lookup + initiate/confirm purchase). Lives alongside [TransferFragment] which
|
||||
* dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source, or whenever
|
||||
* an M-Faisa merchant QR is loaded.
|
||||
*
|
||||
* Mirrors [BmlTransferHandler]: the fragment keeps the shared confirm dialog, the recipient card
|
||||
* and the form state; the handler keeps everything M-Faisa-specific.
|
||||
*
|
||||
* Lifetime is bound to the fragment's view: it captures [binding] + [viewModel] + [fragment] (for
|
||||
* [Fragment.viewLifecycleOwner] and Context) — and must be re-created when the view is recreated.
|
||||
*/
|
||||
class MfaisaTransferHandler(
|
||||
private val fragment: TransferFragment,
|
||||
private val binding: FragmentTransferBinding,
|
||||
private val viewModel: HomeViewModel,
|
||||
/** Reads the fragment's currently-selected source account. */
|
||||
private val currentSource: () -> BankAccount?,
|
||||
/** Asks the fragment to make [BankAccount] the source (amount prefix + from-card + Send state). */
|
||||
private val selectSource: (BankAccount) -> Unit,
|
||||
/** Hook called when M-Faisa successfully resolves or clears a recipient — fragment uses this to update Send-button state. */
|
||||
private val onRecipientChanged: () -> Unit,
|
||||
/** Hook called on a successful transfer; fragment navigates to the receipt and refreshes account balances. */
|
||||
private val onTransferSuccess: (TransferReceiptData, Bitmap?) -> Unit,
|
||||
) {
|
||||
|
||||
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||
private val ctx get() = fragment.requireContext()
|
||||
private val host get() = fragment.activity as? HomeActivity
|
||||
|
||||
/** Set to the resolved recipient after a successful search; null otherwise. */
|
||||
var recipient: MfaisaTransferClient.Recipient? = null
|
||||
private set
|
||||
|
||||
/** Merchant QR payment mode (set when the scanned QR is an M-Faisa qrCodeId). */
|
||||
var qrInfo: MfaisaQrPayClient.QrMerchant? = null
|
||||
private set
|
||||
|
||||
private var lookupInFlight = false
|
||||
|
||||
// ─── Public API the fragment calls ───────────────────────────────────────
|
||||
|
||||
/** Whether the recipient lookup has resolved — gates the Send button. */
|
||||
fun isRecipientReady(): Boolean = recipient?.isMvr == true
|
||||
|
||||
/** Triggered when the user taps the search end-icon in `tilTo` (and source bank is MFAISA). */
|
||||
fun searchRecipient(rawInput: String) {
|
||||
if (lookupInFlight) return
|
||||
// Reuse the shared normalizer so "+960", "960", and embedded spaces work the same as
|
||||
// they do for MIB/BML lookup. The result is a bare 7-digit MSISDN when input was a
|
||||
// local phone number, untouched otherwise.
|
||||
val phone = AccountInputParser.normalize(rawInput)
|
||||
if (AccountInputParser.detect(phone) != AccountInputParser.InputType.PHONE) {
|
||||
binding.tilTo.error = "Enter a valid mobile number"
|
||||
return
|
||||
}
|
||||
binding.tilTo.error = null
|
||||
|
||||
val source = mfaisaSource() ?: return
|
||||
val session = app.mfaisaSessionFor(source) ?: run {
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
lookupInFlight = true
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
MfaisaTransferClient.forContext(ctx).searchRecipient(session, phone)
|
||||
} catch (_: MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||
MfaisaTransferClient.forContext(ctx).searchRecipient(fresh, phone)
|
||||
}
|
||||
}
|
||||
if (!result.isMvr) {
|
||||
// Server returned the user but only with a PayPal pocket — not supported.
|
||||
binding.tilTo.error = "This number doesn't have an MVR M-Faisa pocket"
|
||||
} else {
|
||||
recipient = result
|
||||
showResolvedRecipient(result)
|
||||
}
|
||||
} catch (_: MfaisaRecipientNotFoundException) {
|
||||
binding.tilTo.error = "No M-Faisa wallet found for this number"
|
||||
} catch (e: java.io.IOException) {
|
||||
Toast.makeText(ctx, R.string.connectivity_no_internet, Toast.LENGTH_SHORT).show()
|
||||
} catch (e: Exception) {
|
||||
binding.tilTo.error = e.message ?: "Lookup failed"
|
||||
} finally {
|
||||
lookupInFlight = false
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||
onRecipientChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Triggered when the user taps the Send button (and source bank is MFAISA). */
|
||||
fun submit() {
|
||||
val source = mfaisaSource() ?: return
|
||||
val r = recipient ?: run {
|
||||
Toast.makeText(ctx, "Search for a recipient first", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val amountStr = binding.etAmount.text?.toString()?.trim().orEmpty()
|
||||
val amount = amountStr.toDoubleOrNull()
|
||||
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
|
||||
binding.tilAmount.error = null
|
||||
val remarks = binding.etRemarks.text?.toString()?.trim().orEmpty()
|
||||
|
||||
val sourcePocketId = source.accountNumber // pocketId IS the accountNumber for M-Faisa accounts
|
||||
|
||||
binding.btnTransfer.isEnabled = false
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val refId = try {
|
||||
withContext(Dispatchers.IO) { initiateWithRetry(source, sourcePocketId, r, amountStr, remarks) }
|
||||
} catch (e: Exception) {
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||
binding.btnTransfer.isEnabled = true
|
||||
showError(e)
|
||||
return@launch
|
||||
}
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||
// Server has now SMSed an OTP to the user's phone. Prompt them for it.
|
||||
promptForOtp(source, r, amountStr, remarks, refId, errorMsg = null)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a merchant QR is loaded — counts as a resolved recipient for the Send button. */
|
||||
val hasQrMerchant: Boolean get() = qrInfo != null
|
||||
|
||||
/** Called when the source account changes away from M-Faisa (or the view tears down). */
|
||||
fun clearState() {
|
||||
recipient = null
|
||||
lookupInFlight = false
|
||||
}
|
||||
|
||||
/** Drops the loaded merchant and unlocks the amount/remarks fields the QR mode had frozen. */
|
||||
fun clearQrMerchant() {
|
||||
if (qrInfo == null) return
|
||||
qrInfo = null
|
||||
binding.tilAmount.isEnabled = true
|
||||
binding.tilRemarks.isEnabled = true
|
||||
binding.tilRemarks.alpha = 1f
|
||||
binding.etAmount.setText("")
|
||||
}
|
||||
|
||||
// ─── Merchant QR ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolves an M-Faisa qrCodeId to a merchant, paints it in the "To" card, auto-selects an
|
||||
* M-Faisa source if none is selected (or the current one is the wrong bank), and pre-fills
|
||||
* the amount if the QR is dynamic.
|
||||
*/
|
||||
fun lookupQrMerchant(qrCodeId: String) {
|
||||
val source = mfaisaSource() ?: run {
|
||||
Toast.makeText(ctx, "No M-Faisa account available", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val session = app.mfaisaSessionFor(source) ?: run {
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-switch from a non-MFAISA source so the user doesn't have to fix it manually
|
||||
if (currentSource()?.bank != "MFAISA") selectSource(source)
|
||||
|
||||
// Lock the "To" input row while loading
|
||||
binding.tilTo.visibility = View.GONE
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
host?.setRefreshing(true)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val merchant = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
MfaisaQrPayClient().fetchQrDetails(session, qrCodeId)
|
||||
} catch (_: MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||
?: return@withContext null
|
||||
try { MfaisaQrPayClient().fetchQrDetails(fresh, qrCodeId) }
|
||||
catch (_: Exception) { null }
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
host?.setRefreshing(false)
|
||||
if (merchant == null) {
|
||||
Toast.makeText(ctx, "Could not look up M-Faisa QR", Toast.LENGTH_LONG).show()
|
||||
fragment.resetToFieldVisibility()
|
||||
return@launch
|
||||
}
|
||||
qrInfo = merchant
|
||||
|
||||
// Static QRs (no preset amount) make sense to keep in Recents — the merchant is
|
||||
// reusable. Dynamic QRs are one-off so we skip them, same rule as BML QR pay.
|
||||
if (merchant.txnAmount.isNullOrBlank()) {
|
||||
RecentsCache.save(ctx, RecentPick(
|
||||
accountNumber = "mfaisaqr:${merchant.qrCodeId}",
|
||||
displayName = merchant.merchantName,
|
||||
subtitle = "M-Faisa merchant · ${merchant.merchantMsisdn}",
|
||||
colorHex = "#ED1C24",
|
||||
imageHash = null,
|
||||
isProfileImage = false,
|
||||
bank = "MFAISA"
|
||||
))
|
||||
}
|
||||
|
||||
// Show merchant in the "To" card — clear button is the only way to back out
|
||||
binding.tvToAccountName.text = merchant.merchantName
|
||||
binding.tvToBankBic.text = "M-Faisa merchant · ${merchant.merchantMsisdn}"
|
||||
binding.tvToAccountDetails.visibility = View.GONE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
|
||||
binding.cardToInfo.visibility = View.VISIBLE
|
||||
|
||||
// Pre-fill + lock amount if the QR is dynamic
|
||||
val dynamicAmount = merchant.txnAmount?.toDoubleOrNull()
|
||||
if (dynamicAmount != null && dynamicAmount > 0.0) {
|
||||
binding.etAmount.setText("%.2f".format(dynamicAmount))
|
||||
binding.tilAmount.isEnabled = false
|
||||
}
|
||||
|
||||
onRecipientChanged()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm-then-pay for a loaded merchant QR. Uses the fragment's shared confirm dialog —
|
||||
* the /initiateNewBuy + /confirmNewBuy pair does NOT require OTP for wallet QR pay
|
||||
* (2FARequired=NONE), so unlike [submit] there is no code to enter.
|
||||
*/
|
||||
fun submitQrPayment() {
|
||||
val merchant = qrInfo ?: return
|
||||
val src = currentSource() ?: run {
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
if (src.bank != "MFAISA") {
|
||||
Toast.makeText(ctx, "Switch to an M-Faisa account to pay this QR", Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val amountStr = binding.etAmount.text?.toString()?.trim() ?: ""
|
||||
val amount = amountStr.toDoubleOrNull()
|
||||
if (amount == null || amount <= 0) { binding.tilAmount.error = "Enter a valid amount"; return }
|
||||
binding.tilAmount.error = null
|
||||
val remarks = binding.etRemarks.text?.toString()?.trim().orEmpty()
|
||||
val amountValue = "%.2f".format(amount)
|
||||
|
||||
val confirmView = fragment.buildTransferConfirmView(
|
||||
amountCurrency = merchant.currencyCode,
|
||||
amountValue = amountValue,
|
||||
fromName = src.accountBriefName,
|
||||
fromNumber = src.accountNumber,
|
||||
fromDetail = "M-Faisa",
|
||||
toName = merchant.merchantName,
|
||||
toNumber = merchant.merchantMsisdn,
|
||||
toDetail = "Ooredoo M-Faisa merchant"
|
||||
)
|
||||
fragment.showConfirmWithBiometric(
|
||||
title = ctx.getString(R.string.transfer),
|
||||
customView = confirmView,
|
||||
biometricSubtitle = "${merchant.currencyCode} $amountValue → ${merchant.merchantName}",
|
||||
onConfirmed = { dialog, frame ->
|
||||
fragment.showProcessingInDialog(dialog, frame)
|
||||
executeQrPayment(src, merchant, amountValue, remarks, dialog)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun executeQrPayment(
|
||||
src: BankAccount,
|
||||
merchant: MfaisaQrPayClient.QrMerchant,
|
||||
amountStr: String,
|
||||
remarks: String,
|
||||
dialog: AlertDialog
|
||||
) {
|
||||
val loginId = src.loginTag.removePrefix("mfaisa_")
|
||||
val initialSession = app.mfaisaSessionFor(src) ?: run {
|
||||
dialog.dismiss()
|
||||
Toast.makeText(ctx, R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
// M-Faisa expects the user's MSISDN with the "960" country prefix (the session stores the
|
||||
// bare 7-digit form). The pocket itself is identified by [BankAccount.accountNumber].
|
||||
val sourceMdn = "960${initialSession.msisdn}"
|
||||
|
||||
binding.btnTransfer.isEnabled = false
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
val outcome = withContext(Dispatchers.IO) {
|
||||
val client = MfaisaQrPayClient()
|
||||
try {
|
||||
val refId = try {
|
||||
client.initiatePurchase(initialSession, src.accountNumber, sourceMdn, merchant, amountStr, remarks)
|
||||
} catch (_: MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(loginId)
|
||||
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||
client.initiatePurchase(fresh, src.accountNumber, "960${fresh.msisdn}", merchant, amountStr, remarks)
|
||||
}
|
||||
val confirmSession = app.mfaisaSessionFor(src) ?: initialSession
|
||||
try {
|
||||
client.confirmPurchase(confirmSession, refId)
|
||||
} catch (_: MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(loginId)
|
||||
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||
client.confirmPurchase(fresh, refId)
|
||||
}
|
||||
Result.success(refId)
|
||||
} catch (e: Exception) {
|
||||
Result.failure<String>(e)
|
||||
}
|
||||
}
|
||||
if (fragment.view == null) return@launch
|
||||
|
||||
outcome.fold(
|
||||
onSuccess = { _ ->
|
||||
val receipt = TransferReceiptData(
|
||||
bank = "MFAISA",
|
||||
amount = amountStr,
|
||||
currency = merchant.currencyCode,
|
||||
fromLabel = src.accountBriefName,
|
||||
fromColorHex = "#ED1C24",
|
||||
toLabel = merchant.merchantName,
|
||||
toAccount = merchant.merchantMsisdn,
|
||||
toBank = "Ooredoo M-Faisa",
|
||||
remarks = remarks,
|
||||
mfaisaTransactionType = "Merchant payment",
|
||||
mfaisaFromName = src.accountBriefName,
|
||||
mfaisaFromMsisdn = src.accountNumber,
|
||||
mfaisaToMsisdn = merchant.merchantMsisdn,
|
||||
mfaisaTimestamp = System.currentTimeMillis()
|
||||
)
|
||||
dialog.dismiss()
|
||||
onTransferSuccess(receipt, null)
|
||||
},
|
||||
onFailure = { e ->
|
||||
dialog.dismiss()
|
||||
binding.btnTransfer.isEnabled = true
|
||||
showError(e, fallback = "Payment failed")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The M-Faisa account to act on: the selected source when it is one, otherwise the first
|
||||
* M-Faisa account available. The fallback is what lets a scanned merchant QR auto-switch
|
||||
* the source away from another bank.
|
||||
*/
|
||||
private fun mfaisaSource(): BankAccount? =
|
||||
currentSource()?.takeIf { it.bank == "MFAISA" }
|
||||
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" }
|
||||
|
||||
private fun showResolvedRecipient(r: MfaisaTransferClient.Recipient) {
|
||||
// Reuse the same recipient card the fragment uses for other banks. The fragment owns the
|
||||
// card view, so we just populate its text fields and toggle visibility.
|
||||
binding.tvToAccountName.text = r.name.ifBlank { r.msisdn }
|
||||
binding.tvToBankBic.text = r.msisdn
|
||||
binding.tvToAccountDetails.text = "Ooredoo M-Faisa · MVR"
|
||||
binding.tvToAccountDetails.visibility = View.VISIBLE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.setImageResource(R.drawable.ooredoo_logo)
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.FIT_CENTER
|
||||
|
||||
binding.tilTo.visibility = View.GONE
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
binding.cardToInfo.visibility = View.VISIBLE
|
||||
|
||||
RecentsCache.save(ctx, RecentPick(
|
||||
accountNumber = r.msisdn,
|
||||
displayName = r.name.ifBlank { r.msisdn },
|
||||
subtitle = "Ooredoo M-Faisa · ${r.msisdn}",
|
||||
colorHex = "#ED1C24",
|
||||
imageHash = null,
|
||||
isProfileImage = false,
|
||||
bank = "MFAISA"
|
||||
))
|
||||
}
|
||||
|
||||
/** Initiate with one automatic retry if the session has expired. */
|
||||
private fun initiateWithRetry(
|
||||
source: BankAccount,
|
||||
sourcePocketId: String,
|
||||
r: MfaisaTransferClient.Recipient,
|
||||
amountStr: String,
|
||||
remarks: String
|
||||
): String {
|
||||
val session = app.mfaisaSessionFor(source) ?: throw IllegalStateException("No M-Faisa session")
|
||||
return try {
|
||||
MfaisaTransferClient.forContext(ctx).initiateTransfer(session, sourcePocketId, r, amountStr, remarks)
|
||||
} catch (_: MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||
MfaisaTransferClient.forContext(ctx).initiateTransfer(fresh, sourcePocketId, r, amountStr, remarks)
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmWithRetry(source: BankAccount, refId: String, otpCode: String) {
|
||||
val session = app.mfaisaSessionFor(source) ?: throw IllegalStateException("No M-Faisa session")
|
||||
try {
|
||||
MfaisaTransferClient.forContext(ctx).confirmTransfer(session, refId, otpCode)
|
||||
} catch (_: MfaisaSessionExpiredException) {
|
||||
val fresh = app.refreshMfaisaSession(source.loginTag.removePrefix("mfaisa_"))
|
||||
?: throw IllegalStateException("Could not refresh M-Faisa session")
|
||||
MfaisaTransferClient.forContext(ctx).confirmTransfer(fresh, refId, otpCode)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the unified "confirm + enter OTP" dialog. Body is the standard transfer-confirm
|
||||
* view (amount + from/to blocks via [TransferFragment.buildTransferConfirmView]) plus an
|
||||
* OTP input. The Confirm button stays disabled until a 6-digit code is entered. Biometric
|
||||
* gating + invalid-OTP re-prompt + session-refresh retry are all preserved.
|
||||
*
|
||||
* The displayed "code sent to" line uses the SOURCE M-Faisa login's MSISDN (where the SMS
|
||||
* was actually sent) — the old standalone OTP dialog mistakenly showed the recipient.
|
||||
*/
|
||||
private fun promptForOtp(
|
||||
source: BankAccount,
|
||||
r: MfaisaTransferClient.Recipient,
|
||||
amountStr: String,
|
||||
remarks: String,
|
||||
refId: String,
|
||||
errorMsg: String?
|
||||
) {
|
||||
val view = fragment.view ?: return
|
||||
val dp = ctx.resources.displayMetrics.density
|
||||
val colorMuted = MaterialColors.getColor(
|
||||
view, com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
|
||||
val colorOutline = MaterialColors.getColor(
|
||||
view, com.google.android.material.R.attr.colorOutlineVariant, Color.LTGRAY)
|
||||
|
||||
val amountValue = try { "%.2f".format(amountStr.toDouble()) } catch (_: Exception) { amountStr }
|
||||
val confirmView = fragment.buildTransferConfirmView(
|
||||
amountCurrency = "MVR",
|
||||
amountValue = amountValue,
|
||||
fromName = source.accountBriefName,
|
||||
fromNumber = source.accountNumber,
|
||||
fromDetail = "M-Faisa",
|
||||
toName = r.name.ifBlank { r.msisdn },
|
||||
toNumber = r.msisdn,
|
||||
toDetail = "Ooredoo M-Faisa"
|
||||
)
|
||||
|
||||
// The user's own M-Faisa MSISDN (where the SMS is sent). The session stores the bare
|
||||
// 7 digits; prefix with 960 for display.
|
||||
val userMsisdn = app.mfaisaSessionFor(source)?.msisdn
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { "960$it" }
|
||||
?: "your registered number"
|
||||
|
||||
val otpHeader = TextView(ctx).apply {
|
||||
text = "A 6-digit verification code has been sent to $userMsisdn"
|
||||
textSize = 13f
|
||||
setTextColor(colorMuted)
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
val otpInput = android.widget.EditText(ctx).apply {
|
||||
hint = "Enter 6-digit code"
|
||||
inputType = android.text.InputType.TYPE_CLASS_NUMBER
|
||||
filters = arrayOf(android.text.InputFilter.LengthFilter(6))
|
||||
textSize = 20f
|
||||
gravity = Gravity.CENTER
|
||||
letterSpacing = 0.3f
|
||||
}
|
||||
val errorView = errorMsg?.let {
|
||||
TextView(ctx).apply {
|
||||
text = it
|
||||
textSize = 13f
|
||||
setTextColor(Color.RED)
|
||||
gravity = Gravity.CENTER
|
||||
}
|
||||
}
|
||||
val divider = View(ctx).apply {
|
||||
setBackgroundColor(colorOutline)
|
||||
layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, (1 * dp).toInt()).apply {
|
||||
topMargin = (8 * dp).toInt()
|
||||
}
|
||||
}
|
||||
val otpSection = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding((20 * dp).toInt(), (12 * dp).toInt(), (20 * dp).toInt(), (4 * dp).toInt())
|
||||
addView(otpHeader, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT))
|
||||
addView(otpInput, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
topMargin = (8 * dp).toInt()
|
||||
})
|
||||
if (errorView != null) {
|
||||
addView(errorView, LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
|
||||
topMargin = (8 * dp).toInt()
|
||||
})
|
||||
}
|
||||
}
|
||||
val container = LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
addView(confirmView)
|
||||
addView(divider)
|
||||
addView(otpSection)
|
||||
}
|
||||
|
||||
// Hide any previously-open keyboard so the OTP field can claim focus cleanly.
|
||||
val imm = ctx.getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(R.string.transfer)
|
||||
.setView(container)
|
||||
.setPositiveButton(R.string.transfer_confirm, null)
|
||||
.setNegativeButton(R.string.cancel) { d, _ ->
|
||||
d.dismiss()
|
||||
binding.btnTransfer.isEnabled = true
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
|
||||
val confirmBtn = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
|
||||
confirmBtn.isEnabled = false
|
||||
otpInput.addTextChangedListener { text ->
|
||||
confirmBtn.isEnabled = (text?.length ?: 0) == 6
|
||||
}
|
||||
|
||||
val prefs = ctx.getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false)
|
||||
val canAuth = BiometricManager.from(ctx)
|
||||
.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
|
||||
|
||||
val runConfirm: () -> Unit = {
|
||||
val otp = otpInput.text?.toString()?.trim().orEmpty()
|
||||
dialog.dismiss()
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(true)
|
||||
|
||||
fragment.viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { confirmWithRetry(source, refId, otp) }
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||
val receipt = TransferReceiptData(
|
||||
bank = "MFAISA",
|
||||
amount = amountValue,
|
||||
currency = "MVR",
|
||||
fromLabel = source.accountBriefName,
|
||||
fromColorHex = "#ED1C24",
|
||||
toLabel = r.name.ifBlank { r.msisdn },
|
||||
toAccount = r.msisdn,
|
||||
toBank = "Ooredoo M-Faisa",
|
||||
remarks = remarks,
|
||||
mfaisaTransactionType = "Transfer to mobile",
|
||||
mfaisaFromName = source.accountBriefName,
|
||||
mfaisaFromMsisdn = source.accountNumber,
|
||||
mfaisaToMsisdn = r.msisdn,
|
||||
mfaisaTimestamp = System.currentTimeMillis()
|
||||
)
|
||||
onTransferSuccess(receipt, null)
|
||||
} catch (e: MfaisaInvalidOtpException) {
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||
// Server kept the referenceId alive — re-prompt without restarting initiate.
|
||||
promptForOtp(source, r, amountStr, remarks, refId, e.message)
|
||||
} catch (e: Exception) {
|
||||
(fragment.activity as? HomeActivity)?.setRefreshing(false)
|
||||
binding.btnTransfer.isEnabled = true
|
||||
showError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
confirmBtn.setOnClickListener {
|
||||
val otp = otpInput.text?.toString()?.trim().orEmpty()
|
||||
if (otp.length != 6) { otpInput.error = "Enter 6 digits"; return@setOnClickListener }
|
||||
if (biometricTransferConfirm && canAuth) {
|
||||
val prompt = BiometricPrompt(fragment, ContextCompat.getMainExecutor(ctx),
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
runConfirm()
|
||||
}
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
if (errorCode != BiometricPrompt.ERROR_CANCELED &&
|
||||
errorCode != BiometricPrompt.ERROR_USER_CANCELED &&
|
||||
errorCode != BiometricPrompt.ERROR_NEGATIVE_BUTTON) {
|
||||
Toast.makeText(ctx, errString, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
override fun onAuthenticationFailed() { /* keep dialog open */ }
|
||||
})
|
||||
prompt.authenticate(
|
||||
BiometricPrompt.PromptInfo.Builder()
|
||||
.setTitle(ctx.getString(R.string.biometric_transfer_title))
|
||||
.setSubtitle("MVR $amountValue → ${r.name.ifBlank { r.msisdn }}")
|
||||
.setNegativeButtonText(ctx.getString(android.R.string.cancel))
|
||||
.build()
|
||||
)
|
||||
} else {
|
||||
runConfirm()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showError(e: Throwable, fallback: String = "Transfer failed") {
|
||||
val msg = when {
|
||||
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
|
||||
!e.message.isNullOrBlank() -> e.message!!
|
||||
else -> fallback
|
||||
}
|
||||
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package sh.sar.basedbank.ui.home.transfer
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.fragment.app.Fragment
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.api.mib.MibContactsClient
|
||||
import sh.sar.basedbank.api.mib.MibIpsAccountInfo
|
||||
import sh.sar.basedbank.api.mib.MibLookupException
|
||||
import sh.sar.basedbank.api.mib.MibSession
|
||||
import sh.sar.basedbank.api.mib.MibTransferClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.ui.home.TransferReceiptData
|
||||
import sh.sar.basedbank.util.AccountInputParser
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.util.Totp
|
||||
|
||||
/**
|
||||
* Owns the MIB-only parts of the Transfer screen: session resolution, IPS destination lookup,
|
||||
* the transfer itself (profile switch + TOTP + `MibTransferClient`), and the profile/contact
|
||||
* avatar API that every bank's "To" card borrows.
|
||||
*
|
||||
* Unlike [BmlTransferHandler] and [MfaisaTransferHandler] this one holds no view binding and
|
||||
* paints nothing — every method is either a session accessor or a blocking API call meant for
|
||||
* `Dispatchers.IO`. [sh.sar.basedbank.ui.home.TransferFragment] keeps the coroutine plumbing and
|
||||
* the view updates, so this handler can outlive a view recreation.
|
||||
*/
|
||||
class MibTransferHandler(
|
||||
private val fragment: Fragment,
|
||||
/** Reads the fragment's currently-selected source account. */
|
||||
private val currentSource: () -> BankAccount?,
|
||||
) {
|
||||
|
||||
private val app get() = fragment.requireActivity().application as BasedBankApp
|
||||
private val ctx get() = fragment.requireContext()
|
||||
|
||||
/** The selected source's MIB session, falling back to any logged-in MIB session. */
|
||||
val session: MibSession?
|
||||
get() = currentSource()?.let { app.mibSessionFor(it) } ?: app.anyMibSession()
|
||||
|
||||
/** Any logged-in MIB session, ignoring which source is selected. */
|
||||
val anySession: MibSession? get() = app.anyMibSession()
|
||||
|
||||
// ─── Destination lookup ──────────────────────────────────────────────────
|
||||
|
||||
/** Either a resolved destination or the message to show the user — never both. */
|
||||
data class LookupOutcome(val info: MibIpsAccountInfo?, val error: String?)
|
||||
|
||||
/**
|
||||
* IPS lookup for a destination account/alias. Blocking — call from IO.
|
||||
* A missing MIB session is reported as "account not found", same as a failed lookup.
|
||||
*/
|
||||
fun lookupDestination(accountNumber: String): LookupOutcome {
|
||||
val sess = session ?: return LookupOutcome(null, notFound())
|
||||
return try {
|
||||
LookupOutcome(MibTransferClient().lookup(sess, accountNumber), null)
|
||||
} catch (e: MibLookupException) {
|
||||
LookupOutcome(null, e.message)
|
||||
} catch (_: Exception) {
|
||||
LookupOutcome(null, notFound())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Currency-only lookup, used to enrich a BML "verify MIB account" result — that endpoint
|
||||
* resolves the name but not the currency. Blocking; returns "" when it can't be determined.
|
||||
*/
|
||||
fun lookupCurrency(accountNumber: String): String {
|
||||
val sess = session ?: return ""
|
||||
return try { MibTransferClient().lookup(sess, accountNumber).currency }
|
||||
catch (_: Exception) { "" }
|
||||
}
|
||||
|
||||
private fun notFound() = ctx.getString(R.string.transfer_account_not_found)
|
||||
|
||||
// ─── Avatars ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetches and decodes a profile image (P41, [isProfile] true) or a saved contact's image.
|
||||
* Blocking — call from IO. Returns null on any failure, including a missing image.
|
||||
*
|
||||
* The session is passed in rather than read from [session] because callers differ: the "To"
|
||||
* card uses the selected source's session, the account dropdown uses [anySession].
|
||||
*/
|
||||
fun fetchAvatar(session: MibSession, hash: String, isProfile: Boolean): Bitmap? = try {
|
||||
val base64 = if (isProfile) {
|
||||
app.anyMibFlow()?.fetchProfileImage(session, hash)
|
||||
} else {
|
||||
MibContactsClient().fetchProfileImageBase64(session, hash)
|
||||
}
|
||||
if (base64 == null) null else {
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
// ─── Transfer ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Switches to the profile that owns [src], then runs the transfer with a freshly generated
|
||||
* TOTP. Blocking — call from IO. Returns `(ok, message, receipt)`; the message is
|
||||
* "CONNECTIVITY" when the network was unreachable.
|
||||
*/
|
||||
fun doTransfer(
|
||||
src: BankAccount,
|
||||
destAccount: String,
|
||||
destName: String,
|
||||
destDisplay: String,
|
||||
amount: String,
|
||||
remarks: String,
|
||||
bankName: String
|
||||
): Triple<Boolean, String, TransferReceiptData?> {
|
||||
val sess = session ?: return Triple(false, ctx.getString(R.string.transfer_session_unavailable), null)
|
||||
val loginId = src.loginTag.removePrefix("mib_")
|
||||
val otp = CredentialStore(ctx).loadMibCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) }
|
||||
?: return Triple(false, "OTP unavailable", null)
|
||||
val currencyCode = if (src.currencyName == "USD") "840" else "462"
|
||||
val currency = if (src.currencyName == "USD") "USD" else "MVR"
|
||||
val isDestMib = AccountInputParser.detect(destAccount) == AccountInputParser.InputType.MIB_ACCOUNT
|
||||
val bankNo = if (isDestMib) 2 else 3
|
||||
val toBank = when {
|
||||
isDestMib -> "MIB"
|
||||
else -> when (bankName.uppercase()) {
|
||||
"MALBMVMV" -> "BML"
|
||||
"MADVMVMV" -> "MIB"
|
||||
else -> bankName.ifBlank { "LOCAL" }
|
||||
}
|
||||
}
|
||||
return try {
|
||||
// Switch to the profile that owns the source account
|
||||
if (src.profileId.isNotBlank()) {
|
||||
val profiles = app.mibProfilesMap[loginId] ?: emptyList()
|
||||
val profile = profiles.firstOrNull { it.profileId == src.profileId }
|
||||
if (profile != null) app.mibFlowFor(loginId).switchProfile(sess, profile)
|
||||
}
|
||||
val result = MibTransferClient().transfer(
|
||||
session = sess,
|
||||
fromAccount = src.accountNumber,
|
||||
toAccount = destAccount,
|
||||
amount = amount,
|
||||
currencyCode = currencyCode,
|
||||
benefName = destName.ifBlank { "Recipient" },
|
||||
bankNo = bankNo,
|
||||
purpose = remarks,
|
||||
otp = otp
|
||||
)
|
||||
if (result.success) {
|
||||
val receipt = TransferReceiptData(
|
||||
bank = "MIB",
|
||||
amount = "%.2f".format(amount.toDoubleOrNull() ?: 0.0),
|
||||
currency = currency,
|
||||
fromLabel = src.accountBriefName,
|
||||
fromColorHex = "#FE860E",
|
||||
fromProfileImageHash = src.profileImageHash,
|
||||
toLabel = destDisplay.ifBlank { destName },
|
||||
toAccount = destAccount,
|
||||
toBank = toBank,
|
||||
remarks = remarks,
|
||||
mibReferenceNo = result.trxId,
|
||||
mibTransactionDate = result.date
|
||||
)
|
||||
Triple(true, "BankTransaction ID: ${result.trxId}\n${result.date}", receipt)
|
||||
} else {
|
||||
Triple(false, result.errorMessage.ifBlank { "Transfer failed" }, null)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Triple(false, if (e is java.io.IOException) "CONNECTIVITY" else (e.message ?: "Transfer failed"), null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,10 @@ class BankSelectionFragment : Fragment() {
|
||||
val args = android.os.Bundle().apply { putString("bankType", "FAHIPAY") }
|
||||
findNavController().navigate(R.id.action_bankSelection_to_credentials_fahipay, args)
|
||||
}
|
||||
binding.cardOoredoo.setOnClickListener {
|
||||
val args = android.os.Bundle().apply { putString("bankType", "OOREDOO") }
|
||||
findNavController().navigate(R.id.action_bankSelection_to_credentials_ooredoo, args)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package sh.sar.basedbank.ui.login
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
@@ -13,11 +14,13 @@ import android.os.Looper
|
||||
import android.text.Editable
|
||||
import android.text.TextWatcher
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.util.OtpauthParser
|
||||
import sh.sar.basedbank.util.Totp
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.R
|
||||
@@ -27,6 +30,12 @@ import sh.sar.basedbank.api.bml.BmlLoginFlow
|
||||
import sh.sar.basedbank.api.fahipay.FahipayAccountClient
|
||||
import sh.sar.basedbank.api.fahipay.FahipayLoginFlow
|
||||
import sh.sar.basedbank.api.fahipay.FahipaySession
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaAccountClient
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaInvalidPinException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaKycRequiredException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaLoginFlow
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaNotRegisteredException
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaWalletNotReadyException
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibLoginFlow
|
||||
import sh.sar.basedbank.api.mib.MibProfileClient
|
||||
@@ -34,6 +43,7 @@ import sh.sar.basedbank.util.AccountCache
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
|
||||
import sh.sar.basedbank.ui.home.HomeActivity
|
||||
import sh.sar.basedbank.ui.home.QrScannerActivity
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
|
||||
class CredentialsFragment : Fragment() {
|
||||
@@ -60,6 +70,25 @@ class CredentialsFragment : Fragment() {
|
||||
private var bmlLoginId: String = ""
|
||||
private var bmlAccumulatedAccounts = mutableListOf<BankAccount>()
|
||||
|
||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||
val entries = OtpauthParser.parse(raw)
|
||||
when {
|
||||
entries.isEmpty() -> Toast.makeText(requireContext(), "No OTP data found in QR", Toast.LENGTH_SHORT).show()
|
||||
entries.size == 1 -> binding.etOtpSeed.setText(entries[0].secret)
|
||||
else -> {
|
||||
val labels = entries.map { e ->
|
||||
if (e.issuer.isNotBlank()) "${e.issuer} (${e.name})" else e.name.ifBlank { e.secret.take(8) + "…" }
|
||||
}.toTypedArray()
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Choose account")
|
||||
.setItems(labels) { _, i -> binding.etOtpSeed.setText(entries[i].secret) }
|
||||
.show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
@@ -75,7 +104,20 @@ class CredentialsFragment : Fragment() {
|
||||
binding.ivBankLogo.setImageResource(R.drawable.fahipay_logo_long)
|
||||
binding.tvSignInDesc.setText(R.string.fahipay_sign_in_desc)
|
||||
binding.tilUsername.hint = getString(R.string.fahipay_id_card)
|
||||
binding.tilOtpSeed.visibility = android.view.View.GONE
|
||||
binding.rowOtpSeed.visibility = android.view.View.GONE
|
||||
binding.etOtpSeed.isEnabled = false
|
||||
binding.etOtpSeed.isFocusable = false
|
||||
}
|
||||
"OOREDOO" -> {
|
||||
binding.ivBankLogo.setImageResource(R.drawable.ooredoo_logo_long)
|
||||
binding.tvSignInDesc.setText(R.string.ooredoo_sign_in_desc)
|
||||
binding.tilUsername.hint = getString(R.string.ooredoo_phone)
|
||||
binding.etUsername.inputType = android.text.InputType.TYPE_CLASS_PHONE
|
||||
binding.etPassword.inputType =
|
||||
android.text.InputType.TYPE_CLASS_NUMBER or android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD
|
||||
binding.etPassword.filters = arrayOf<android.text.InputFilter>(android.text.InputFilter.LengthFilter(4))
|
||||
binding.tilPassword.hint = getString(R.string.ooredoo_pin)
|
||||
binding.rowOtpSeed.visibility = android.view.View.GONE
|
||||
binding.etOtpSeed.isEnabled = false
|
||||
binding.etOtpSeed.isFocusable = false
|
||||
}
|
||||
@@ -83,6 +125,9 @@ class CredentialsFragment : Fragment() {
|
||||
|
||||
binding.btnLogin.isEnabled = false
|
||||
binding.btnLogin.setOnClickListener { attemptLogin() }
|
||||
binding.btnScanOtpSeed.setOnClickListener {
|
||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||
}
|
||||
|
||||
binding.cardOtp.setOnClickListener {
|
||||
val code = binding.tvOtpCode.text.toString().replace(" ", "")
|
||||
@@ -107,7 +152,7 @@ class CredentialsFragment : Fragment() {
|
||||
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
|
||||
})
|
||||
|
||||
if (bankType != "FAHIPAY") {
|
||||
if (bankType != "FAHIPAY" && bankType != "OOREDOO") {
|
||||
binding.etOtpSeed.addTextChangedListener(object : TextWatcher {
|
||||
override fun afterTextChanged(s: Editable?) {
|
||||
updateOtpDisplay()
|
||||
@@ -121,7 +166,7 @@ class CredentialsFragment : Fragment() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (bankType != "FAHIPAY") otpHandler.post(otpRunnable)
|
||||
if (bankType != "FAHIPAY" && bankType != "OOREDOO") otpHandler.post(otpRunnable)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
@@ -144,6 +189,7 @@ class CredentialsFragment : Fragment() {
|
||||
val otpSeed = resolveOtpSeed(otpSeedRaw)
|
||||
binding.btnLogin.isEnabled = when (bankType) {
|
||||
"FAHIPAY" -> username.isNotEmpty() && password.isNotEmpty()
|
||||
"OOREDOO" -> username.isNotEmpty() && password.length == 4
|
||||
else -> username.isNotEmpty() && password.isNotEmpty() && otpSeed.isNotEmpty() && password != otpSeedRaw
|
||||
}
|
||||
}
|
||||
@@ -178,6 +224,7 @@ class CredentialsFragment : Fragment() {
|
||||
when (bankType) {
|
||||
"BML" -> { attemptBmlLogin(); return }
|
||||
"FAHIPAY" -> { attemptFahipayLogin(); return }
|
||||
"OOREDOO" -> { attemptMfaisaLogin(); return }
|
||||
}
|
||||
|
||||
val username = binding.etUsername.text.toString().trim()
|
||||
@@ -385,6 +432,97 @@ class CredentialsFragment : Fragment() {
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun attemptMfaisaLogin() {
|
||||
val msisdn = binding.etUsername.text.toString().trim()
|
||||
val pin = binding.etPassword.text.toString()
|
||||
|
||||
if (msisdn.isEmpty() || pin.length != 4) {
|
||||
binding.tvError.text = "Please enter your phone number and 4-digit mPIN"
|
||||
binding.tvError.visibility = View.VISIBLE
|
||||
return
|
||||
}
|
||||
|
||||
binding.tvError.visibility = View.GONE
|
||||
binding.progressBar.visibility = View.VISIBLE
|
||||
binding.btnLogin.isEnabled = false
|
||||
binding.etUsername.isEnabled = false
|
||||
binding.etPassword.isEnabled = false
|
||||
|
||||
val store = CredentialStore(requireContext())
|
||||
val flow = MfaisaLoginFlow(requireContext())
|
||||
val loginTag = "mfaisa_$msisdn"
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) { flow.fetchSubscriber(msisdn) }
|
||||
val result = withContext(Dispatchers.IO) { flow.doMobileLogin(msisdn, pin) }
|
||||
val accounts = MfaisaAccountClient.buildAccounts(result, loginTag)
|
||||
|
||||
store.saveMfaisaCredentials(msisdn, msisdn, pin)
|
||||
store.saveMfaisaUserProfile(
|
||||
msisdn,
|
||||
CredentialStore.MfaisaUserProfile(
|
||||
name = result.profile.name,
|
||||
email = result.profile.email,
|
||||
mdnId = result.profile.mdnId,
|
||||
subscriberId = result.profile.subscriberId,
|
||||
walletId = result.profile.walletId,
|
||||
roleId = result.profile.roleId,
|
||||
offerId = result.profile.offerId
|
||||
)
|
||||
)
|
||||
AccountCache.saveMfaisa(requireContext(), msisdn, accounts)
|
||||
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
app.mfaisaSessions[msisdn] = result.session
|
||||
app.mfaisaAccounts = app.mfaisaAccounts.filter { it.loginTag != loginTag } + accounts
|
||||
app.accounts = app.accounts.filter { it.loginTag != loginTag } + accounts
|
||||
app.isUnlocked = true
|
||||
|
||||
val intent = Intent(requireContext(), HomeActivity::class.java)
|
||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
startActivity(intent)
|
||||
} catch (e: MfaisaNotRegisteredException) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("User not registered")
|
||||
.setMessage("Please use the Ooredoo SuperApp to register your M-Faisa wallet and complete KYC, then come back to Thijooree.")
|
||||
.setPositiveButton("OK", null)
|
||||
.show()
|
||||
binding.tvError.visibility = View.GONE
|
||||
} catch (e: MfaisaKycRequiredException) {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("KYC incomplete")
|
||||
.setMessage("Your M-Faisa wallet needs Full KYC. Please complete KYC in the Ooredoo SuperApp, then come back to Thijooree.")
|
||||
.setPositiveButton("OK", null)
|
||||
.show()
|
||||
binding.tvError.visibility = View.GONE
|
||||
} catch (e: MfaisaWalletNotReadyException) {
|
||||
binding.tvError.text = e.message
|
||||
binding.tvError.visibility = View.VISIBLE
|
||||
} catch (e: MfaisaInvalidPinException) {
|
||||
val message = if (e.lastAttempt)
|
||||
"${e.message}\n\nOne more wrong mPIN will lock your account."
|
||||
else
|
||||
e.message
|
||||
binding.tvError.text = message ?: "Incorrect mPIN"
|
||||
binding.tvError.visibility = View.VISIBLE
|
||||
// Re-enable PIN input only — leave phone number locked
|
||||
binding.etPassword.isEnabled = true
|
||||
binding.etPassword.setText("")
|
||||
binding.etPassword.requestFocus()
|
||||
} catch (e: Exception) {
|
||||
binding.tvError.text = e.message ?: "Login failed"
|
||||
binding.tvError.visibility = View.VISIBLE
|
||||
binding.etUsername.isEnabled = true
|
||||
binding.etPassword.isEnabled = true
|
||||
} finally {
|
||||
binding.progressBar.visibility = View.GONE
|
||||
binding.btnLogin.isEnabled = true
|
||||
// After PIN error, keep phone disabled; for any other resolution above we already restored as needed.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun attemptFahipayLogin() {
|
||||
if (fahipayAwaitingTotp) {
|
||||
submitFahipayTotp()
|
||||
|
||||
@@ -31,9 +31,14 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
||||
ThemeHelper.applyAccent(this)
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// If security is already configured, onboarding is complete. Redirect to lock screen
|
||||
// to prevent overwriting an existing PIN/pattern via direct activity launch.
|
||||
if (CredentialStore(this).loadSecurityHash() != null) {
|
||||
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
|
||||
// Only redirect to the lock screen if onboarding is fully complete. Checking the
|
||||
// security hash alone is not sufficient — the hash is written during the PIN/pattern
|
||||
// setup step (page 1) which happens *before* the user clicks "Get Started", so a
|
||||
// theme change or process restart mid-onboarding would otherwise trigger this guard
|
||||
// and strand the user in the lock flow without finishing onboarding.
|
||||
if (prefs.getBoolean("onboarding_done", false) && CredentialStore(this).loadSecurityHash() != null) {
|
||||
startActivity(Intent(this, LockActivity::class.java))
|
||||
finish()
|
||||
return
|
||||
@@ -50,7 +55,6 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
||||
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
|
||||
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
|
||||
ta.recycle()
|
||||
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||
val originalBottomPadding = binding.bottomBar.paddingBottom
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets ->
|
||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.biometric.BiometricManager
|
||||
import androidx.fragment.app.Fragment
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentOnboardingConfigureBinding
|
||||
import sh.sar.basedbank.ui.home.NavCustomization
|
||||
|
||||
class OnboardingConfigureFragment : Fragment() {
|
||||
|
||||
@@ -24,12 +25,20 @@ class OnboardingConfigureFragment : Fragment() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
|
||||
// Navigation — default Drawer
|
||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
||||
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer)
|
||||
// Navigation
|
||||
binding.navModeToggle.check(when (NavCustomization.getNavMode(prefs)) {
|
||||
NavCustomization.NAV_MODE_BOTTOM -> R.id.btnNavBottom
|
||||
NavCustomization.NAV_MODE_CIRCULAR -> R.id.btnNavCircular
|
||||
else -> R.id.btnNavDrawer
|
||||
})
|
||||
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||
if (!isChecked) return@addOnButtonCheckedListener
|
||||
prefs.edit().putBoolean("bottom_nav", checkedId == R.id.btnNavBottom).apply()
|
||||
val mode = when (checkedId) {
|
||||
R.id.btnNavBottom -> NavCustomization.NAV_MODE_BOTTOM
|
||||
R.id.btnNavCircular -> NavCustomization.NAV_MODE_CIRCULAR
|
||||
else -> NavCustomization.NAV_MODE_DRAWER
|
||||
}
|
||||
NavCustomization.saveNavMode(prefs, mode)
|
||||
}
|
||||
|
||||
// Theme — default System
|
||||
|
||||
@@ -59,6 +59,7 @@ class OnboardingFragment : Fragment() {
|
||||
|
||||
private fun notifyScrolledToBottom() {
|
||||
if (scrolledToBottom) return
|
||||
if (!isAdded) return
|
||||
scrolledToBottom = true
|
||||
parentFragmentManager.setFragmentResult(RESULT_SCROLLED_TO_BOTTOM, Bundle.EMPTY)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.material.button.MaterialButton
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding
|
||||
@@ -102,8 +103,17 @@ class SecuritySetupFragment : Fragment() {
|
||||
else
|
||||
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
||||
val btn = MaterialButton(requireContext(), null, style).apply {
|
||||
if (key == "⌫" || key == "✓") {
|
||||
text = ""
|
||||
icon = ContextCompat.getDrawable(requireContext(),
|
||||
if (key == "⌫") R.drawable.ic_backspace else R.drawable.ic_check)
|
||||
iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START
|
||||
iconPadding = 0
|
||||
iconSize = (28 * dp).toInt()
|
||||
} else {
|
||||
text = key
|
||||
textSize = 24f
|
||||
}
|
||||
insetTop = 0; insetBottom = 0
|
||||
minimumWidth = 0; minimumHeight = 0
|
||||
cornerRadius = btnSize / 2
|
||||
|
||||
@@ -11,6 +11,7 @@ object AccountCache {
|
||||
private const val KEY_MIB = "mib_accounts"
|
||||
private fun bmlKey(loginId: String) = "bml_accounts_$loginId"
|
||||
private fun fahipayKey(loginId: String) = "fahipay_accounts_$loginId"
|
||||
private fun mfaisaKey(loginId: String) = "mfaisa_accounts_$loginId"
|
||||
|
||||
fun save(context: Context, accounts: List<BankAccount>) {
|
||||
val arr = JSONArray()
|
||||
@@ -150,6 +151,62 @@ object AccountCache {
|
||||
fun loadFahipay(context: Context, loginIds: List<String>): List<BankAccount> =
|
||||
loginIds.flatMap { loadFahipay(context, it) }
|
||||
|
||||
fun saveMfaisa(context: Context, loginId: String, accounts: List<BankAccount>) {
|
||||
val arr = JSONArray()
|
||||
for (acc in accounts) {
|
||||
arr.put(JSONObject().apply {
|
||||
put("profileName", acc.profileName)
|
||||
put("profileType", acc.profileType)
|
||||
put("accountNumber", acc.accountNumber)
|
||||
put("accountBriefName", acc.accountBriefName)
|
||||
put("currencyName", acc.currencyName)
|
||||
put("accountTypeName", acc.accountTypeName)
|
||||
put("availableBalance", acc.availableBalance)
|
||||
put("currentBalance", acc.currentBalance)
|
||||
put("blockedAmount", acc.blockedAmount)
|
||||
put("mvrBalance", acc.mvrBalance)
|
||||
put("statusDesc", acc.statusDesc)
|
||||
put("loginTag", acc.loginTag)
|
||||
put("profileId", acc.profileId)
|
||||
put("internalId", acc.internalId)
|
||||
})
|
||||
}
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit().putString(mfaisaKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
||||
}
|
||||
|
||||
fun loadMfaisa(context: Context, loginId: String): List<BankAccount> {
|
||||
val raw = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(mfaisaKey(loginId), null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||
(0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
BankAccount(
|
||||
bank = "MFAISA",
|
||||
profileName = o.optString("profileName"),
|
||||
profileType = o.optString("profileType"),
|
||||
accountNumber = o.optString("accountNumber"),
|
||||
accountBriefName = o.optString("accountBriefName"),
|
||||
currencyName = o.optString("currencyName"),
|
||||
accountTypeName = o.optString("accountTypeName"),
|
||||
availableBalance = o.optString("availableBalance"),
|
||||
currentBalance = o.optString("currentBalance"),
|
||||
blockedAmount = o.optString("blockedAmount"),
|
||||
mvrBalance = o.optString("mvrBalance"),
|
||||
statusDesc = o.optString("statusDesc"),
|
||||
profileImageHash = null,
|
||||
loginTag = o.optString("loginTag"),
|
||||
profileId = o.optString("profileId", ""),
|
||||
internalId = o.optString("internalId", "")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun loadMfaisa(context: Context, loginIds: List<String>): List<BankAccount> =
|
||||
loginIds.flatMap { loadMfaisa(context, it) }
|
||||
|
||||
fun clear(context: Context) {
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ package sh.sar.basedbank.util
|
||||
data class AccountHistoryDisplay(
|
||||
val name: String,
|
||||
val number: String,
|
||||
val bankPill: String?, // "BML", "FP", null for MIB (no pill)
|
||||
val bankPill: String?, // "BML", "FP", "MIB" — bank label shown on the pill
|
||||
val typeLabel: String, // e.g. "Savings", "Current", "Visa Platinum"
|
||||
val availableBalance: String, // formatted "CCY amount"
|
||||
val workingBalance: String, // ledger/working balance — formatted "CCY amount"
|
||||
|
||||
@@ -3,6 +3,7 @@ package sh.sar.basedbank.util
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.bmlapi.BmlHistoryParser
|
||||
import sh.sar.basedbank.util.fahipayapi.FahipayHistoryParser
|
||||
import sh.sar.basedbank.util.mfaisaapi.MfaisaHistoryParser
|
||||
import sh.sar.basedbank.util.mibapi.MibHistoryParser
|
||||
|
||||
object AccountHistoryParser {
|
||||
@@ -11,6 +12,7 @@ object AccountHistoryParser {
|
||||
"BML" -> BmlHistoryParser.displayData(account)
|
||||
"FAHIPAY" -> FahipayHistoryParser.displayData(account)
|
||||
"MIB" -> MibHistoryParser.displayData(account)
|
||||
"MFAISA" -> MfaisaHistoryParser.displayData(account)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package sh.sar.basedbank.util
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||
import sh.sar.basedbank.util.fahipayapi.FahipayAccountParser
|
||||
import sh.sar.basedbank.util.mfaisaapi.MfaisaAccountParser
|
||||
import sh.sar.basedbank.util.mibapi.MibAccountParser
|
||||
|
||||
object AccountListParser {
|
||||
@@ -11,6 +12,7 @@ object AccountListParser {
|
||||
"BML" -> BmlDashboardParser.displayData(account)
|
||||
"FAHIPAY" -> FahipayAccountParser.displayData(account)
|
||||
"MIB" -> MibAccountParser.displayData(account)
|
||||
"MFAISA" -> MfaisaAccountParser.displayData(account)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ object CardsCache {
|
||||
put("phoneNumber", c.phoneNumber)
|
||||
put("cardHolderName", c.cardHolderName)
|
||||
put("loginTag", c.loginTag)
|
||||
put("profileId", c.profileId)
|
||||
})
|
||||
}
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
@@ -45,7 +46,8 @@ object CardsCache {
|
||||
customerId = o.optString("customerId"),
|
||||
phoneNumber = o.optString("phoneNumber"),
|
||||
cardHolderName = o.optString("cardHolderName"),
|
||||
loginTag = o.optString("loginTag")
|
||||
loginTag = o.optString("loginTag"),
|
||||
profileId = o.optString("profileId")
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
|
||||
@@ -21,6 +21,7 @@ class CredentialStore(context: Context) {
|
||||
data class MibCredentials(val username: String, val passwordHash: String, val otpSeed: String)
|
||||
data class BmlCredentials(val username: String, val password: String, val otpSeed: String)
|
||||
data class FahipayCredentials(val idCard: String, val password: String)
|
||||
data class MfaisaCredentials(val msisdn: String, val pin: String)
|
||||
|
||||
// ── MIB login credentials (multi-login, keyed by loginId = username) ─────
|
||||
|
||||
@@ -35,6 +36,11 @@ class CredentialStore(context: Context) {
|
||||
|
||||
fun hasMibCredentials(): Boolean = getMibLoginIds().isNotEmpty()
|
||||
|
||||
fun setMibLoginIds(order: List<String>) {
|
||||
if (order.toSet() != getMibLoginIds().toSet()) return
|
||||
prefs.edit().putString("mib_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||
}
|
||||
|
||||
private fun addMibLoginId(loginId: String) {
|
||||
val ids = getMibLoginIds().toMutableList()
|
||||
if (loginId !in ids) {
|
||||
@@ -159,6 +165,11 @@ class CredentialStore(context: Context) {
|
||||
|
||||
fun hasBmlCredentials(): Boolean = getBmlLoginIds().isNotEmpty()
|
||||
|
||||
fun setBmlLoginIds(order: List<String>) {
|
||||
if (order.toSet() != getBmlLoginIds().toSet()) return
|
||||
prefs.edit().putString("bml_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||
}
|
||||
|
||||
private fun addBmlLoginId(loginId: String) {
|
||||
val ids = getBmlLoginIds().toMutableList()
|
||||
if (loginId !in ids) {
|
||||
@@ -315,6 +326,11 @@ class CredentialStore(context: Context) {
|
||||
|
||||
fun hasFahipayCredentials(): Boolean = getFahipayLoginIds().isNotEmpty()
|
||||
|
||||
fun setFahipayLoginIds(order: List<String>) {
|
||||
if (order.toSet() != getFahipayLoginIds().toSet()) return
|
||||
prefs.edit().putString("fahipay_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||
}
|
||||
|
||||
private fun addFahipayLoginId(loginId: String) {
|
||||
val ids = getFahipayLoginIds().toMutableList()
|
||||
if (loginId !in ids) {
|
||||
@@ -460,6 +476,115 @@ class CredentialStore(context: Context) {
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
// ── M-Faisa login credentials (multi-login, keyed by loginId = msisdn) ───
|
||||
|
||||
fun getMfaisaLoginIds(): List<String> {
|
||||
val json = prefs.getString("mfaisa_login_ids", null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = org.json.JSONArray(json)
|
||||
(0 until arr.length()).map { arr.getString(it) }
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
fun hasMfaisaCredentials(): Boolean = getMfaisaLoginIds().isNotEmpty()
|
||||
|
||||
fun setMfaisaLoginIds(order: List<String>) {
|
||||
if (order.toSet() != getMfaisaLoginIds().toSet()) return
|
||||
prefs.edit().putString("mfaisa_login_ids", org.json.JSONArray(order).toString()).apply()
|
||||
}
|
||||
|
||||
private fun addMfaisaLoginId(loginId: String) {
|
||||
val ids = getMfaisaLoginIds().toMutableList()
|
||||
if (loginId !in ids) {
|
||||
ids.add(loginId)
|
||||
prefs.edit().putString("mfaisa_login_ids", org.json.JSONArray(ids).toString()).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeMfaisaLoginId(loginId: String) {
|
||||
val ids = getMfaisaLoginIds().toMutableList()
|
||||
if (ids.remove(loginId))
|
||||
prefs.edit().putString("mfaisa_login_ids", org.json.JSONArray(ids).toString()).apply()
|
||||
}
|
||||
|
||||
fun saveMfaisaCredentials(loginId: String, msisdn: String, pin: String) {
|
||||
addMfaisaLoginId(loginId)
|
||||
val key = getOrCreateKey()
|
||||
prefs.edit()
|
||||
.putString("mfaisa_${loginId}_enc_msisdn", encrypt(msisdn, key))
|
||||
.putString("mfaisa_${loginId}_enc_pin", encrypt(pin, key))
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun loadMfaisaCredentials(loginId: String): MfaisaCredentials? {
|
||||
val key = getOrCreateKey()
|
||||
val encMsisdn = prefs.getString("mfaisa_${loginId}_enc_msisdn", null) ?: return null
|
||||
val encPin = prefs.getString("mfaisa_${loginId}_enc_pin", null) ?: return null
|
||||
return try {
|
||||
MfaisaCredentials(decrypt(encMsisdn, key), decrypt(encPin, key))
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
fun clearMfaisaCredentials(loginId: String) {
|
||||
removeMfaisaLoginId(loginId)
|
||||
prefs.edit()
|
||||
.remove("mfaisa_${loginId}_enc_msisdn")
|
||||
.remove("mfaisa_${loginId}_enc_pin")
|
||||
.remove("mfaisa_${loginId}_enc_profile")
|
||||
.remove("mfaisa_${loginId}_hidden_pockets")
|
||||
.apply()
|
||||
}
|
||||
|
||||
/** Pocket-level hide flags (parallels [getHiddenBmlProfileIds]). Keyed by pocket ID. */
|
||||
fun getHiddenMfaisaPocketIds(loginId: String): Set<String> =
|
||||
prefs.getStringSet("mfaisa_${loginId}_hidden_pockets", emptySet()) ?: emptySet()
|
||||
|
||||
fun setHiddenMfaisaPocketIds(loginId: String, ids: Set<String>) =
|
||||
prefs.edit().putStringSet("mfaisa_${loginId}_hidden_pockets", ids).apply()
|
||||
|
||||
// ── M-Faisa user profile (per loginId) ────────────────────────────────────
|
||||
|
||||
data class MfaisaUserProfile(
|
||||
val name: String,
|
||||
val email: String,
|
||||
val mdnId: String,
|
||||
val subscriberId: String,
|
||||
val walletId: String,
|
||||
val roleId: String,
|
||||
val offerId: String
|
||||
)
|
||||
|
||||
fun saveMfaisaUserProfile(loginId: String, p: MfaisaUserProfile) {
|
||||
val json = org.json.JSONObject().apply {
|
||||
put("name", p.name)
|
||||
put("email", p.email)
|
||||
put("mdnId", p.mdnId)
|
||||
put("subscriberId", p.subscriberId)
|
||||
put("walletId", p.walletId)
|
||||
put("roleId", p.roleId)
|
||||
put("offerId", p.offerId)
|
||||
}.toString()
|
||||
val key = getOrCreateKey()
|
||||
prefs.edit().putString("mfaisa_${loginId}_enc_profile", encrypt(json, key)).apply()
|
||||
}
|
||||
|
||||
fun loadMfaisaUserProfile(loginId: String): MfaisaUserProfile? {
|
||||
val key = getOrCreateKey()
|
||||
val enc = prefs.getString("mfaisa_${loginId}_enc_profile", null) ?: return null
|
||||
return try {
|
||||
val o = org.json.JSONObject(decrypt(enc, key))
|
||||
MfaisaUserProfile(
|
||||
name = o.optString("name"),
|
||||
email = o.optString("email"),
|
||||
mdnId = o.optString("mdnId"),
|
||||
subscriberId = o.optString("subscriberId"),
|
||||
walletId = o.optString("walletId"),
|
||||
roleId = o.optString("roleId"),
|
||||
offerId = o.optString("offerId")
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
// ── Security credential (PIN / pattern hash) ──────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -627,6 +752,29 @@ class CredentialStore(context: Context) {
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
// ── Default transfer/QR account ───────────────────────────────────────────
|
||||
|
||||
/** Account number the user has pinned as their default source for transfers and PayMV QR, or null. */
|
||||
fun getDefaultAccountNumber(): String? = prefs.getString("default_account_number", null)
|
||||
|
||||
fun setDefaultAccountNumber(accountNumber: String?) {
|
||||
val editor = prefs.edit()
|
||||
if (accountNumber == null) editor.remove("default_account_number")
|
||||
else editor.putString("default_account_number", accountNumber)
|
||||
editor.apply()
|
||||
}
|
||||
|
||||
// ── Dashboard card visibility ─────────────────────────────────────────────
|
||||
|
||||
fun getHiddenDashboardCardNumbers(): Set<String> =
|
||||
prefs.getStringSet("hidden_dashboard_cards", emptySet()) ?: emptySet()
|
||||
|
||||
fun setCardHiddenFromDashboard(accountNumber: String, hidden: Boolean) {
|
||||
val current = getHiddenDashboardCardNumbers().toMutableSet()
|
||||
if (hidden) current.add(accountNumber) else current.remove(accountNumber)
|
||||
prefs.edit().putStringSet("hidden_dashboard_cards", current).apply()
|
||||
}
|
||||
|
||||
// ── MIB profile visibility (per loginId) ─────────────────────────────────
|
||||
|
||||
/** Returns the set of MIB profile IDs the user has chosen to hide (for a given loginId). */
|
||||
|
||||
@@ -6,6 +6,7 @@ import kotlinx.coroutines.withContext
|
||||
import sh.sar.basedbank.BasedBankApp
|
||||
import sh.sar.basedbank.api.bml.BmlHistoryClient
|
||||
import sh.sar.basedbank.api.fahipay.FahipayHistoryClient
|
||||
import sh.sar.basedbank.api.mfaisa.MfaisaHistoryClient
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibHistoryClient
|
||||
import sh.sar.basedbank.api.models.BankTransaction
|
||||
@@ -24,6 +25,8 @@ class HistoryFetcher(private val account: BankAccount) {
|
||||
private val isBmlCard get() = account.profileType == "BML_PREPAID" || account.profileType == "BML_CREDIT" || account.profileType == "BML_DEBIT"
|
||||
private val isBmlLoan get() = account.profileType == "BML_LOAN"
|
||||
private val isFahipay get() = account.bank == "FAHIPAY"
|
||||
private val isMfaisa get() = account.bank == "MFAISA"
|
||||
private val isMfaisaPaypal get() = account.profileType == "MFAISA_PAYPAL"
|
||||
|
||||
// MIB pagination
|
||||
private var mibNextStart = 1
|
||||
@@ -35,16 +38,38 @@ class HistoryFetcher(private val account: BankAccount) {
|
||||
|
||||
// BML card pagination (month-based)
|
||||
private var cardMonthOffset = 0
|
||||
private var pendingCardOutstanding: List<BankTransaction>? = null
|
||||
private var pendingCardUnbilled: List<BankTransaction>? = null
|
||||
|
||||
/**
|
||||
* Returns and clears the card outstanding + unbilled lists captured on the first card
|
||||
* fetch. Each list is only ever returned once.
|
||||
*/
|
||||
fun takeCardPendingSections(): Pair<List<BankTransaction>, List<BankTransaction>>? {
|
||||
val o = pendingCardOutstanding
|
||||
val u = pendingCardUnbilled
|
||||
if (o == null && u == null) return null
|
||||
pendingCardOutstanding = null
|
||||
pendingCardUnbilled = null
|
||||
return Pair(o ?: emptyList(), u ?: emptyList())
|
||||
}
|
||||
|
||||
// Fahipay pagination
|
||||
private var fahipayNextStart = 0
|
||||
private var fahipayTotal = -1
|
||||
|
||||
// M-Faisa pagination — the server doesn't return a "total" field, so we infer "more pages exist"
|
||||
// from whether the last page was full-sized.
|
||||
private var mfaisaNextPage = 1
|
||||
private var mfaisaHasMore = true
|
||||
|
||||
fun hasMore(): Boolean = when {
|
||||
isBmlLoan -> false
|
||||
isMfaisaPaypal -> false // PayPal pockets have no known history endpoint
|
||||
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||
isBmlCard -> cardMonthOffset < 3
|
||||
isMfaisa -> mfaisaHasMore
|
||||
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||
}
|
||||
|
||||
@@ -53,9 +78,36 @@ class HistoryFetcher(private val account: BankAccount) {
|
||||
isFahipay -> withContext(Dispatchers.IO) { fetchFahipay(app) }
|
||||
isMib -> app.mibMutex.withLock { withContext(Dispatchers.IO) { fetchMib(app, pageSize) } }
|
||||
isBmlCard -> withContext(Dispatchers.IO) { fetchBmlCard(app) }
|
||||
isMfaisa -> withContext(Dispatchers.IO) { fetchMfaisa(app) }
|
||||
else -> withContext(Dispatchers.IO) { fetchBmlCasa(app) }
|
||||
}
|
||||
|
||||
private fun fetchMfaisa(app: BasedBankApp): List<BankTransaction> {
|
||||
val loginId = account.loginTag.removePrefix("mfaisa_")
|
||||
var session = app.mfaisaSessionFor(account) ?: return emptyList()
|
||||
val page = try {
|
||||
MfaisaHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountNumber = account.accountNumber,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
pageNo = mfaisaNextPage,
|
||||
recordSize = 70
|
||||
)
|
||||
} catch (_: sh.sar.basedbank.api.mfaisa.MfaisaSessionExpiredException) {
|
||||
session = app.refreshMfaisaSession(loginId) ?: return emptyList()
|
||||
MfaisaHistoryClient().fetchHistory(
|
||||
session = session,
|
||||
accountNumber = account.accountNumber,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
pageNo = mfaisaNextPage,
|
||||
recordSize = 70
|
||||
)
|
||||
}
|
||||
mfaisaHasMore = page.hasMore
|
||||
mfaisaNextPage++
|
||||
return page.transactions
|
||||
}
|
||||
|
||||
private fun fetchFahipay(app: BasedBankApp): List<BankTransaction> {
|
||||
val session = app.fahipaySessionFor(account) ?: return emptyList()
|
||||
val (list, total) = FahipayHistoryClient().fetchHistory(
|
||||
@@ -90,16 +142,22 @@ class HistoryFetcher(private val account: BankAccount) {
|
||||
private fun fetchBmlCard(app: BasedBankApp): List<BankTransaction> {
|
||||
val session = app.bmlSessionFor(account) ?: return emptyList()
|
||||
val cal = Calendar.getInstance()
|
||||
val isFirstFetch = cardMonthOffset == 0
|
||||
cal.add(Calendar.MONTH, -cardMonthOffset)
|
||||
val month = SimpleDateFormat("yyyyMM", Locale.US).format(cal.time)
|
||||
cardMonthOffset++
|
||||
return BmlHistoryClient().fetchCardHistory(
|
||||
val result = BmlHistoryClient().fetchCardHistory(
|
||||
session = session,
|
||||
cardId = account.internalId,
|
||||
accountDisplayName = account.accountBriefName,
|
||||
accountNumber = account.accountNumber,
|
||||
month = month
|
||||
)
|
||||
if (isFirstFetch) {
|
||||
pendingCardOutstanding = result.outstanding
|
||||
pendingCardUnbilled = result.unbilled
|
||||
}
|
||||
return result.statement
|
||||
}
|
||||
|
||||
private fun fetchBmlCasa(app: BasedBankApp): List<BankTransaction> {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.nfc.NfcAdapter
|
||||
import android.nfc.cardemulation.CardEmulation
|
||||
import android.provider.Settings
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import sh.sar.basedbank.R
|
||||
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
|
||||
|
||||
object NfcPaymentUtil {
|
||||
fun checkAndProceed(context: Context, onReady: () -> Unit) {
|
||||
val nfcAdapter = NfcAdapter.getDefaultAdapter(context) ?: run {
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.nfc_unsupported_title)
|
||||
.setMessage(R.string.nfc_unsupported_message)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
return
|
||||
}
|
||||
|
||||
if (!nfcAdapter.isEnabled) {
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.nfc_disabled_title)
|
||||
.setMessage(R.string.nfc_disabled_message)
|
||||
.setPositiveButton(R.string.nfc_open_settings) { _, _ ->
|
||||
context.startActivity(Intent(Settings.ACTION_NFC_SETTINGS))
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
return
|
||||
}
|
||||
|
||||
val cardEmulation = CardEmulation.getInstance(nfcAdapter)
|
||||
val componentName = ComponentName(context, BmlHostCardEmulatorService::class.java)
|
||||
if (!cardEmulation.isDefaultServiceForCategory(componentName, CardEmulation.CATEGORY_PAYMENT)) {
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.nfc_not_default_title)
|
||||
.setMessage(context.getString(R.string.nfc_not_default_message,
|
||||
context.applicationInfo.loadLabel(context.packageManager)))
|
||||
.setPositiveButton(R.string.nfc_payment_open_settings) { _, _ ->
|
||||
context.startActivity(Intent(Settings.ACTION_NFC_PAYMENT_SETTINGS))
|
||||
}
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.show()
|
||||
return
|
||||
}
|
||||
|
||||
onReady()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
import android.content.Context
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import sh.sar.basedbank.ui.home.AppNotification
|
||||
|
||||
object NotificationsCache {
|
||||
|
||||
private const val PREFS = "notifications_cache"
|
||||
private const val KEY_MIB_READ_IDS = "mib_read_ids"
|
||||
|
||||
private fun bmlKey(loginId: String) = "bml_notifs_$loginId"
|
||||
private fun mibKey(loginId: String) = "mib_activities_$loginId"
|
||||
|
||||
// ── BML ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
fun saveBml(ctx: Context, loginId: String, items: List<AppNotification>) {
|
||||
val arr = JSONArray()
|
||||
items.forEach { n ->
|
||||
arr.put(JSONObject().apply {
|
||||
put("id", n.id)
|
||||
put("group", n.group)
|
||||
put("title", n.title)
|
||||
put("message", n.message)
|
||||
put("timestampMs", n.timestampMs)
|
||||
put("isRead", n.isRead)
|
||||
val fields = JSONArray()
|
||||
n.detailFields.forEach { (k, v) -> fields.put(JSONObject().put("k", k).put("v", v)) }
|
||||
put("detailFields", fields)
|
||||
})
|
||||
}
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit().putString(bmlKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
||||
}
|
||||
|
||||
fun loadBml(ctx: Context, loginId: String): List<AppNotification> {
|
||||
val raw = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(bmlKey(loginId), null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||
(0 until arr.length()).map { i ->
|
||||
val obj = arr.getJSONObject(i)
|
||||
val fields = obj.optJSONArray("detailFields")
|
||||
val detailFields = if (fields != null) {
|
||||
(0 until fields.length()).map { j ->
|
||||
val f = fields.getJSONObject(j)
|
||||
f.getString("k") to f.getString("v")
|
||||
}
|
||||
} else emptyList()
|
||||
AppNotification(
|
||||
id = obj.getString("id"),
|
||||
bank = "BML",
|
||||
loginId = loginId,
|
||||
group = obj.getString("group"),
|
||||
title = obj.getString("title"),
|
||||
message = obj.getString("message"),
|
||||
timestampMs = obj.getLong("timestampMs"),
|
||||
isRead = obj.getBoolean("isRead"),
|
||||
detailFields = detailFields
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
// ── MIB ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
fun saveMib(ctx: Context, loginId: String, items: List<AppNotification>) {
|
||||
val arr = JSONArray()
|
||||
items.forEach { n ->
|
||||
arr.put(JSONObject().apply {
|
||||
put("id", n.id)
|
||||
put("title", n.title)
|
||||
put("message", n.message)
|
||||
put("timestampMs", n.timestampMs)
|
||||
val fields = JSONArray()
|
||||
n.detailFields.forEach { (k, v) -> fields.put(JSONObject().put("k", k).put("v", v)) }
|
||||
put("detailFields", fields)
|
||||
})
|
||||
}
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit().putString(mibKey(loginId), CacheEncryption.encrypt(arr.toString())).apply()
|
||||
}
|
||||
|
||||
fun loadMib(ctx: Context, loginId: String, readIds: Set<String>): List<AppNotification> {
|
||||
val raw = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(mibKey(loginId), null) ?: return emptyList()
|
||||
return try {
|
||||
val arr = JSONArray(CacheEncryption.decrypt(raw))
|
||||
(0 until arr.length()).map { i ->
|
||||
val obj = arr.getJSONObject(i)
|
||||
val id = obj.getString("id")
|
||||
val fields = obj.optJSONArray("detailFields")
|
||||
val detailFields = if (fields != null) {
|
||||
(0 until fields.length()).map { j ->
|
||||
val f = fields.getJSONObject(j)
|
||||
f.getString("k") to f.getString("v")
|
||||
}
|
||||
} else emptyList()
|
||||
AppNotification(
|
||||
id = id,
|
||||
bank = "MIB",
|
||||
loginId = loginId,
|
||||
group = "ALERTS",
|
||||
title = obj.getString("title"),
|
||||
message = obj.getString("message"),
|
||||
timestampMs = obj.getLong("timestampMs"),
|
||||
isRead = id in readIds,
|
||||
detailFields = detailFields
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) { emptyList() }
|
||||
}
|
||||
|
||||
// ── MIB read IDs (in-app only) ───────────────────────────────────────────────
|
||||
|
||||
fun getMibReadIds(ctx: Context): Set<String> {
|
||||
val raw = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.getString(KEY_MIB_READ_IDS, null) ?: return emptySet()
|
||||
return try {
|
||||
val arr = JSONArray(raw)
|
||||
(0 until arr.length()).map { arr.getString(it) }.toSet()
|
||||
} catch (_: Exception) { emptySet() }
|
||||
}
|
||||
|
||||
fun addMibReadIds(ctx: Context, ids: Collection<String>) {
|
||||
val current = getMibReadIds(ctx).toMutableSet()
|
||||
current.addAll(ids)
|
||||
val arr = JSONArray().apply { current.forEach { put(it) } }
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
.edit().putString(KEY_MIB_READ_IDS, arr.toString()).apply()
|
||||
}
|
||||
|
||||
fun clearAll(ctx: Context) {
|
||||
ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package sh.sar.basedbank.util
|
||||
|
||||
import android.net.Uri
|
||||
import android.util.Base64
|
||||
|
||||
data class OtpEntry(val name: String, val issuer: String, val secret: String)
|
||||
|
||||
object OtpauthParser {
|
||||
|
||||
fun parse(raw: String): List<OtpEntry> = when {
|
||||
raw.startsWith("otpauth-migration://") -> parseMigration(raw)
|
||||
raw.startsWith("otpauth://") -> parseStandard(raw)?.let { listOf(it) } ?: emptyList()
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
private fun parseStandard(raw: String): OtpEntry? {
|
||||
val uri = Uri.parse(raw)
|
||||
val secret = uri.getQueryParameter("secret") ?: return null
|
||||
val issuer = uri.getQueryParameter("issuer") ?: ""
|
||||
val label = uri.path?.trimStart('/') ?: ""
|
||||
val name = if (':' in label) label.substringAfter(':').trim() else label
|
||||
return OtpEntry(name, issuer, secret.uppercase())
|
||||
}
|
||||
|
||||
private fun parseMigration(raw: String): List<OtpEntry> {
|
||||
val data = Uri.parse(raw).getQueryParameter("data") ?: return emptyList()
|
||||
val bytes = try { Base64.decode(data, Base64.DEFAULT) } catch (_: Exception) { return emptyList() }
|
||||
val reader = ProtobufReader(bytes)
|
||||
val entries = mutableListOf<OtpEntry>()
|
||||
while (reader.hasMore()) {
|
||||
val tag = reader.readVarint().toInt()
|
||||
val fieldNum = tag ushr 3
|
||||
val wireType = tag and 0x7
|
||||
if (fieldNum == 1 && wireType == 2) {
|
||||
parseOtpParameters(reader.readBytes())?.let { entries.add(it) }
|
||||
} else {
|
||||
reader.skip(wireType)
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
private fun parseOtpParameters(bytes: ByteArray): OtpEntry? {
|
||||
val reader = ProtobufReader(bytes)
|
||||
var secret: ByteArray? = null
|
||||
var name = ""
|
||||
var issuer = ""
|
||||
var type = 2 // default to TOTP
|
||||
while (reader.hasMore()) {
|
||||
val tag = reader.readVarint().toInt()
|
||||
val fieldNum = tag ushr 3
|
||||
val wireType = tag and 0x7
|
||||
when (fieldNum) {
|
||||
1 -> secret = reader.readBytes()
|
||||
2 -> name = String(reader.readBytes(), Charsets.UTF_8)
|
||||
3 -> issuer = String(reader.readBytes(), Charsets.UTF_8)
|
||||
6 -> type = reader.readVarint().toInt()
|
||||
else -> reader.skip(wireType)
|
||||
}
|
||||
}
|
||||
if (type == 1) return null // skip HOTP
|
||||
val secretBase32 = base32Encode(secret ?: return null)
|
||||
return OtpEntry(name, issuer, secretBase32)
|
||||
}
|
||||
|
||||
private fun base32Encode(bytes: ByteArray): String {
|
||||
val alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
|
||||
val sb = StringBuilder()
|
||||
var buffer = 0
|
||||
var bitsLeft = 0
|
||||
for (b in bytes) {
|
||||
buffer = (buffer shl 8) or (b.toInt() and 0xFF)
|
||||
bitsLeft += 8
|
||||
while (bitsLeft >= 5) {
|
||||
bitsLeft -= 5
|
||||
sb.append(alphabet[(buffer ushr bitsLeft) and 0x1F])
|
||||
}
|
||||
}
|
||||
if (bitsLeft > 0) sb.append(alphabet[(buffer shl (5 - bitsLeft)) and 0x1F])
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
private class ProtobufReader(private val bytes: ByteArray) {
|
||||
private var pos = 0
|
||||
|
||||
fun hasMore() = pos < bytes.size
|
||||
|
||||
fun readVarint(): Long {
|
||||
var result = 0L
|
||||
var shift = 0
|
||||
while (pos < bytes.size) {
|
||||
val b = bytes[pos++].toInt() and 0xFF
|
||||
result = result or ((b and 0x7F).toLong() shl shift)
|
||||
if (b and 0x80 == 0) break
|
||||
shift += 7
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
fun readBytes(): ByteArray {
|
||||
val len = readVarint().toInt()
|
||||
val data = bytes.copyOfRange(pos, pos + len)
|
||||
pos += len
|
||||
return data
|
||||
}
|
||||
|
||||
fun skip(wireType: Int) {
|
||||
when (wireType) {
|
||||
0 -> readVarint()
|
||||
1 -> pos += 8
|
||||
2 -> readBytes()
|
||||
5 -> pos += 4
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,60 @@ data class PaymvQrData(
|
||||
|
||||
object PaymvQrParser {
|
||||
|
||||
private const val BML_GATEWAY_PREFIX = "https://pay.bml.com.mv/app/"
|
||||
private const val BML_EBANKING_PREFIX = "https://ebanking.bankofmaldives.com.mv/qrpay/"
|
||||
private const val BML_POS_DOMAIN = "mv.com.bml.qtr"
|
||||
|
||||
/**
|
||||
* Returns the value to hand to the BML QR payment flow, or null when [raw] is not a BML QR.
|
||||
*
|
||||
* Three shapes exist:
|
||||
* - plain URL QRs — the QR text is the gateway short link or an ebanking `qrpay` link;
|
||||
* - combined EMV QRs (e.g. Fahipay+BML card QRs), which carry the full gateway URL in TLV at
|
||||
* `35` → `20` → `01`;
|
||||
* - BML POS QRs (supplementary data domain `mv.com.bml.qtr`), where the same TLV path holds a
|
||||
* bare reference such as `02:<32 hex>` rather than a URL. Those return the whole EMV payload,
|
||||
* which [bmlPayRequestKey] then reduces back to the reference the lookup wants.
|
||||
*
|
||||
* The TLV is walked rather than regex-matched so a URL cannot greedily swallow the EMV tags
|
||||
* that follow it.
|
||||
*/
|
||||
fun bmlQrPayTarget(raw: String): String? {
|
||||
if (raw.startsWith(BML_GATEWAY_PREFIX) || raw.startsWith(BML_EBANKING_PREFIX)) return raw
|
||||
val ref = bmlQrReference(raw) ?: return null
|
||||
if (ref.startsWith("https://")) return ref.takeIf { it.startsWith(BML_GATEWAY_PREFIX) }
|
||||
// A bare reference only means a BML POS QR when the supplementary domain says so; anything
|
||||
// else keeps falling through to the PayMV parse, as it did before POS QRs existed.
|
||||
return raw.takeIf { supplementaryDomain(it)?.startsWith(BML_POS_DOMAIN) == true }
|
||||
}
|
||||
|
||||
/** TLV `80` → `00` — the supplementary data domain (`mv.favara.mpqr`, `mv.com.bml.qtr`, …). */
|
||||
private fun supplementaryDomain(raw: String): String? = try {
|
||||
parseTlv(raw)["80"]?.let { parseTlv(it) }?.get("00")
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/** TLV `35` → `20` → `01` — the BML payment reference carried inside an EMV QR. */
|
||||
private fun bmlQrReference(raw: String): String? = try {
|
||||
val merchantInfo = parseTlv(raw)["35"]?.let { parseTlv(it) }
|
||||
val inner = merchantInfo?.get("20")?.let { parseTlv(it) }
|
||||
inner?.get("01")?.takeIf { it.isNotBlank() }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* The `payrequest` lookup key for [target] (a value returned by [bmlQrPayTarget]), to be
|
||||
* Base64-encoded into the URL. URL QRs resolve under the URL itself; POS QRs resolve under the
|
||||
* bare `35` → `20` → `01` reference (the whole EMV payload is rejected with code 112,
|
||||
* "Unsupported payment link").
|
||||
*/
|
||||
fun bmlPayRequestKey(target: String): String {
|
||||
if (target.startsWith("https://")) return target
|
||||
return bmlQrReference(target) ?: target
|
||||
}
|
||||
|
||||
fun parse(raw: String): PaymvQrData? {
|
||||
return try {
|
||||
val root = parseTlv(raw)
|
||||
@@ -23,7 +77,7 @@ object PaymvQrParser {
|
||||
|
||||
PaymvQrData(
|
||||
accountNumber = merchantInfo?.get("03"),
|
||||
amount = root["54"],
|
||||
amount = root["54"]?.takeIf { it != "***" },
|
||||
purpose = additionalData?.get("08"),
|
||||
merchantName = root["59"]
|
||||
)
|
||||
|
||||
@@ -43,7 +43,12 @@ object ReceiptStore {
|
||||
bmlFromName = o.optString("bmlFromName"),
|
||||
bmlReference = o.optString("bmlReference"),
|
||||
bmlTimestamp = o.optString("bmlTimestamp"),
|
||||
bmlMessage = o.optString("bmlMessage")
|
||||
bmlMessage = o.optString("bmlMessage"),
|
||||
mfaisaTransactionType = o.optString("mfaisaTransactionType"),
|
||||
mfaisaFromName = o.optString("mfaisaFromName"),
|
||||
mfaisaFromMsisdn = o.optString("mfaisaFromMsisdn"),
|
||||
mfaisaToMsisdn = o.optString("mfaisaToMsisdn"),
|
||||
mfaisaTimestamp = o.optLong("mfaisaTimestamp", 0L)
|
||||
),
|
||||
savedAt = o.optLong("savedAt", 0L)
|
||||
)
|
||||
@@ -75,6 +80,11 @@ object ReceiptStore {
|
||||
put("bmlReference", d.bmlReference)
|
||||
put("bmlTimestamp", d.bmlTimestamp)
|
||||
put("bmlMessage", d.bmlMessage)
|
||||
put("mfaisaTransactionType", d.mfaisaTransactionType)
|
||||
put("mfaisaFromName", d.mfaisaFromName)
|
||||
put("mfaisaFromMsisdn", d.mfaisaFromMsisdn)
|
||||
put("mfaisaToMsisdn", d.mfaisaToMsisdn)
|
||||
put("mfaisaTimestamp", d.mfaisaTimestamp)
|
||||
put("savedAt", ts)
|
||||
})
|
||||
File(context.filesDir, FILE_NAME).writeText(CacheEncryption.encrypt(arr.toString()))
|
||||
|
||||
@@ -10,7 +10,10 @@ data class RecentPick(
|
||||
val subtitle: String,
|
||||
val colorHex: String,
|
||||
val imageHash: String?,
|
||||
val isProfileImage: Boolean
|
||||
val isProfileImage: Boolean,
|
||||
/** Source bank tag for the recent — e.g. "MFAISA". Used by the picker to decide
|
||||
* per-bank selectability. Null for legacy entries; treated as unspecified. */
|
||||
val bank: String? = null
|
||||
)
|
||||
|
||||
object RecentsCache {
|
||||
@@ -34,6 +37,7 @@ object RecentsCache {
|
||||
put("colorHex", r.colorHex)
|
||||
if (r.imageHash != null) put("imageHash", r.imageHash)
|
||||
put("isProfileImage", r.isProfileImage)
|
||||
if (r.bank != null) put("bank", r.bank)
|
||||
})
|
||||
}
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
@@ -51,6 +55,7 @@ object RecentsCache {
|
||||
put("colorHex", r.colorHex)
|
||||
if (r.imageHash != null) put("imageHash", r.imageHash)
|
||||
put("isProfileImage", r.isProfileImage)
|
||||
if (r.bank != null) put("bank", r.bank)
|
||||
})
|
||||
}
|
||||
context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
@@ -75,7 +80,8 @@ object RecentsCache {
|
||||
subtitle = o.getString("subtitle"),
|
||||
colorHex = o.getString("colorHex"),
|
||||
imageHash = o.optString("imageHash").takeIf { it.isNotBlank() },
|
||||
isProfileImage = o.optBoolean("isProfileImage", false)
|
||||
isProfileImage = o.optBoolean("isProfileImage", false),
|
||||
bank = o.optString("bank").takeIf { it.isNotBlank() }
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
|
||||
@@ -36,14 +36,14 @@ object BmlCardParser {
|
||||
"C8902", "C8907", "C8909", "C8912", "C8992", "C8996", "C8997", "C8982", "C8983" -> "cards/bml/master_islamic.png"
|
||||
"C8101" -> "cards/bml/master_masveriyaa.png"
|
||||
"C8102" -> "cards/bml/master_odiveriyaa.png"
|
||||
"C8010", "C8011" -> "cards/bml/master_platinum.png"
|
||||
"C8010", "C8011", "C8033" -> "cards/bml/master_platinum.png"
|
||||
"C8040", "C8044" -> "cards/bml/master_world.png"
|
||||
"C8030", "C8033" -> "cards/bml/master_business_debit.png"
|
||||
"C8030" -> "cards/bml/master_business_debit.png"
|
||||
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.png"
|
||||
"C1030", "C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
|
||||
"C1090", "C1130", "C1033", "C1133" -> "cards/bml/visa_corporate.png"
|
||||
"C8905", "C8995" -> "cards/bml/visa_credit.png"
|
||||
"C1001", "C1011", "C1082", "C1081", "C1101", "C1111", "C1181", "C1182" -> "cards/bml/visa_debit_generic.png"
|
||||
"C1005", "C1006", "C1089" -> "cards/bml/visa_debit_islamic.png"
|
||||
"C1005", "C1006", "C1030", "C1089" -> "cards/bml/visa_debit_islamic.png"
|
||||
"C1017" -> "cards/bml/visa_infinite.png"
|
||||
"C1009", "C1019", "C1085", "C1086", "C1109", "C1119", "C1185", "C1186" -> "cards/bml/visa_platinum.png"
|
||||
"C1050", "C1051", "C1087", "C1088", "C1150", "C1151", "C1187", "C1188", "C1040", "C1041", "C1047", "C1048", "C1140", "C1141", "C1147", "C1148" -> "cards/bml/visa_student_black.png"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package sh.sar.basedbank.util.mfaisaapi
|
||||
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.AccountListDisplay
|
||||
|
||||
object MfaisaAccountParser {
|
||||
|
||||
fun displayData(account: BankAccount) = AccountListDisplay(
|
||||
name = account.accountBriefName,
|
||||
number = account.accountNumber,
|
||||
typeLabel = account.accountTypeName,
|
||||
balance = "${account.currencyName} ${account.availableBalance}"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package sh.sar.basedbank.util.mfaisaapi
|
||||
|
||||
import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.util.AccountHistoryDisplay
|
||||
|
||||
object MfaisaHistoryParser {
|
||||
|
||||
fun displayData(account: BankAccount) = AccountHistoryDisplay(
|
||||
name = account.accountBriefName,
|
||||
number = account.accountNumber,
|
||||
bankPill = if (account.profileType == "MFAISA_PAYPAL") "PP" else "MF",
|
||||
typeLabel = account.accountTypeName,
|
||||
availableBalance = "${account.currencyName} ${account.availableBalance}",
|
||||
workingBalance = "${account.currencyName} ${account.currentBalance}",
|
||||
blockedBalance = null
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ object MibHistoryParser {
|
||||
return AccountHistoryDisplay(
|
||||
name = account.accountBriefName,
|
||||
number = account.accountNumber,
|
||||
bankPill = null, // MIB has no bank pill
|
||||
bankPill = "MIB",
|
||||
typeLabel = MibAccountParser.productLabel(account.accountTypeName),
|
||||
availableBalance = "${account.currencyName} ${account.availableBalance}",
|
||||
workingBalance = "${account.currencyName} ${account.currentBalance}",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Gray-to-white vertical gradient at the bottom of the receipt, below the zigzag tear. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<gradient
|
||||
android:angle="90"
|
||||
android:startColor="#FFFFFF"
|
||||
android:endColor="#E5E6E7" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- White-to-gray vertical gradient leading into the zigzag tear at the top of the receipt body. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<gradient
|
||||
android:angle="270"
|
||||
android:startColor="#FFFFFF"
|
||||
android:endColor="#E5E6E7" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#44808080" />
|
||||
<corners android:radius="2dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M22,3H7C6.31,3 5.77,3.35 5.41,3.88L0,12L5.41,20.12C5.77,20.65 6.31,21 7,21H22C23.1,21 24,20.1 24,19V5C24,3.9 23.1,3 22,3ZM19,15.59L17.59,17L14,13.41L10.41,17L9,15.59L12.59,12L9,8.41L10.41,7L14,10.59L17.59,7L19,8.41L15.41,12L19,15.59Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- Bell body -->
|
||||
<path
|
||||
android:fillColor="?attr/colorControlNormal"
|
||||
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07-1.63,-5.64-4.5,-6.32V4c0,-0.83-0.67,-1.5-1.5,-1.5s-1.5,0.67-1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"/>
|
||||
|
||||
<!-- Unread notification dot (red) -->
|
||||
<path
|
||||
android:fillColor="#EF5350"
|
||||
android:pathData="M18.5,2A3.5,3.5,0,1,0,18.5,9A3.5,3.5,0,0,0,18.5,2Z"/>
|
||||
|
||||
</vector>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07-1.63,-5.64-4.5,-6.32V4c0,-0.83-0.67,-1.5-1.5,-1.5s-1.5,0.67-1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"/>
|
||||
|
||||
</vector>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
|
||||
<!-- Bell outline (no fill) -->
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.9,2 2,2zM18,16v-5c0,-3.07-1.63,-5.64-4.5,-6.32V4c0,-0.83-0.67,-1.5-1.5,-1.5s-1.5,0.67-1.5,1.5v0.68C7.64,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeWidth="1.5"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M9,16.17L4.83,12L3.41,13.41L9,19L21,7L19.59,5.59Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- Shackle (open - right leg lifted free) -->
|
||||
<path
|
||||
android:fillColor="@android:color/transparent"
|
||||
android:pathData="M8.9,10V6c0,-1.71 1.39,-3.1 3.1,-3.1 1.71,0 3.1,1.39 3.1,3.1"
|
||||
android:strokeColor="@android:color/white"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeWidth="2.2"/>
|
||||
|
||||
<!-- Body + keyhole cutout -->
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M6,8H18c1.1,0 2,0.9 2,2V20c0,1.1-0.9,2-2,2H6c-1.1,0-2,-0.9-2,-2V10c0,-1.1 0.9,-2 2,-2zM12,15c-1.1,0-2,0.9-2,2s0.9,2 2,2 2,-0.9 2,-2-0.9,-2-2,-2z"/>
|
||||
|
||||
</vector>
|
||||
@@ -2,7 +2,7 @@
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item>
|
||||
<shape android:shape="oval">
|
||||
<solid android:color="#E8B547" />
|
||||
<solid android:color="@color/ic_logo_background" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:drawable="@drawable/ic_launcher_foreground" />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user