Compare commits
90 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
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 = 22
|
||||
versionName = "1.0.21"
|
||||
|
||||
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 {
|
||||
text = key
|
||||
textSize = 24f
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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() },
|
||||
accountNumber = accountNumber,
|
||||
accountDisplayName = accountDisplayName,
|
||||
source = "BML_CARD"
|
||||
))
|
||||
}
|
||||
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"
|
||||
)
|
||||
}
|
||||
|
||||
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() }
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,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) }
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
@@ -33,6 +33,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() {
|
||||
@@ -150,6 +152,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 +330,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 bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw, pendingQrAccountNumber)
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
|
||||
)
|
||||
} else {
|
||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||
val qr = PaymvQrParser.parse(raw)
|
||||
if (qr?.accountNumber != null) {
|
||||
Toast.makeText(requireContext(), R.string.card_qr_paymv_unsupported, Toast.LENGTH_SHORT).show()
|
||||
val defaultFrom = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromQr(
|
||||
accountNumber = qr.accountNumber,
|
||||
displayName = qr.merchantName ?: qr.accountNumber,
|
||||
amount = qr.amount,
|
||||
remarks = qr.purpose,
|
||||
fromAccountNumber = defaultFrom
|
||||
)
|
||||
)
|
||||
} else {
|
||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
pendingQrAccountNumber = null
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
@@ -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()
|
||||
requireActivity().title = getString(R.string.nav_dashboard)
|
||||
val isBottom = NavCustomization.getNavMode(requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)) == NavCustomization.NAV_MODE_BOTTOM
|
||||
if (isBottom) {
|
||||
requireActivity().title = getString(R.string.app_name)
|
||||
val size = (28 * resources.displayMetrics.density).toInt()
|
||||
val gap = (8 * resources.displayMetrics.density).toInt()
|
||||
val icon = requireContext().packageManager.getApplicationIcon(requireContext().packageName)
|
||||
val bmp = android.graphics.Bitmap.createBitmap(size + gap, size, android.graphics.Bitmap.Config.ARGB_8888)
|
||||
val canvas = android.graphics.Canvas(bmp)
|
||||
icon.setBounds(0, 0, size, size)
|
||||
icon.draw(canvas)
|
||||
requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar).logo =
|
||||
android.graphics.drawable.BitmapDrawable(resources, bmp)
|
||||
} else {
|
||||
requireActivity().title = getString(R.string.nav_dashboard)
|
||||
}
|
||||
refreshQuickActions()
|
||||
}
|
||||
|
||||
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,14 +263,31 @@ 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
|
||||
navigateTo(navDest, fragment)
|
||||
} else {
|
||||
show(DashboardFragment())
|
||||
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,21 +390,44 @@ 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) {
|
||||
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 {
|
||||
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
|
||||
toggle.isDrawerIndicatorEnabled = true
|
||||
toggle.syncState()
|
||||
binding.bottomNavigation.visibility = View.GONE
|
||||
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()
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 bmlUrl = sh.sar.basedbank.util.PaymvQrParser.extractBmlGatewayUrl(text)
|
||||
if (text.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||
navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: text, 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() {
|
||||
@@ -124,6 +92,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 bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, 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
|
||||
@@ -125,7 +170,7 @@ class SettingsAppearanceFragment : Fragment() {
|
||||
// Accent color
|
||||
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
||||
binding.accentToggle.check(when (savedPreset) {
|
||||
ThemeHelper.PRESET_RED -> R.id.btnAccentOrange
|
||||
ThemeHelper.PRESET_RED -> R.id.btnAccentOrange
|
||||
ThemeHelper.PRESET_GREEN -> R.id.btnAccentGreen
|
||||
ThemeHelper.PRESET_CUSTOM -> R.id.btnAccentCustom
|
||||
else -> R.id.btnAccentBlue
|
||||
@@ -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))
|
||||
}
|
||||
@@ -358,8 +368,9 @@ class SettingsLoginsFragment : Fragment() {
|
||||
val mibLoginIds = store.getMibLoginIds()
|
||||
val bmlLoginIds = store.getBmlLoginIds()
|
||||
val fahipayLoginIds = store.getFahipayLoginIds()
|
||||
val mfaisaLoginIds = store.getMfaisaLoginIds()
|
||||
|
||||
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
|
||||
binding.tvLoginsTitle.visibility = if (mibLoginIds.isNotEmpty() || bmlLoginIds.isNotEmpty() || fahipayLoginIds.isNotEmpty() || mfaisaLoginIds.isNotEmpty()) View.VISIBLE else View.GONE
|
||||
|
||||
for (loginId in mibLoginIds) {
|
||||
val profile = store.loadMibUserProfile(loginId)
|
||||
@@ -386,6 +397,14 @@ class SettingsLoginsFragment : Fragment() {
|
||||
showFahipayLoginDetails(store, loginId, profile)
|
||||
}
|
||||
}
|
||||
|
||||
for (loginId in mfaisaLoginIds) {
|
||||
val profile = store.loadMfaisaUserProfile(loginId)
|
||||
val displayName = profile?.name?.takeIf { it.isNotBlank() } ?: getString(R.string.ooredoo_name)
|
||||
addLoginRow(container, R.drawable.ooredoo_logo, displayName) {
|
||||
showMfaisaLoginDetails(store, loginId, profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun addLoginRow(container: LinearLayout, logoRes: Int, displayName: String, onClick: () -> Unit) {
|
||||
@@ -1055,6 +1074,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,
|
||||
)
|
||||
|
||||
@@ -26,6 +26,8 @@ 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 +38,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 +69,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,28 +97,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") {
|
||||
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
|
||||
bindMib(binding)
|
||||
_receiptCard = binding.receiptCard
|
||||
binding.root
|
||||
} else {
|
||||
val binding = FragmentReceiptBmlBinding.inflate(inflater, container, false)
|
||||
bindBml(binding)
|
||||
_receiptCard = binding.receiptCard
|
||||
binding.root
|
||||
return when (bank) {
|
||||
"MIB" -> {
|
||||
val binding = FragmentReceiptMibBinding.inflate(inflater, container, false)
|
||||
bindMib(binding)
|
||||
_receiptCard = binding.receiptCard
|
||||
binding.root
|
||||
}
|
||||
"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() }
|
||||
|
||||
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.btnDone).setOnClickListener {
|
||||
parentFragmentManager.popBackStack()
|
||||
}
|
||||
@@ -222,6 +270,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() {
|
||||
@@ -338,14 +429,22 @@ class TransferReceiptFragment : Fragment() {
|
||||
setBackgroundColor(Color.BLACK)
|
||||
}
|
||||
|
||||
val cardView = if (bank == "MIB") {
|
||||
val binding = FragmentReceiptMibBinding.inflate(layoutInflater)
|
||||
bindMib(binding)
|
||||
binding.receiptCard
|
||||
} else {
|
||||
val binding = FragmentReceiptBmlBinding.inflate(layoutInflater)
|
||||
bindBml(binding)
|
||||
binding.receiptCard
|
||||
val cardView = when (bank) {
|
||||
"MIB" -> {
|
||||
val binding = FragmentReceiptMibBinding.inflate(layoutInflater)
|
||||
bindMib(binding)
|
||||
binding.receiptCard
|
||||
}
|
||||
"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() }
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
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.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.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. Lives alongside [sh.sar.basedbank.ui.home.TransferFragment]
|
||||
* which dispatches to it whenever [BankAccount.bank] == "MFAISA" is the selected source.
|
||||
*
|
||||
* 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: Fragment,
|
||||
private val binding: FragmentTransferBinding,
|
||||
private val viewModel: HomeViewModel,
|
||||
/** 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()
|
||||
|
||||
/** Set to the resolved recipient after a successful search; null otherwise. */
|
||||
var recipient: MfaisaTransferClient.Recipient? = 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 = currentSource() ?: 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 = currentSource() ?: 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)
|
||||
}
|
||||
}
|
||||
|
||||
/** Called when the source account changes away from M-Faisa (or the view tears down). */
|
||||
fun clearState() {
|
||||
recipient = null
|
||||
lookupInFlight = false
|
||||
}
|
||||
|
||||
// ─── Internal ────────────────────────────────────────────────────────────
|
||||
|
||||
private fun currentSource(): BankAccount? =
|
||||
viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" && it.accountNumber == sourceAccountNumberFromCard() }
|
||||
?: viewModel.accounts.value?.firstOrNull { it.bank == "MFAISA" } // fallback if from-card field isn't easily readable
|
||||
|
||||
private fun sourceAccountNumberFromCard(): String =
|
||||
binding.tvFromAccountNumber.text?.toString().orEmpty()
|
||||
|
||||
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 tf = fragment as? TransferFragment ?: return
|
||||
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 = tf.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: Exception) {
|
||||
val msg = when {
|
||||
e is java.io.IOException -> ctx.getString(R.string.connectivity_no_internet)
|
||||
!e.message.isNullOrBlank() -> e.message!!
|
||||
else -> "Transfer failed"
|
||||
}
|
||||
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
text = key
|
||||
textSize = 24f
|
||||
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) ─────
|
||||
|
||||
@@ -460,6 +461,110 @@ 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()
|
||||
|
||||
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 +732,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,17 +38,39 @@ 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
|
||||
isFahipay -> fahipayTotal < 0 || fahipayNextStart < fahipayTotal
|
||||
isMib -> mibTotalCount < 0 || mibNextStart <= mibTotalCount
|
||||
isBmlCard -> cardMonthOffset < 3
|
||||
else -> bmlTotalPages < 0 || bmlNextPage <= bmlTotalPages
|
||||
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
|
||||
}
|
||||
|
||||
suspend fun fetchNextPage(app: BasedBankApp, pageSize: Int = 10): List<BankTransaction> = when {
|
||||
@@ -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,24 @@ data class PaymvQrData(
|
||||
|
||||
object PaymvQrParser {
|
||||
|
||||
/**
|
||||
* Returns the BML gateway URL if [raw] is or contains one, otherwise null.
|
||||
* Handles both plain URL QRs and combined EMV QRs (e.g. Fahipay+BML card QR).
|
||||
* For combined EMV QRs the URL is parsed from TLV (root tag 35 → sub-tag 20 → sub-sub-tag 01)
|
||||
* rather than via regex, to avoid greedily consuming subsequent EMV tag bytes.
|
||||
*/
|
||||
fun extractBmlGatewayUrl(raw: String): String? {
|
||||
if (raw.startsWith("https://pay.bml.com.mv/app/")) return raw
|
||||
return try {
|
||||
val root = parseTlv(raw)
|
||||
val bmlMerchantInfo = root["35"]?.let { parseTlv(it) } ?: return null
|
||||
val inner = bmlMerchantInfo["20"]?.let { parseTlv(it) } ?: return null
|
||||
inner["01"]?.takeIf { it.startsWith("https://pay.bml.com.mv/app/") }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun parse(raw: String): PaymvQrData? {
|
||||
return try {
|
||||
val root = parseTlv(raw)
|
||||
@@ -23,7 +41,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) {
|
||||
|
||||
@@ -40,10 +40,10 @@ object BmlCardParser {
|
||||
"C8040", "C8044" -> "cards/bml/master_world.png"
|
||||
"C8030", "C8033" -> "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" />
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Green check-in-circle used on the m-faisaa receipt next to the total amount. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="25dp"
|
||||
android:height="25dp"
|
||||
android:viewportWidth="25"
|
||||
android:viewportHeight="25">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M22.917,11.541V12.5C22.916,14.746 22.188,16.932 20.843,18.731C19.498,20.53 17.608,21.846 15.454,22.483C13.3,23.12 10.997,23.043 8.89,22.265C6.783,21.486 4.984,20.048 3.762,18.163C2.539,16.279 1.958,14.05 2.106,11.808C2.254,9.567 3.122,7.433 4.582,5.726C6.041,4.018 8.013,2.828 10.205,2.333C12.396,1.838 14.688,2.065 16.74,2.979M22.917,4.166L12.5,14.594L9.375,11.469"
|
||||
android:strokeColor="#B0E020"
|
||||
android:strokeWidth="1.8"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
@@ -2,9 +2,41 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
android:viewportWidth="48"
|
||||
android:viewportHeight="48">
|
||||
|
||||
<!-- Phone outline -->
|
||||
<path
|
||||
android:fillColor="?attr/colorOnSurface"
|
||||
android:pathData="M20,2L4,2C2.9,2 2,2.9 2,4l0,16c0,1.1 0.9,2 2,2l16,0c1.1,0 2,-0.9 2,-2L22,4C22,2.9 21.1,2 20,2zM13,18l-2,0 0,-1c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5l-2,0c0,-1.65 -1.35,-3 -3,-3s-3,1.35 -3,3 1.35,3 3,3l0,-2 2,3zM19,12l-2,0c0,-2.76 -2.24,-5 -5,-5l0,-2C15.87,5 19,8.13 19,12z"/>
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="?attr/colorOnSurface"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M6.08,8.6 L20.45,8.6 A1.58,1.58,0,0,1,22.03,10.18 L22.03,37.81 A1.58,1.58,0,0,1,20.45,39.39 L6.08,39.39 A1.58,1.58,0,0,1,4.5,37.81 L4.5,10.18 A1.58,1.58,0,0,1,6.08,8.6 Z"/>
|
||||
|
||||
<!-- Top notch line -->
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="?attr/colorOnSurface"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M4.5,12.55 L22.03,12.55"/>
|
||||
|
||||
<!-- Bottom notch line -->
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="?attr/colorOnSurface"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M4.5,35.45 L22.03,35.45"/>
|
||||
|
||||
<!-- NFC waves (outer, mid, inner) -->
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="?attr/colorOnSurface"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M36.07,9.29 a18.28,18.28,0,0,1,0,29.42 m-4,-24.16 a11.84,11.84,0,0,1,5,9.45 11.84,11.84,0,0,1,-5,9.45 m-3.68,-14 a5.67,5.67,0,0,1,0,9.1"/>
|
||||
|
||||
</vector>
|
||||
|
||||
@@ -4,13 +4,47 @@
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
|
||||
<group
|
||||
android:translateX="30"
|
||||
android:translateY="30"
|
||||
android:scaleX="2"
|
||||
android:scaleY="2">
|
||||
android:scaleX="1.0"
|
||||
android:scaleY="1.0">
|
||||
|
||||
<!-- Phone outline -->
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M20,4H4C2.89,4 2.01,4.89 2.01,6L2,18c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2V6C22,4.89 21.11,4 20,4zM20,18H4v-6h16V18zM20,8H4V6h16V8z" />
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="2.5"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M6.08,8.6 L20.45,8.6 A1.58,1.58,0,0,1,22.03,10.18 L22.03,37.81 A1.58,1.58,0,0,1,20.45,39.39 L6.08,39.39 A1.58,1.58,0,0,1,4.5,37.81 L4.5,10.18 A1.58,1.58,0,0,1,6.08,8.6 Z"/>
|
||||
|
||||
<!-- Top notch line -->
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="2.5"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M4.5,12.55 L22.03,12.55"/>
|
||||
|
||||
<!-- Bottom notch line -->
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="2.5"
|
||||
android:strokeLineCap="round"
|
||||
android:pathData="M4.5,35.45 L22.03,35.45"/>
|
||||
|
||||
<!-- NFC waves -->
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#FFFFFF"
|
||||
android:strokeWidth="2.5"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"
|
||||
android:pathData="M36.07,9.29 a18.28,18.28,0,0,1,0,29.42 m-4,-24.16 a11.84,11.84,0,0,1,5,9.45 11.84,11.84,0,0,1,-5,9.45 m-3.68,-14 a5.67,5.67,0,0,1,0,9.1"/>
|
||||
|
||||
</group>
|
||||
|
||||
</vector>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
M-Faisaa logo with text. Recolored from the decompiled original (white fill,
|
||||
intended to be tinted by parent) to the brand red so it can be used directly.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="94dp"
|
||||
android:height="123dp"
|
||||
android:viewportWidth="94"
|
||||
android:viewportHeight="123">
|
||||
<group>
|
||||
<clip-path android:pathData="M0.09,0h93.82v122.17h-93.82z" />
|
||||
<path android:fillColor="#ED1C24" android:pathData="M62.06,49.1H61.96C61.75,49.09 61.54,49.04 61.34,48.94C61.15,48.85 60.97,48.72 60.83,48.56C60.69,48.39 60.58,48.21 60.51,48C60.43,47.8 60.4,47.58 60.42,47.37C60.98,40.13 62.75,33.04 65.64,26.38C65.89,25.8 65.9,25.15 65.67,24.57C65.44,23.98 64.99,23.51 64.41,23.25L55.36,19.21C54.84,18.97 54.24,18.93 53.68,19.1C53.13,19.27 52.66,19.63 52.35,20.13C50.47,23.4 49.18,26.99 48.55,30.72C48.52,30.93 48.45,31.14 48.34,31.32C48.24,31.51 48.09,31.67 47.92,31.8C47.75,31.93 47.55,32.03 47.34,32.08C47.13,32.14 46.92,32.15 46.7,32.12C46.49,32.09 46.28,32.02 46.1,31.91C45.91,31.8 45.75,31.66 45.62,31.49C45.49,31.32 45.39,31.12 45.34,30.91C45.28,30.7 45.27,30.49 45.3,30.27C45.97,26.1 47.4,22.09 49.53,18.44C50.25,17.26 51.37,16.38 52.7,15.97C54.02,15.56 55.44,15.64 56.7,16.21L65.75,20.24C67.11,20.86 68.17,21.98 68.72,23.37C69.26,24.76 69.24,26.3 68.67,27.68C65.91,33.98 64.23,40.7 63.7,47.55C63.67,47.97 63.49,48.37 63.18,48.65C62.88,48.94 62.47,49.1 62.05,49.1" />
|
||||
<path android:fillColor="#ED1C24" android:pathData="M93.55,15.03C93.24,14.09 92.71,13.24 92.02,12.53C91.32,11.83 90.48,11.29 89.54,10.96L60.13,0.39C58.82,-0.09 57.39,-0.13 56.05,0.26C54.7,0.65 53.53,1.47 52.68,2.58C51.68,3.88 50.71,5.34 49.76,6.83C49.68,6.93 49.61,7.05 49.55,7.17C49.16,7.78 48.8,8.4 48.43,9.02H30.38C27.88,9.02 25.48,10.01 23.71,11.78C21.94,13.55 20.95,15.95 20.95,18.45V86.23C20.95,88.73 21.94,91.13 23.71,92.9C25.48,94.67 27.88,95.66 30.38,95.66H61.16C62.4,95.66 63.63,95.42 64.78,94.95C65.92,94.47 66.96,93.78 67.84,92.9C68.72,92.03 69.42,90.99 69.89,89.84C70.37,88.7 70.61,87.47 70.61,86.23V79.68C74.35,51.89 87.38,29.23 92.87,20.7C93.4,19.87 93.74,18.93 93.86,17.94C93.98,16.96 93.88,15.97 93.55,15.03L93.55,15.03ZM47.98,87.18H43.57C43.06,87.18 42.57,86.97 42.21,86.61C41.85,86.25 41.64,85.76 41.64,85.25C41.64,84.74 41.85,84.25 42.21,83.89C42.57,83.53 43.06,83.33 43.57,83.33H47.98C48.49,83.33 48.98,83.53 49.34,83.89C49.7,84.25 49.9,84.74 49.9,85.25C49.9,85.76 49.7,86.25 49.34,86.61C48.98,86.97 48.49,87.18 47.98,87.18ZM90.1,18.91C83.54,29.1 72.45,49.27 68.09,74.41C68.09,74.41 67.87,75.75 67.81,76.14C67.68,76.77 67.33,77.34 66.82,77.74C66.32,78.14 65.68,78.35 65.04,78.33H30.69C29.71,78.33 28.77,77.94 28.07,77.25C27.38,76.55 26.98,75.61 26.98,74.63V24.95C26.98,23.96 27.37,23.02 28.07,22.32C28.76,21.63 29.71,21.23 30.69,21.23H42.81C43.35,21.23 45.35,21.58 46.85,18.77C47.8,17.01 49.55,13.46 51.72,9.9L74.82,18.69C75.25,18.85 75.64,19.11 75.97,19.44C76.3,19.77 76.55,20.16 76.71,20.6C76.88,21.03 76.96,21.49 76.93,21.95C76.91,22.41 76.78,22.85 76.57,23.26C72.32,31.5 65.05,48.07 64.24,66.62C64.23,66.83 64.26,67.05 64.33,67.25C64.4,67.45 64.52,67.64 64.66,67.8C64.8,67.96 64.98,68.09 65.17,68.18C65.37,68.27 65.58,68.33 65.8,68.34H65.87C66.3,68.34 66.7,68.17 67.01,67.88C67.32,67.59 67.5,67.19 67.52,66.76C68.3,48.85 75.35,32.76 79.49,24.78C79.91,23.96 80.16,23.06 80.21,22.13C80.26,21.21 80.12,20.28 79.79,19.42C79.46,18.56 78.95,17.77 78.29,17.12C77.64,16.46 76.85,15.95 75.99,15.63L53.54,7.06C54.11,6.2 54.72,5.36 55.3,4.56C55.72,4.01 56.31,3.61 56.98,3.42C57.65,3.22 58.36,3.25 59.01,3.49L88.44,14.05C88.9,14.22 89.32,14.49 89.67,14.84C90.02,15.19 90.28,15.61 90.44,16.08C90.6,16.55 90.65,17.05 90.59,17.54C90.53,18.03 90.36,18.5 90.09,18.91" />
|
||||
<path android:fillColor="#ED1C24" android:pathData="M16.82,116.24V121.41C16.83,121.49 16.82,121.56 16.8,121.64C16.77,121.71 16.73,121.77 16.68,121.83C16.62,121.88 16.56,121.92 16.49,121.95C16.42,121.97 16.34,121.98 16.26,121.97H14.26C14.18,121.98 14.11,121.97 14.03,121.95C13.96,121.92 13.9,121.88 13.84,121.83C13.79,121.77 13.75,121.71 13.72,121.64C13.7,121.56 13.69,121.49 13.7,121.41V116.02C13.7,115.53 13.51,115.07 13.16,114.72C12.82,114.38 12.35,114.18 11.86,114.18C11.37,114.18 10.91,114.38 10.56,114.72C10.22,115.07 10.02,115.53 10.02,116.02V121.41C10.03,121.49 10.02,121.56 10,121.64C9.97,121.71 9.93,121.77 9.88,121.83C9.82,121.88 9.76,121.92 9.69,121.95C9.62,121.97 9.54,121.98 9.46,121.97H7.45C7.38,121.98 7.3,121.97 7.23,121.95C7.16,121.92 7.09,121.88 7.04,121.83C6.98,121.77 6.94,121.71 6.92,121.64C6.89,121.57 6.89,121.49 6.89,121.41V116.02C6.89,115.78 6.85,115.54 6.75,115.32C6.66,115.09 6.53,114.89 6.36,114.72C6.18,114.55 5.98,114.41 5.76,114.32C5.54,114.23 5.3,114.18 5.05,114.18C4.81,114.18 4.57,114.23 4.35,114.32C4.13,114.41 3.92,114.55 3.75,114.72C3.58,114.89 3.45,115.09 3.35,115.32C3.26,115.54 3.21,115.78 3.21,116.02V121.41C3.22,121.49 3.22,121.56 3.19,121.64C3.17,121.71 3.13,121.77 3.07,121.83C3.02,121.88 2.95,121.92 2.88,121.95C2.81,121.97 2.73,121.98 2.66,121.97H0.65C0.57,121.98 0.5,121.97 0.42,121.95C0.35,121.92 0.29,121.88 0.23,121.83C0.18,121.77 0.14,121.71 0.11,121.64C0.09,121.56 0.08,121.49 0.09,121.41V116.24C0.05,115.58 0.14,114.91 0.38,114.29C0.61,113.66 0.98,113.1 1.45,112.62C1.92,112.15 2.49,111.79 3.11,111.55C3.73,111.32 4.4,111.22 5.06,111.27C5.7,111.23 6.34,111.33 6.93,111.58C7.52,111.82 8.04,112.2 8.46,112.68C8.88,112.2 9.41,111.82 10,111.58C10.59,111.33 11.23,111.23 11.86,111.27C12.53,111.22 13.19,111.32 13.82,111.55C14.44,111.79 15.01,112.15 15.48,112.63C15.95,113.1 16.31,113.66 16.54,114.29C16.77,114.91 16.87,115.58 16.82,116.24Z" />
|
||||
<path android:fillColor="#ED1C24" android:pathData="M18.89,115.8C18.9,115.63 18.94,115.46 19.01,115.3C19.09,115.14 19.2,115 19.33,114.89C19.46,114.77 19.62,114.69 19.79,114.64C19.95,114.58 20.13,114.57 20.3,114.59H23.09C23.26,114.56 23.44,114.57 23.61,114.62C23.78,114.67 23.94,114.75 24.08,114.87C24.21,114.99 24.32,115.13 24.39,115.29C24.47,115.45 24.5,115.63 24.5,115.8C24.5,115.98 24.47,116.15 24.39,116.32C24.32,116.48 24.21,116.62 24.08,116.74C23.94,116.85 23.78,116.94 23.61,116.98C23.44,117.03 23.26,117.04 23.09,117.02H20.3C20.13,117.04 19.95,117.02 19.79,116.97C19.62,116.92 19.46,116.83 19.33,116.72C19.2,116.61 19.09,116.47 19.01,116.31C18.94,116.15 18.9,115.98 18.89,115.8Z" />
|
||||
<path android:fillColor="#ED1C24" android:pathData="M30.57,111.23V113.92H34.85C35.05,113.89 35.27,113.92 35.47,113.98C35.66,114.05 35.85,114.15 36,114.29C36.16,114.43 36.29,114.61 36.37,114.8C36.46,114.99 36.5,115.2 36.5,115.41C36.5,115.62 36.46,115.82 36.37,116.02C36.29,116.21 36.16,116.38 36,116.52C35.85,116.66 35.66,116.77 35.47,116.83C35.27,116.9 35.05,116.92 34.85,116.9H30.57V121.42C30.58,121.49 30.57,121.57 30.54,121.64C30.52,121.71 30.48,121.78 30.43,121.83C30.37,121.88 30.31,121.92 30.24,121.95C30.16,121.97 30.09,121.98 30.01,121.97H27.92C27.85,121.98 27.77,121.97 27.7,121.95C27.63,121.92 27.56,121.88 27.51,121.83C27.45,121.78 27.41,121.71 27.39,121.64C27.36,121.57 27.36,121.49 27.37,121.42V109.8C27.35,109.59 27.38,109.38 27.46,109.18C27.53,108.99 27.65,108.81 27.8,108.66C27.95,108.52 28.13,108.4 28.32,108.33C28.52,108.26 28.73,108.23 28.94,108.24H35.74C35.95,108.22 36.16,108.25 36.36,108.31C36.56,108.38 36.74,108.48 36.9,108.62C37.05,108.76 37.18,108.93 37.26,109.13C37.35,109.32 37.39,109.53 37.39,109.74C37.39,109.95 37.35,110.15 37.26,110.35C37.18,110.54 37.05,110.71 36.9,110.85C36.74,110.99 36.56,111.1 36.36,111.16C36.16,111.23 35.95,111.25 35.74,111.23H30.57Z" />
|
||||
<path android:fillColor="#ED1C24" android:pathData="M49.49,113.07L49.47,121.41C49.48,121.49 49.47,121.56 49.44,121.63C49.42,121.71 49.38,121.77 49.32,121.82C49.27,121.88 49.21,121.92 49.13,121.94C49.06,121.97 48.99,121.98 48.91,121.97H47C46.92,121.98 46.85,121.97 46.78,121.94C46.7,121.92 46.64,121.88 46.59,121.82C46.53,121.77 46.49,121.71 46.47,121.63C46.44,121.56 46.43,121.49 46.44,121.41V121.01C46.07,121.38 45.63,121.67 45.15,121.87C44.66,122.07 44.14,122.17 43.62,122.16C42.19,122.14 40.83,121.55 39.83,120.53C38.83,119.51 38.27,118.14 38.27,116.71C38.27,115.29 38.83,113.92 39.83,112.9C40.83,111.88 42.19,111.29 43.62,111.26C44.7,111.23 45.76,111.62 46.56,112.36C46.64,112.04 46.83,111.77 47.08,111.57C47.34,111.37 47.65,111.26 47.98,111.26C48.87,111.27 49.49,112 49.49,113.07ZM46.4,116.7C46.4,115.99 46.12,115.31 45.62,114.81C45.12,114.31 44.45,114.03 43.74,114.03C43.03,114.03 42.35,114.31 41.85,114.81C41.35,115.31 41.07,115.99 41.07,116.7C41.07,117.4 41.35,118.08 41.85,118.58C42.35,119.08 43.03,119.36 43.74,119.36C44.45,119.36 45.12,119.08 45.62,118.58C46.12,118.08 46.4,117.4 46.4,116.7Z" />
|
||||
<path android:fillColor="#ED1C24" android:pathData="M51.96,108.54C51.96,108.2 52.06,107.87 52.25,107.59C52.44,107.31 52.71,107.09 53.02,106.96C53.34,106.83 53.68,106.8 54.01,106.86C54.35,106.93 54.65,107.1 54.89,107.33C55.13,107.57 55.3,107.88 55.36,108.21C55.43,108.55 55.4,108.89 55.27,109.21C55.14,109.52 54.92,109.79 54.64,109.98C54.36,110.17 54.03,110.27 53.69,110.27C53.46,110.27 53.23,110.23 53.02,110.14C52.81,110.06 52.62,109.93 52.46,109.77C52.3,109.61 52.17,109.42 52.09,109.21C52,109 51.96,108.77 51.96,108.54ZM55.26,121.41C55.26,121.49 55.26,121.57 55.23,121.64C55.21,121.71 55.17,121.77 55.11,121.83C55.06,121.88 54.99,121.92 54.92,121.95C54.85,121.97 54.77,121.98 54.7,121.97H52.69C52.61,121.98 52.54,121.97 52.47,121.95C52.39,121.92 52.33,121.88 52.28,121.83C52.22,121.77 52.18,121.71 52.16,121.64C52.13,121.57 52.12,121.49 52.13,121.41V112.66C52.13,112.46 52.18,112.26 52.26,112.08C52.35,111.9 52.47,111.74 52.62,111.61C52.77,111.48 52.94,111.38 53.13,111.32C53.33,111.26 53.53,111.24 53.72,111.27C53.92,111.25 54.12,111.27 54.3,111.33C54.49,111.39 54.66,111.49 54.8,111.62C54.95,111.75 55.06,111.91 55.14,112.09C55.22,112.27 55.26,112.46 55.26,112.66L55.26,121.41Z" />
|
||||
<path android:fillColor="#ED1C24" android:pathData="M61.72,119.66C62.66,119.66 63.11,119.48 63.11,119.11C63.11,119 63.08,118.9 63.03,118.82C62.98,118.73 62.9,118.66 62.81,118.61C62.22,118.28 61.61,118 60.96,117.79L59.97,117.39C59.35,117.19 58.82,116.79 58.45,116.26C58.08,115.73 57.89,115.09 57.92,114.45C57.98,112.56 59.57,111.26 62.44,111.26C63.46,111.24 64.46,111.46 65.38,111.9C65.63,112.01 65.84,112.19 65.99,112.42C66.13,112.65 66.21,112.92 66.2,113.19C66.18,113.53 66.03,113.84 65.79,114.07C65.54,114.29 65.22,114.41 64.88,114.4C64.51,114.36 64.14,114.23 63.81,114.05C63.28,113.85 62.72,113.75 62.16,113.77C61.36,113.77 61.07,114.13 61.07,114.37C61.06,114.45 61.08,114.53 61.12,114.61C61.15,114.68 61.2,114.75 61.27,114.81C61.98,115.26 62.75,115.61 63.55,115.86L64.41,116.18C64.97,116.35 65.46,116.71 65.79,117.19C66.12,117.68 66.28,118.26 66.24,118.84C66.18,120.67 64.55,122.17 61.45,122.17C60.33,122.18 59.23,121.96 58.2,121.53C57.95,121.42 57.74,121.24 57.6,121.01C57.45,120.78 57.38,120.51 57.39,120.24C57.4,119.9 57.55,119.59 57.8,119.36C58.04,119.14 58.37,119.02 58.7,119.03C59.08,119.08 59.45,119.2 59.78,119.39C60.41,119.58 61.06,119.67 61.72,119.66Z" />
|
||||
<path android:fillColor="#ED1C24" android:pathData="M78.81,113.07L78.79,121.41C78.8,121.49 78.79,121.56 78.77,121.63C78.74,121.71 78.7,121.77 78.65,121.82C78.59,121.88 78.53,121.92 78.46,121.94C78.39,121.97 78.31,121.98 78.23,121.97H76.32C76.25,121.98 76.17,121.97 76.1,121.94C76.03,121.92 75.96,121.88 75.91,121.82C75.86,121.77 75.82,121.71 75.79,121.63C75.77,121.56 75.76,121.49 75.77,121.41V121.01C75.4,121.38 74.96,121.67 74.47,121.87C73.99,122.07 73.47,122.17 72.94,122.16C71.51,122.14 70.16,121.55 69.16,120.53C68.16,119.51 67.6,118.14 67.6,116.71C67.6,115.29 68.16,113.92 69.16,112.9C70.16,111.88 71.51,111.29 72.94,111.26C74.03,111.23 75.08,111.62 75.89,112.36C75.97,112.04 76.15,111.77 76.4,111.57C76.66,111.37 76.98,111.26 77.3,111.26C78.2,111.27 78.81,112 78.81,113.07ZM75.73,116.7C75.71,116 75.43,115.33 74.93,114.84C74.43,114.36 73.76,114.08 73.06,114.08C72.36,114.08 71.69,114.36 71.2,114.84C70.7,115.33 70.41,116 70.4,116.7C70.39,117.05 70.45,117.4 70.59,117.73C70.72,118.06 70.91,118.36 71.16,118.61C71.41,118.87 71.7,119.07 72.03,119.21C72.36,119.34 72.71,119.41 73.06,119.41C73.42,119.41 73.77,119.34 74.09,119.21C74.42,119.07 74.72,118.87 74.97,118.61C75.21,118.36 75.41,118.06 75.54,117.73C75.67,117.4 75.74,117.05 75.73,116.7Z" />
|
||||
<path android:fillColor="#ED1C24" android:pathData="M91.9,113.07L91.88,121.41C91.89,121.49 91.88,121.56 91.86,121.63C91.83,121.71 91.79,121.77 91.74,121.82C91.68,121.88 91.62,121.92 91.55,121.94C91.48,121.97 91.4,121.98 91.32,121.97H89.41C89.34,121.98 89.26,121.97 89.19,121.94C89.12,121.92 89.05,121.88 89,121.82C88.95,121.77 88.91,121.71 88.88,121.63C88.86,121.56 88.85,121.49 88.86,121.41V121.01C88.49,121.38 88.05,121.67 87.56,121.87C87.08,122.07 86.56,122.17 86.03,122.16C84.6,122.14 83.25,121.55 82.25,120.53C81.25,119.51 80.69,118.14 80.69,116.71C80.69,115.29 81.25,113.92 82.25,112.9C83.25,111.88 84.6,111.29 86.03,111.26C87.12,111.23 88.17,111.62 88.98,112.36C89.06,112.04 89.24,111.77 89.49,111.57C89.75,111.37 90.07,111.26 90.39,111.26C91.29,111.27 91.9,112 91.9,113.07ZM88.82,116.7C88.8,116 88.52,115.33 88.02,114.84C87.52,114.36 86.85,114.08 86.15,114.08C85.45,114.08 84.78,114.36 84.28,114.84C83.79,115.33 83.5,116 83.49,116.7C83.48,117.05 83.54,117.4 83.67,117.73C83.8,118.06 84,118.36 84.25,118.61C84.5,118.87 84.79,119.07 85.12,119.21C85.45,119.34 85.8,119.41 86.15,119.41C86.51,119.41 86.86,119.34 87.18,119.21C87.51,119.07 87.81,118.87 88.06,118.61C88.3,118.36 88.5,118.06 88.63,117.73C88.76,117.4 88.82,117.05 88.82,116.7Z" />
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="26dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="26">
|
||||
|
||||
<path
|
||||
android:fillColor="#ED1C24"
|
||||
android:pathData="M20.111 2.777c-1.766 0-3.174 1.437-3.174 3.195a3.18 3.18 0 0 0 3.174 3.194c1.766 0 3.174-1.437 3.174-3.194a3.168 3.168 0 0 0-3.174-3.195Z" />
|
||||
|
||||
<path
|
||||
android:fillColor="#ED1C24"
|
||||
android:fillType="evenOdd"
|
||||
android:pathData="M18.286 16.175c0-4.912-3.968-8.885-8.829-8.885-4.88 0-8.849 3.993-8.849 8.885 0 4.912 3.968 8.885 8.849 8.885 4.88 0.02 8.829-3.973 8.829-8.885Zm-8.829 4.871c-2.678 0-4.841-2.196-4.841-4.871 0-2.696 2.182-4.872 4.841-4.872 2.678 0 4.84 2.196 4.84 4.872 0 2.695-2.162 4.871-4.84 4.871Z" />
|
||||
</vector>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user