Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
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
|
@@ -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
|
||||
|
||||
@@ -18,3 +18,4 @@ docs/bmlapi/tmp
|
||||
docs/fahipayapi/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-28T18:41:19.777722821Z">
|
||||
<DropdownSelection timestamp="2026-06-13T17:53:06.478193524Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=4254e2f" />
|
||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=a703e092" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
|
||||
@@ -20,12 +20,19 @@ A native Android client for Maldivian banking services. It is a pure client: req
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
+16
-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 = 11
|
||||
versionName = "1.0.12"
|
||||
versionCode = 20
|
||||
versionName = "1.0.20"
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_logo_background">#CC0000</color>
|
||||
</resources>
|
||||
@@ -8,6 +8,11 @@
|
||||
<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" />
|
||||
|
||||
@@ -68,6 +73,11 @@
|
||||
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"
|
||||
@@ -81,6 +91,20 @@
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
@@ -279,10 +288,12 @@ 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)
|
||||
|
||||
@@ -21,6 +43,17 @@ class MainActivity : AppCompatActivity() {
|
||||
val store = CredentialStore(this)
|
||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
||||
|
||||
// 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
|
||||
@@ -46,6 +79,7 @@ class MainActivity : AppCompatActivity() {
|
||||
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() }
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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,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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class BmlHostCardEmulatorService : HostApduService() {
|
||||
INS_SELECT -> handleSelect(apdu)
|
||||
INS_GPO -> handleGpo()
|
||||
INS_READ -> handleReadRecord()
|
||||
else -> SW_UNKNOWN_ERROR
|
||||
else -> SW_INS_NOT_SUPPORTED
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,8 +56,7 @@ class BmlHostCardEmulatorService : HostApduService() {
|
||||
}
|
||||
|
||||
private fun launchPromptActivity() {
|
||||
val intent = Intent("sh.sar.basedbank.TAP_TO_PAY").apply {
|
||||
setPackage(applicationContext.packageName)
|
||||
val intent = Intent(applicationContext, BmlTapToPayActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||
}
|
||||
startActivity(intent)
|
||||
@@ -141,7 +140,7 @@ class BmlHostCardEmulatorService : HostApduService() {
|
||||
val data: ByteArray?
|
||||
|
||||
init {
|
||||
if (raw.size < 4) {
|
||||
if (raw.size < 5) {
|
||||
isError = true; ins = -1; data = null
|
||||
} else {
|
||||
isError = false
|
||||
@@ -166,8 +165,9 @@ class BmlHostCardEmulatorService : HostApduService() {
|
||||
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 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
|
||||
|
||||
@@ -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,23 @@ class AccountHistoryFragment : Fragment() {
|
||||
}
|
||||
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
||||
|
||||
// Show default account toggle only for non-card accounts
|
||||
val isCard = AccountListParser.from(account)?.isCard ?: false
|
||||
if (!isCard) {
|
||||
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 +139,7 @@ class AccountHistoryFragment : Fragment() {
|
||||
}
|
||||
(activity as? HomeActivity)?.setRefreshing(true)
|
||||
loadNextPage()
|
||||
loadPendingTransactions()
|
||||
|
||||
binding.swipeRefresh.setOnRefreshListener {
|
||||
if (isLoading) {
|
||||
@@ -131,7 +152,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 +186,7 @@ class AccountHistoryFragment : Fragment() {
|
||||
binding.emptyView.visibility = View.GONE
|
||||
}
|
||||
loadNextPage()
|
||||
loadPendingTransactions()
|
||||
}
|
||||
|
||||
private fun loadNextPage() {
|
||||
@@ -201,6 +228,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 +260,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
|
||||
|
||||
@@ -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,10 @@ 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")
|
||||
}
|
||||
account != null -> {
|
||||
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
|
||||
bundle.putString(KEY_COLOR, "#FE860E")
|
||||
|
||||
@@ -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
|
||||
@@ -25,8 +25,9 @@ import sh.sar.basedbank.api.models.BankAccount
|
||||
import sh.sar.basedbank.api.mib.MibCard
|
||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||
import sh.sar.basedbank.util.PaymvQrParser
|
||||
import sh.sar.basedbank.util.CredentialStore
|
||||
import 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
|
||||
@@ -36,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
|
||||
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, 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 {
|
||||
@@ -85,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()
|
||||
@@ -101,12 +116,12 @@ class DashboardFragment : Fragment() {
|
||||
val credStore = CredentialStore(requireContext())
|
||||
val hidden = credStore.getHiddenDashboardCardNumbers()
|
||||
val mibItems = (viewModel.mibCards.value ?: emptyList())
|
||||
.filter { !hidden.contains(it.maskedCardNumber) }
|
||||
.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) && !hidden.contains(it.accountNumber) }
|
||||
.map { CardItem.Bml(it) }
|
||||
val all = mibItems + bmlItems
|
||||
val all = bmlItems + mibItems
|
||||
val defaultNum = credStore.getDefaultCardAccountNumber()
|
||||
val ordered = if (defaultNum != null) {
|
||||
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||
@@ -120,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)
|
||||
@@ -131,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
|
||||
@@ -382,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))
|
||||
}
|
||||
}
|
||||
@@ -393,11 +427,13 @@ class DashboardFragment : Fragment() {
|
||||
if (isMib) {
|
||||
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
val accountNumber = (item as CardItem.Bml).account.accountNumber
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_pay_with_card,
|
||||
CardsFragment.newInstanceWithAutoTapMode(accountNumber)
|
||||
)
|
||||
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()
|
||||
@@ -238,16 +259,30 @@ class HomeActivity : AppCompatActivity() {
|
||||
val navDest = intent.getIntExtra("nav_destination", -1)
|
||||
val autoScan = intent.getBooleanExtra("auto_scan", false)
|
||||
val autoTapMode = intent.getBooleanExtra("auto_tap_mode", false)
|
||||
if (navDest != -1) {
|
||||
val fragment = when {
|
||||
autoScan && navDest == R.id.nav_transfer -> TransferFragment.newInstanceWithAutoScan()
|
||||
autoTapMode && navDest == R.id.nav_pay_with_card -> CardsFragment.newInstanceWithAutoTapMode()
|
||||
else -> null
|
||||
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)
|
||||
}
|
||||
}
|
||||
navigateTo(navDest, fragment)
|
||||
} else {
|
||||
show(DashboardFragment())
|
||||
binding.navigationView.setCheckedItem(R.id.nav_dashboard)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,14 +296,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())
|
||||
@@ -324,21 +385,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,11 +461,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()
|
||||
@@ -389,25 +477,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
|
||||
}
|
||||
}
|
||||
@@ -436,6 +515,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.
|
||||
@@ -444,6 +550,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
|
||||
@@ -526,9 +637,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
|
||||
@@ -541,6 +662,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
|
||||
@@ -554,6 +679,16 @@ 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)
|
||||
@@ -995,14 +1130,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_")
|
||||
@@ -1085,7 +1220,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
|
||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw))
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
val qr = PaymvQrParser.parse(raw)
|
||||
if (qr == null || qr.accountNumber == null) {
|
||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||
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
|
||||
@@ -37,17 +37,25 @@ 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
|
||||
|
||||
@@ -60,8 +68,39 @@ 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
|
||||
@@ -78,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
|
||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||
(requireActivity() as HomeActivity).navigateTo(
|
||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, pendingQrAccountNumber)
|
||||
)
|
||||
} else {
|
||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
pendingQrAccountNumber = null
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
_binding = FragmentCardsBinding.inflate(inflater, container, false)
|
||||
return binding.root
|
||||
@@ -130,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()) {
|
||||
@@ -203,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))
|
||||
}
|
||||
}
|
||||
@@ -217,11 +247,13 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
return@setOnClickListener
|
||||
}
|
||||
val bmlItem = item as CardItem.Bml
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
|
||||
showBiometricPromptForTap(bmlItem)
|
||||
} else {
|
||||
setTapMode(true, bmlItem)
|
||||
NfcPaymentUtil.checkAndProceed(requireContext()) {
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
|
||||
showBiometricPromptForTap(bmlItem)
|
||||
} else {
|
||||
setTapMode(true, bmlItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,20 +261,161 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
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 -> {
|
||||
@@ -252,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
|
||||
@@ -263,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) }
|
||||
@@ -315,6 +519,16 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -495,15 +709,37 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
tapAnimView = animView
|
||||
|
||||
val dp = resources.displayMetrics.density
|
||||
val cancelBtn = MaterialButton(requireContext(), null,
|
||||
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
||||
).apply { setText(R.string.cancel); setOnClickListener { setTapMode(false) } }
|
||||
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(0, 0, 0, (24 * dp).toInt())
|
||||
addView(cancelBtn)
|
||||
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
|
||||
@@ -514,6 +750,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
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)
|
||||
})
|
||||
@@ -620,10 +857,12 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
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
|
||||
@@ -643,7 +882,10 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
view?.post {
|
||||
if (!isTapMode) return@post
|
||||
setTapMode(false)
|
||||
if (success) Toast.makeText(requireContext(), "Payment complete", Toast.LENGTH_SHORT).show()
|
||||
if (success) {
|
||||
Toast.makeText(requireContext(), "Payment complete", Toast.LENGTH_SHORT).show()
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -658,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 }
|
||||
@@ -699,11 +945,13 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
currentCardPosition = pos
|
||||
binding.rvCards.scrollToPosition(pos)
|
||||
}
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
|
||||
showBiometricPromptForTap(targetCard)
|
||||
} else {
|
||||
setTapMode(true, targetCard)
|
||||
NfcPaymentUtil.checkAndProceed(requireContext()) {
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
|
||||
showBiometricPromptForTap(targetCard)
|
||||
} else {
|
||||
setTapMode(true, targetCard)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -761,6 +1009,10 @@ 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 {
|
||||
@@ -785,7 +1037,11 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
|
||||
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() {
|
||||
@@ -839,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
|
||||
@@ -875,7 +1131,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
|
||||
val dp = resources.displayMetrics.density
|
||||
val progress = animator.animatedFraction
|
||||
val cx = w / 2f; val cy = h / 2f - 20 * dp
|
||||
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)
|
||||
@@ -884,70 +1140,59 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
||||
val colorSurfaceVariant = MaterialColors.getColor(this,
|
||||
com.google.android.material.R.attr.colorSurfaceVariant, android.graphics.Color.LTGRAY)
|
||||
|
||||
// Phone (left of center)
|
||||
val phoneW = 36 * dp; val phoneH = 62 * dp
|
||||
val phoneX = cx - 72 * dp - phoneW; val phoneY = cy - phoneH / 2f
|
||||
// POS terminal (top center)
|
||||
val posW = 44 * dp; val posH = 72 * dp
|
||||
val posX = cx - posW / 2f; val posY = cy - 170 * dp
|
||||
|
||||
// POS terminal (right of center)
|
||||
val posW = 30 * dp; val posH = 50 * dp
|
||||
val posX = cx + 72 * dp; val posY = cy - posH / 2f
|
||||
|
||||
// Phone body
|
||||
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
|
||||
canvas.drawRoundRect(phoneX, phoneY, phoneX + phoneW, phoneY + phoneH, 6 * dp, 6 * dp, paint)
|
||||
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2 * dp; paint.color = colorOnSurface
|
||||
canvas.drawRoundRect(phoneX, phoneY, phoneX + phoneW, phoneY + phoneH, 6 * dp, 6 * dp, paint)
|
||||
|
||||
// Phone screen
|
||||
paint.style = Paint.Style.FILL; paint.color = colorPrimary; paint.alpha = 70
|
||||
canvas.drawRoundRect(phoneX + 3 * dp, phoneY + 8 * dp,
|
||||
phoneX + phoneW - 3 * dp, phoneY + phoneH - 12 * dp, 3 * dp, 3 * dp, paint)
|
||||
paint.alpha = 255
|
||||
|
||||
// Static NFC arcs on the right side of phone
|
||||
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2 * dp; paint.color = colorPrimary
|
||||
val arcOriginX = phoneX + phoneW
|
||||
for (i in 1..3) {
|
||||
val r = i * 10 * dp
|
||||
paint.alpha = 220 - i * 50
|
||||
canvas.drawArc(RectF(arcOriginX - r, cy - r, arcOriginX + r, cy + r),
|
||||
-70f, 140f, false, paint)
|
||||
}
|
||||
paint.alpha = 255
|
||||
// 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, 5 * dp, 5 * dp, paint)
|
||||
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2 * dp; paint.color = colorOnSurface
|
||||
canvas.drawRoundRect(posX, posY, posX + posW, posY + posH, 5 * dp, 5 * dp, paint)
|
||||
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 + 3 * dp, posY + 4 * dp,
|
||||
posX + posW - 3 * dp, posY + posH * 0.45f, 3 * dp, 3 * dp, paint)
|
||||
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 = 1.5f * dp; paint.color = colorOnSurface
|
||||
canvas.drawLine(posX + 4 * dp, posY + posH * 0.72f, posX + posW - 4 * dp, posY + posH * 0.72f, paint)
|
||||
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)
|
||||
|
||||
// Animated NFC rings travelling from phone toward POS
|
||||
val gapStart = arcOriginX + 28 * dp
|
||||
val gapEnd = posX - 4 * dp
|
||||
val midX = (gapStart + gapEnd) / 2f
|
||||
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * dp
|
||||
// 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 * (gapEnd - gapStart) / 2f + 6 * dp
|
||||
val r = (p * maxR + 6 * dp).coerceAtMost(maxR)
|
||||
paint.color = colorPrimary; paint.alpha = ((1f - p) * 200).toInt().coerceIn(0, 255)
|
||||
canvas.drawArc(RectF(midX - r, cy - r, midX + r, cy + r), -80f, 160f, false, paint)
|
||||
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 = 14 * dp; paint.textAlign = Paint.Align.CENTER
|
||||
canvas.drawText(context.getString(R.string.card_pay_nfc), cx, cy + 60 * dp, paint)
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -985,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))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -86,15 +86,25 @@ class TransferFragment : Fragment() {
|
||||
private var resolvedAccountNumber = ""
|
||||
private var resolvedRecipientName = ""
|
||||
private var resolvedBankName = ""
|
||||
private var resolvedDestCurrency = "" // "MVR" / "USD" / "" if unknown
|
||||
private var resolvedToOwnAccount: BankAccount? = null
|
||||
|
||||
// Selected Fahipay service when source is Fahipay and destination is a phone number
|
||||
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
|
||||
private var selectedFahipayService: String? = null
|
||||
|
||||
// Form state preserved across view destroy/create when the fragment instance is cached
|
||||
private var savedAmount = ""
|
||||
private var savedRemarks = ""
|
||||
private var savedToText = ""
|
||||
private var savedToSubtitle = ""
|
||||
private var savedToColorHex = "#607D8B"
|
||||
private var savedToImageHash: String? = null
|
||||
|
||||
// BML QR merchant payment mode (set when navigated from a card QR scan)
|
||||
private var bmlQrInfo: BmlQrPayInfo? = null
|
||||
private var bmlGatewayQr = false // true for pay.bml.com.mv QRs (requires pre-initiate step)
|
||||
private var bmlQrLookupAttempted = false // prevents re-lookup after user clears the merchant
|
||||
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
||||
|
||||
// BML business profile OTP flow state
|
||||
@@ -139,6 +149,28 @@ class TransferFragment : Fragment() {
|
||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||
return@registerForActivityResult
|
||||
}
|
||||
|
||||
// Cards can't pay PayMV QR — fall back to default account or clear selection
|
||||
val isCard = selectedAccount?.let {
|
||||
it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT"
|
||||
} ?: false
|
||||
if (isCard) {
|
||||
Toast.makeText(requireContext(), R.string.card_qr_paymv_unsupported, Toast.LENGTH_SHORT).show()
|
||||
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||
val defaultAcc = defaultNum?.let { num -> viewModel.accounts.value?.firstOrNull { it.accountNumber == num } }
|
||||
selectedAccount = defaultAcc
|
||||
binding.tilAmount.prefixText = null
|
||||
if (defaultAcc != null) {
|
||||
updateAmountPrefix(defaultAcc)
|
||||
showFromCard(defaultAcc)
|
||||
} else {
|
||||
binding.cardFromInfo.visibility = View.GONE
|
||||
binding.tilFrom.visibility = View.VISIBLE
|
||||
binding.actvFrom.setText("", false)
|
||||
}
|
||||
updateTransferButton()
|
||||
}
|
||||
|
||||
if (qr.amount != null) binding.etAmount.setText(qr.amount)
|
||||
if (qr.purpose != null) binding.etRemarks.setText(qr.purpose)
|
||||
prefillToFromContact(qr.accountNumber, "")
|
||||
@@ -191,13 +223,15 @@ class TransferFragment : Fragment() {
|
||||
accountNumber: String,
|
||||
displayName: String,
|
||||
amount: String?,
|
||||
remarks: String?
|
||||
remarks: String?,
|
||||
fromAccountNumber: String? = null
|
||||
) = TransferFragment().apply {
|
||||
arguments = Bundle().apply {
|
||||
putString(ARG_ACCOUNT, accountNumber)
|
||||
putString(ARG_NAME, displayName)
|
||||
putString(ARG_SUBTITLE, accountNumber)
|
||||
putString(ARG_COLOR, "#607D8B")
|
||||
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
||||
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
|
||||
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
|
||||
}
|
||||
@@ -221,11 +255,27 @@ class TransferFragment : Fragment() {
|
||||
|
||||
childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
|
||||
val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener
|
||||
if (accountNumber.startsWith("bmlqr:")) {
|
||||
lookupBmlQrMerchant(accountNumber.removePrefix("bmlqr:"))
|
||||
return@setFragmentResultListener
|
||||
}
|
||||
val label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
|
||||
val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber
|
||||
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
|
||||
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
|
||||
prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash)
|
||||
if (selectedAccount == null) {
|
||||
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||
if (defaultNum != null) {
|
||||
val defaultAcc = viewModel.accounts.value?.firstOrNull { it.accountNumber == defaultNum }
|
||||
if (defaultAcc != null) {
|
||||
selectedAccount = defaultAcc
|
||||
updateAmountPrefix(defaultAcc)
|
||||
showFromCard(defaultAcc)
|
||||
updateTransferButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binding.btnPickContact.setOnClickListener {
|
||||
@@ -265,9 +315,37 @@ class TransferFragment : Fragment() {
|
||||
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
|
||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||
}
|
||||
|
||||
// Restore form state when view is recreated on the cached no-args instance
|
||||
if (arguments == null) {
|
||||
if (resolvedAccountNumber.isNotEmpty()) {
|
||||
val ownAccount = viewModel.accounts.value?.firstOrNull { it.accountNumber == resolvedAccountNumber }
|
||||
if (ownAccount != null) {
|
||||
showToCard(ownAccount)
|
||||
} else {
|
||||
binding.tvToAccountName.text = resolvedRecipientName
|
||||
binding.tvToBankBic.text = savedToSubtitle
|
||||
binding.tvToAccountDetails.visibility = View.GONE
|
||||
binding.tvToBalance.visibility = View.GONE
|
||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(resolvedRecipientName, savedToColorHex))
|
||||
}
|
||||
binding.tilTo.visibility = View.GONE
|
||||
binding.btnPickContact.visibility = View.GONE
|
||||
binding.btnScanQr.visibility = View.GONE
|
||||
binding.cardToInfo.visibility = View.VISIBLE
|
||||
if (savedToImageHash != null) loadToPhoto(savedToImageHash!!, isProfile = resolvedToOwnAccount != null)
|
||||
} else if (savedToText.isNotEmpty()) {
|
||||
binding.etTo.setText(savedToText)
|
||||
}
|
||||
if (savedAmount.isNotEmpty()) binding.etAmount.setText(savedAmount)
|
||||
if (savedRemarks.isNotEmpty()) binding.etRemarks.setText(savedRemarks)
|
||||
updateTransferButton()
|
||||
}
|
||||
}
|
||||
|
||||
private fun lookupBmlQrMerchant(qrUrl: String) {
|
||||
bmlQrLookupAttempted = true
|
||||
bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/")
|
||||
val base64Url = android.util.Base64.encodeToString(
|
||||
qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP)
|
||||
@@ -292,6 +370,16 @@ class TransferFragment : Fragment() {
|
||||
return@launch
|
||||
}
|
||||
bmlQrInfo = info
|
||||
if (info.amount == 0.0) {
|
||||
RecentsCache.save(requireContext(), RecentPick(
|
||||
accountNumber = "bmlqr:$qrUrl",
|
||||
displayName = info.merchantName,
|
||||
subtitle = info.merchantAddress.ifBlank { "BML Merchant" },
|
||||
colorHex = "#0066A1",
|
||||
imageHash = null,
|
||||
isProfileImage = false
|
||||
))
|
||||
}
|
||||
|
||||
// Auto-select the user's default BML card if no card was pre-selected
|
||||
if (selectedAccount == null) {
|
||||
@@ -398,6 +486,35 @@ class TransferFragment : Fragment() {
|
||||
updateTransferButton()
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-select default account when arriving from contacts page (TO account already pre-filled)
|
||||
if (selectedAccount == null && arguments?.getString(ARG_ACCOUNT) != null) {
|
||||
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||
if (defaultNum != null) {
|
||||
val defaultAcc = accounts.firstOrNull { it.accountNumber == defaultNum }
|
||||
if (defaultAcc != null) {
|
||||
selectedAccount = defaultAcc
|
||||
updateAmountPrefix(defaultAcc)
|
||||
showFromCard(defaultAcc)
|
||||
updateTransferButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On a cold start (e.g. share intent), anyBmlSession() may be null when
|
||||
// onViewCreated runs. Retry the lookup once sessions are available.
|
||||
val pendingBmlQrUrl = arguments?.getString(ARG_BML_QR_URL)
|
||||
if (pendingBmlQrUrl != null && !bmlQrLookupAttempted) {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
if (app.anyBmlSession() != null) lookupBmlQrMerchant(pendingBmlQrUrl)
|
||||
}
|
||||
|
||||
// Re-render the from card when the view is recreated on a cached instance
|
||||
if (selectedAccount != null && binding.cardFromInfo.visibility != View.VISIBLE) {
|
||||
updateAmountPrefix(selectedAccount!!)
|
||||
showFromCard(selectedAccount!!)
|
||||
updateTransferButton()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,6 +662,7 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
resolvedDestCurrency = ""
|
||||
resolvedToOwnAccount = null
|
||||
selectedFahipayService = null
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
@@ -561,6 +679,7 @@ class TransferFragment : Fragment() {
|
||||
if (binding.cardToInfo.visibility == View.VISIBLE) {
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
resolvedDestCurrency = ""
|
||||
resolvedToOwnAccount = null
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
binding.tilTo.visibility = View.VISIBLE
|
||||
@@ -573,8 +692,21 @@ class TransferFragment : Fragment() {
|
||||
|
||||
private fun lookupAccount() {
|
||||
if (selectedAccount == null) {
|
||||
Toast.makeText(requireContext(), R.string.transfer_select_source_first, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||
if (defaultNum != null) {
|
||||
val allAccounts = viewModel.accounts.value ?: emptyList()
|
||||
val defaultAcc = allAccounts.firstOrNull { it.accountNumber == defaultNum }
|
||||
if (defaultAcc != null) {
|
||||
selectedAccount = defaultAcc
|
||||
updateAmountPrefix(defaultAcc)
|
||||
showFromCard(defaultAcc)
|
||||
updateTransferButton()
|
||||
}
|
||||
}
|
||||
if (selectedAccount == null) {
|
||||
Toast.makeText(requireContext(), R.string.transfer_no_from_account, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
}
|
||||
val accountNumber = AccountInputParser.normalize(binding.etTo.text?.toString()?.trim() ?: "")
|
||||
if (accountNumber.isBlank()) {
|
||||
@@ -631,7 +763,16 @@ class TransferFragment : Fragment() {
|
||||
"IAT" -> "MALBMVMV"
|
||||
else -> bmlResult.agnt ?: bmlResult.account
|
||||
}
|
||||
MibIpsAccountInfo(accountName = bmlResult.name, accountNumber = bmlResult.account, bankId = bankId)
|
||||
// BML's MIB verify endpoint doesn't return the account's currency.
|
||||
// Enrich via MIB lookup when a MIB session is available.
|
||||
val currency = if (
|
||||
inputType == AccountInputParser.InputType.MIB_ACCOUNT &&
|
||||
bmlResult.currency.isBlank() && mibSess != null
|
||||
) {
|
||||
try { MibTransferClient().lookup(mibSess, bmlResult.account).currency }
|
||||
catch (_: Exception) { "" }
|
||||
} else bmlResult.currency
|
||||
MibIpsAccountInfo(accountName = bmlResult.name, accountNumber = bmlResult.account, bankId = bankId, currency = currency)
|
||||
} else if (mibSess != null) {
|
||||
try { MibTransferClient().lookup(mibSess, accountNumber) }
|
||||
catch (e: MibLookupException) { errorMsg = e.message; null }
|
||||
@@ -640,21 +781,29 @@ class TransferFragment : Fragment() {
|
||||
errorMsg = getString(R.string.transfer_account_not_found); null
|
||||
}
|
||||
} else {
|
||||
if (mibSess != null) {
|
||||
val mibInfo = if (mibSess != null) {
|
||||
try { MibTransferClient().lookup(mibSess, accountNumber) }
|
||||
catch (e: MibLookupException) { errorMsg = e.message; null }
|
||||
catch (_: Exception) { errorMsg = getString(R.string.transfer_account_not_found); null }
|
||||
} else {
|
||||
val bmlResult = try { BmlValidateClient().validateAccount(bmlSess!!, accountNumber) } catch (_: Exception) { null }
|
||||
} else null
|
||||
if (mibInfo != null) {
|
||||
mibInfo
|
||||
} else if (bmlSess != null) {
|
||||
val bmlResult = try { BmlValidateClient().validateAccount(bmlSess, accountNumber) } catch (_: Exception) { null }
|
||||
if (bmlResult != null) {
|
||||
errorMsg = null
|
||||
val bankId = when (bmlResult.trnType) {
|
||||
"IAT" -> "MALBMVMV"
|
||||
else -> bmlResult.agnt ?: bmlResult.account
|
||||
}
|
||||
MibIpsAccountInfo(accountName = bmlResult.name, accountNumber = bmlResult.account, bankId = bankId)
|
||||
MibIpsAccountInfo(accountName = bmlResult.name, accountNumber = bmlResult.account, bankId = bankId, currency = bmlResult.currency)
|
||||
} else {
|
||||
errorMsg = getString(R.string.transfer_account_not_found); null
|
||||
if (errorMsg == null) errorMsg = getString(R.string.transfer_account_not_found)
|
||||
null
|
||||
}
|
||||
} else {
|
||||
if (errorMsg == null) errorMsg = getString(R.string.transfer_account_not_found)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -670,6 +819,14 @@ class TransferFragment : Fragment() {
|
||||
resolvedAccountNumber = info.accountNumber
|
||||
resolvedRecipientName = info.accountName
|
||||
resolvedBankName = info.bankId
|
||||
resolvedDestCurrency = info.currency
|
||||
savedToSubtitle = "${info.accountNumber} · ${info.bankId}"
|
||||
savedToColorHex = colorHex
|
||||
savedToImageHash = when {
|
||||
matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash
|
||||
matchedCont?.customerImgHash != null -> matchedCont.customerImgHash
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (matchedAcc != null) {
|
||||
showToCard(matchedAcc)
|
||||
@@ -802,6 +959,9 @@ class TransferFragment : Fragment() {
|
||||
) {
|
||||
resolvedAccountNumber = accountNumber
|
||||
resolvedRecipientName = displayName
|
||||
savedToSubtitle = subtitle
|
||||
savedToColorHex = colorHex
|
||||
savedToImageHash = imageHash
|
||||
val contacts = viewModel.contacts.value ?: emptyList()
|
||||
resolvedBankName = contacts.firstOrNull { it.benefAccount == accountNumber }?.benefBankName ?: ""
|
||||
|
||||
@@ -824,22 +984,37 @@ class TransferFragment : Fragment() {
|
||||
updateTransferButton()
|
||||
|
||||
val contact = contacts.firstOrNull { it.benefAccount == accountNumber }
|
||||
if (contact != null) {
|
||||
RecentsCache.save(requireContext(), RecentPick(
|
||||
accountNumber = contact.benefAccount,
|
||||
displayName = contact.benefNickName,
|
||||
subtitle = subtitle,
|
||||
colorHex = colorHex,
|
||||
imageHash = imageHash,
|
||||
isProfileImage = false
|
||||
))
|
||||
if (imageHash != null) loadToPhoto(imageHash, isProfile = false)
|
||||
val recentImageHash: String?
|
||||
val recentIsProfileImage: Boolean
|
||||
when {
|
||||
ownAccount != null -> {
|
||||
recentImageHash = ownAccount.profileImageHash
|
||||
recentIsProfileImage = true
|
||||
}
|
||||
contact != null -> {
|
||||
recentImageHash = contact.customerImgHash ?: imageHash
|
||||
recentIsProfileImage = false
|
||||
}
|
||||
else -> {
|
||||
recentImageHash = imageHash
|
||||
recentIsProfileImage = false
|
||||
}
|
||||
}
|
||||
RecentsCache.save(requireContext(), RecentPick(
|
||||
accountNumber = accountNumber,
|
||||
displayName = displayName,
|
||||
subtitle = subtitle,
|
||||
colorHex = colorHex,
|
||||
imageHash = recentImageHash,
|
||||
isProfileImage = recentIsProfileImage
|
||||
))
|
||||
if (contact != null && imageHash != null) loadToPhoto(imageHash, isProfile = false)
|
||||
}
|
||||
|
||||
private fun prefillToFromContact(accountNumber: String, label: String) {
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
resolvedDestCurrency = ""
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
binding.tilTo.visibility = View.VISIBLE
|
||||
binding.btnPickContact.visibility = View.VISIBLE
|
||||
@@ -857,13 +1032,20 @@ class TransferFragment : Fragment() {
|
||||
message: String? = null,
|
||||
customView: android.view.View? = null,
|
||||
biometricSubtitle: String,
|
||||
onConfirmed: () -> Unit
|
||||
onConfirmed: (AlertDialog, android.widget.FrameLayout) -> Unit
|
||||
) {
|
||||
val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as android.view.inputmethod.InputMethodManager
|
||||
imm.hideSoftInputFromWindow(requireView().windowToken, 0)
|
||||
|
||||
val frame = android.widget.FrameLayout(requireContext())
|
||||
if (customView != null) frame.addView(customView)
|
||||
|
||||
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(title)
|
||||
.setPositiveButton(R.string.transfer_confirm) { _, _ -> onConfirmed() }
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
if (customView != null) builder.setView(customView) else builder.setMessage(message)
|
||||
.setPositiveButton(R.string.transfer_confirm, null)
|
||||
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
|
||||
.setCancelable(false)
|
||||
if (customView != null) builder.setView(frame) else builder.setMessage(message)
|
||||
val dialog = builder.show()
|
||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||
val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false)
|
||||
@@ -874,8 +1056,7 @@ class TransferFragment : Fragment() {
|
||||
val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()),
|
||||
object : BiometricPrompt.AuthenticationCallback() {
|
||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||
dialog.dismiss()
|
||||
onConfirmed()
|
||||
onConfirmed(dialog, frame)
|
||||
}
|
||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||
if (errorCode != BiometricPrompt.ERROR_CANCELED &&
|
||||
@@ -894,6 +1075,10 @@ class TransferFragment : Fragment() {
|
||||
.build()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||
onConfirmed(dialog, frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -912,11 +1097,27 @@ class TransferFragment : Fragment() {
|
||||
Toast.makeText(requireContext(), R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val qrFromTypeLabel = AccountListParser.from(src)?.typeLabel
|
||||
?: BmlDashboardParser.productLabel(src.accountTypeName)
|
||||
val qrFromDetail = listOfNotNull("BML", qrFromTypeLabel.ifBlank { null }).joinToString(" · ")
|
||||
val qrConfirmView = buildTransferConfirmView(
|
||||
amountCurrency = info.currency,
|
||||
amountValue = "%.2f".format(amount),
|
||||
fromName = src.accountBriefName,
|
||||
fromNumber = src.accountNumber,
|
||||
fromDetail = qrFromDetail,
|
||||
toName = info.merchantName,
|
||||
toNumber = "",
|
||||
toDetail = info.merchantAddress.ifBlank { "BML Merchant" }
|
||||
)
|
||||
showConfirmWithBiometric(
|
||||
title = getString(R.string.transfer),
|
||||
message = "Pay ${info.currency} ${"%.2f".format(amount)} to ${info.merchantName}?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}",
|
||||
customView = qrConfirmView,
|
||||
biometricSubtitle = "${info.currency} ${"%.2f".format(amount)} → ${info.merchantName}",
|
||||
onConfirmed = { executeBmlQrPayment(src, debitAccount, info, amount) }
|
||||
onConfirmed = { dialog, frame ->
|
||||
showProcessingInDialog(dialog, frame)
|
||||
executeBmlQrPayment(src, debitAccount, info, amount, dialog, frame)
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -950,10 +1151,27 @@ class TransferFragment : Fragment() {
|
||||
if (isSrcBml && isDestMib && currency == "USD") {
|
||||
val hasBmlContact = allContacts.any { it.benefCategoryId == "BML" && it.benefAccount == resolvedAccountNumber }
|
||||
if (!hasBmlContact) {
|
||||
// If we verified the dest currency via MIB fallback, the block is purely a BML API limitation.
|
||||
// Otherwise (no MIB session, currency truly unknown) the generic message applies.
|
||||
val msgRes = if (resolvedDestCurrency.isNotBlank())
|
||||
R.string.transfer_bml_contact_required_msg_bml_limit
|
||||
else
|
||||
R.string.transfer_bml_contact_required_msg
|
||||
val bmlProfileId = src.profileId.takeIf { it.isNotBlank() }
|
||||
?: src.loginTag.removePrefix("bml_").takeIf { it.isNotBlank() }
|
||||
val verified = resolvedDestCurrency.isNotBlank()
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.transfer_bml_contact_required_title)
|
||||
.setMessage(R.string.transfer_bml_contact_required_msg)
|
||||
.setPositiveButton(R.string.close, null)
|
||||
.setMessage(msgRes)
|
||||
.setPositiveButton(R.string.contact_save) { _, _ ->
|
||||
AddContactSheetFragment.newInstance(
|
||||
bmlProfileId = bmlProfileId,
|
||||
accountNumber = resolvedAccountNumber,
|
||||
recipientName = if (verified) resolvedRecipientName else null,
|
||||
currency = if (verified) resolvedDestCurrency else null
|
||||
).show(parentFragmentManager, "add_contact")
|
||||
}
|
||||
.setNegativeButton(R.string.close, null)
|
||||
.show()
|
||||
return
|
||||
}
|
||||
@@ -963,26 +1181,27 @@ class TransferFragment : Fragment() {
|
||||
val bankNameCapture = resolvedBankName
|
||||
val capturedToAvatar = (binding.ivToPhoto.drawable as? android.graphics.drawable.BitmapDrawable)?.bitmap
|
||||
|
||||
val destCurrency = allAccounts.firstOrNull { it.accountNumber == resolvedAccountNumber }
|
||||
?.currencyName?.ifBlank { "MVR" }
|
||||
?: allContacts.firstOrNull { it.benefAccount == resolvedAccountNumber }
|
||||
?.transferCyDesc?.ifBlank { "MVR" }
|
||||
?: if (isDestMib) "MVR" else "MVR"
|
||||
val destCurrency = resolvedDestCurrency.ifBlank {
|
||||
allAccounts.firstOrNull { it.accountNumber == resolvedAccountNumber }
|
||||
?.currencyName?.ifBlank { "MVR" }
|
||||
?: allContacts.firstOrNull { it.benefAccount == resolvedAccountNumber }
|
||||
?.transferCyDesc?.ifBlank { "MVR" }
|
||||
?: "MVR"
|
||||
}
|
||||
val isUsdToMvr = currency.equals("USD", ignoreCase = true) && destCurrency.equals("MVR", ignoreCase = true)
|
||||
val isSrcCredit = src.profileType == "BML_CREDIT"
|
||||
|
||||
val mainMsg = "Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}"
|
||||
|
||||
val doTransfer: () -> Unit = {
|
||||
val doTransfer: (AlertDialog, android.widget.FrameLayout) -> Unit = { dialog, frame ->
|
||||
if (isBmlBusiness) {
|
||||
// Business profile: async OTP channel selection flow
|
||||
// Business profile: async OTP channel selection flow — dismiss dialog first
|
||||
dialog.dismiss()
|
||||
startBmlBusinessOtpFlow(
|
||||
src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks,
|
||||
isSrcCard, isDestMib, currency, allAccounts, allContacts, capturedToAvatar
|
||||
)
|
||||
} else {
|
||||
showProcessingInDialog(dialog, frame)
|
||||
binding.btnTransfer.isEnabled = false
|
||||
(activity as? HomeActivity)?.setRefreshing(true)
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
|
||||
if (!isSrcBml) {
|
||||
@@ -992,14 +1211,15 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
binding.btnTransfer.isEnabled = true
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
if (ok && receipt != null) {
|
||||
ReceiptStore.save(requireContext(), receipt)
|
||||
clearForm()
|
||||
val activity = requireActivity() as HomeActivity
|
||||
activity.triggerRefresh()
|
||||
dialog.dismiss()
|
||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
|
||||
} else if (!ok) {
|
||||
dialog.dismiss()
|
||||
if (msg == "CONNECTIVITY") {
|
||||
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
|
||||
} else {
|
||||
@@ -1010,56 +1230,202 @@ class TransferFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
val warningView: android.view.View? = if (isUsdToMvr || isSrcCredit) {
|
||||
val ctx = requireContext()
|
||||
val dp = resources.displayMetrics.density
|
||||
LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding((24 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt(), 0)
|
||||
addView(TextView(ctx).apply { text = mainMsg })
|
||||
if (isUsdToMvr) addView(TextView(ctx).apply {
|
||||
text = "⚠ You are transferring from a USD account to an MVR account. The currency will be converted at the bank's rate and this cannot be reversed!"
|
||||
setTextColor(Color.RED)
|
||||
textSize = 16f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setPadding(0, (16 * dp).toInt(), 0, 0)
|
||||
})
|
||||
if (isSrcCredit) addView(TextView(ctx).apply {
|
||||
text = "⚠ Transferring from a credit card is treated as a cash advance. Cash advance fees will be charged on the 10th of the month."
|
||||
setTextColor(Color.RED)
|
||||
textSize = 16f
|
||||
typeface = Typeface.DEFAULT_BOLD
|
||||
setPadding(0, (16 * dp).toInt(), 0, 0)
|
||||
})
|
||||
val fromTypeLabel = AccountListParser.from(src)?.typeLabel
|
||||
?: if (src.bank == "BML") BmlDashboardParser.productLabel(src.accountTypeName)
|
||||
else src.accountTypeName.ifBlank { src.profileType }
|
||||
val fromBankLabel = when (src.bank) {
|
||||
"BML" -> "BML"
|
||||
"FAHIPAY" -> "Fahipay"
|
||||
"MIB" -> "MIB"
|
||||
else -> src.bank
|
||||
}
|
||||
val fromDetail = listOfNotNull(fromBankLabel.ifBlank { null }, fromTypeLabel.ifBlank { null }).joinToString(" · ")
|
||||
|
||||
val toTypeLabel = resolvedToOwnAccount?.let { acc ->
|
||||
AccountListParser.from(acc)?.typeLabel
|
||||
?: if (acc.bank == "BML") BmlDashboardParser.productLabel(acc.accountTypeName)
|
||||
else acc.accountTypeName.ifBlank { acc.profileType }
|
||||
}
|
||||
val toBankLabel = resolvedToOwnAccount?.let { acc ->
|
||||
when (acc.bank) {
|
||||
"BML" -> "BML"
|
||||
"FAHIPAY" -> "Fahipay"
|
||||
"MIB" -> "MIB"
|
||||
else -> acc.bank
|
||||
}
|
||||
} else null
|
||||
} ?: when {
|
||||
bankNameCapture.equals("MALBMVMV", ignoreCase = true) -> "BML"
|
||||
bankNameCapture.equals("MADVMVMV", ignoreCase = true) -> "MIB"
|
||||
bankNameCapture.isNotBlank() -> bankNameCapture
|
||||
isDestMib -> "MIB"
|
||||
else -> when (selectedFahipayService) {
|
||||
"RAASTAS" -> "Ooredoo · Raastas"
|
||||
"OOREDOO_BILL" -> "Ooredoo · Bill Pay"
|
||||
"DHIRAAGU_RELOAD" -> "Dhiraagu · Reload"
|
||||
"DHIRAAGU_BILL" -> "Dhiraagu · Bill Pay"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
val toDetail = listOfNotNull(toBankLabel.ifBlank { null }, toTypeLabel?.ifBlank { null }).joinToString(" · ")
|
||||
|
||||
val warnings = buildList {
|
||||
if (isUsdToMvr) add("⚠ You are transferring from a USD account to an MVR account. The currency will be converted at the bank's rate and this cannot be reversed!")
|
||||
if (isSrcCredit) add("⚠ Transferring from a credit card is treated as a cash advance. Cash advance fees will be charged on the 10th of the month.")
|
||||
}
|
||||
val confirmView = buildTransferConfirmView(
|
||||
amountCurrency = currency,
|
||||
amountValue = "%.2f".format(amount),
|
||||
fromName = src.accountBriefName,
|
||||
fromNumber = src.accountNumber,
|
||||
fromDetail = fromDetail,
|
||||
toName = destDisplay,
|
||||
toNumber = resolvedAccountNumber,
|
||||
toDetail = toDetail,
|
||||
warningTexts = warnings
|
||||
)
|
||||
showConfirmWithBiometric(
|
||||
title = getString(R.string.transfer),
|
||||
message = if (warningView == null) mainMsg else null,
|
||||
customView = warningView,
|
||||
biometricSubtitle = "$currency $amountStr → $destDisplay",
|
||||
onConfirmed = { doTransfer() }
|
||||
customView = confirmView,
|
||||
biometricSubtitle = "$currency ${"%.2f".format(amount)} → $destDisplay",
|
||||
onConfirmed = { dialog, frame -> doTransfer(dialog, frame) }
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildTransferConfirmView(
|
||||
amountCurrency: String,
|
||||
amountValue: String,
|
||||
fromName: String,
|
||||
fromNumber: String,
|
||||
fromDetail: String,
|
||||
toName: String,
|
||||
toNumber: String,
|
||||
toDetail: String,
|
||||
warningTexts: List<String> = emptyList()
|
||||
): android.view.View {
|
||||
val ctx = requireContext()
|
||||
val dp = resources.displayMetrics.density
|
||||
val colorOnSurface = com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorOnSurface, Color.BLACK)
|
||||
val colorMuted = com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
|
||||
val colorPrimary = com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorPrimary, Color.BLUE)
|
||||
val MATCH = LinearLayout.LayoutParams.MATCH_PARENT
|
||||
val WRAP = LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
|
||||
fun lp(w: Int = MATCH, h: Int = WRAP, init: LinearLayout.LayoutParams.() -> Unit = {}) =
|
||||
LinearLayout.LayoutParams(w, h).apply(init)
|
||||
|
||||
fun accountBlock(label: String, name: String, number: String, detail: String) =
|
||||
LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
layoutParams = lp()
|
||||
addView(TextView(ctx).apply {
|
||||
text = label
|
||||
textSize = 10f
|
||||
isAllCaps = true
|
||||
letterSpacing = 0.12f
|
||||
setTextColor(colorMuted)
|
||||
gravity = Gravity.CENTER
|
||||
})
|
||||
addView(TextView(ctx).apply {
|
||||
text = name
|
||||
textSize = 16f
|
||||
setTypeface(null, Typeface.BOLD)
|
||||
setTextColor(colorOnSurface)
|
||||
gravity = Gravity.CENTER
|
||||
layoutParams = lp { topMargin = (2 * dp).toInt() }
|
||||
})
|
||||
if (number.isNotBlank()) addView(TextView(ctx).apply {
|
||||
text = number
|
||||
textSize = 13f
|
||||
setTextColor(colorMuted)
|
||||
gravity = Gravity.CENTER
|
||||
})
|
||||
if (detail.isNotBlank()) addView(TextView(ctx).apply {
|
||||
text = detail
|
||||
textSize = 12f
|
||||
setTextColor(colorMuted)
|
||||
gravity = Gravity.CENTER
|
||||
alpha = 0.75f
|
||||
})
|
||||
}
|
||||
|
||||
return LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
setPadding((20 * dp).toInt(), (8 * dp).toInt(), (20 * dp).toInt(), (8 * dp).toInt())
|
||||
|
||||
// Currency + amount on same line, centered, baseline-aligned
|
||||
addView(LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
layoutParams = lp { bottomMargin = (20 * dp).toInt() }
|
||||
addView(TextView(ctx).apply {
|
||||
text = "$amountCurrency "
|
||||
textSize = 16f
|
||||
setTextColor(colorMuted)
|
||||
layoutParams = LinearLayout.LayoutParams(WRAP, WRAP)
|
||||
})
|
||||
addView(TextView(ctx).apply {
|
||||
text = amountValue
|
||||
textSize = 34f
|
||||
setTypeface(null, Typeface.BOLD)
|
||||
setTextColor(colorPrimary)
|
||||
})
|
||||
})
|
||||
|
||||
addView(accountBlock("From", fromName, fromNumber, fromDetail))
|
||||
|
||||
// Down arrow — centered
|
||||
addView(ImageView(ctx).apply {
|
||||
setImageResource(R.drawable.ic_arrow_right)
|
||||
rotation = 90f
|
||||
setColorFilter(colorMuted)
|
||||
layoutParams = lp(WRAP, WRAP) {
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
width = (24 * dp).toInt()
|
||||
height = (24 * dp).toInt()
|
||||
topMargin = (12 * dp).toInt()
|
||||
bottomMargin = (12 * dp).toInt()
|
||||
}
|
||||
})
|
||||
|
||||
addView(accountBlock("To", toName, toNumber, toDetail))
|
||||
|
||||
for (warning in warningTexts) {
|
||||
addView(TextView(ctx).apply {
|
||||
text = warning
|
||||
setTextColor(Color.RED)
|
||||
textSize = 14f
|
||||
setTypeface(null, Typeface.BOLD)
|
||||
layoutParams = lp { topMargin = (16 * dp).toInt() }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeBmlQrPayment(
|
||||
src: BankAccount,
|
||||
debitAccount: String,
|
||||
info: BmlQrPayInfo,
|
||||
amount: Double
|
||||
amount: Double,
|
||||
dialog: AlertDialog,
|
||||
frame: android.widget.FrameLayout
|
||||
) {
|
||||
val app = requireActivity().application as BasedBankApp
|
||||
val loginId = src.loginTag.removePrefix("bml_")
|
||||
val session = bmlSessionFor(src) ?: run {
|
||||
dialog.dismiss()
|
||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||
return
|
||||
}
|
||||
val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
||||
?.let { Totp.generate(it) }
|
||||
?: run { Toast.makeText(requireContext(), "OTP unavailable", Toast.LENGTH_SHORT).show(); return }
|
||||
?: run { dialog.dismiss(); Toast.makeText(requireContext(), "OTP unavailable", Toast.LENGTH_SHORT).show(); return }
|
||||
|
||||
binding.btnTransfer.isEnabled = false
|
||||
(activity as? HomeActivity)?.setRefreshing(true)
|
||||
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
@@ -1080,75 +1446,174 @@ class TransferFragment : Fragment() {
|
||||
sh.sar.basedbank.api.bml.BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
|
||||
}
|
||||
}
|
||||
(activity as? HomeActivity)?.setRefreshing(false)
|
||||
if (_binding == null) return@launch
|
||||
|
||||
if (result == null) {
|
||||
dialog.dismiss()
|
||||
binding.btnTransfer.isEnabled = true
|
||||
Toast.makeText(requireContext(), "Failed to initiate payment", Toast.LENGTH_LONG).show()
|
||||
return@launch
|
||||
}
|
||||
if (result.success) {
|
||||
showBmlQrSuccessDialog(
|
||||
merchant = result.merchant.ifBlank { info.merchantName },
|
||||
amount = result.amount.ifBlank { "%.2f".format(amount) },
|
||||
currency = result.currency.ifBlank { info.currency }
|
||||
)
|
||||
showSuccessInDialog(
|
||||
dialog, frame,
|
||||
amountCurrency = result.currency.ifBlank { info.currency },
|
||||
amountValue = result.amount.ifBlank { "%.2f".format(amount) },
|
||||
fromName = src.accountBriefName,
|
||||
toName = result.merchant.ifBlank { info.merchantName }
|
||||
) {
|
||||
clearForm()
|
||||
(activity as? HomeActivity)?.triggerRefresh()
|
||||
}
|
||||
} else {
|
||||
dialog.dismiss()
|
||||
binding.btnTransfer.isEnabled = true
|
||||
Toast.makeText(requireContext(), result.errorMessage, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showBmlQrSuccessDialog(merchant: String, amount: String, currency: String) {
|
||||
private fun showProcessingInDialog(dialog: AlertDialog, frame: android.widget.FrameLayout) {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.visibility = View.GONE
|
||||
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.visibility = View.GONE
|
||||
dialog.setCancelable(false)
|
||||
val ctx = requireContext()
|
||||
val dp = resources.displayMetrics.density
|
||||
val container = android.widget.LinearLayout(ctx).apply {
|
||||
orientation = android.widget.LinearLayout.VERTICAL
|
||||
gravity = android.view.Gravity.CENTER_HORIZONTAL
|
||||
setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
|
||||
val spinner = CircularProgressDrawable(ctx).apply {
|
||||
setStyle(CircularProgressDrawable.LARGE)
|
||||
setColorSchemeColors(com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorPrimary, Color.GRAY))
|
||||
start()
|
||||
}
|
||||
container.addView(android.widget.ImageView(ctx).apply {
|
||||
setImageResource(R.drawable.ic_check_circle)
|
||||
setColorFilter(android.graphics.Color.parseColor("#4CAF50"))
|
||||
layoutParams = android.widget.LinearLayout.LayoutParams(
|
||||
(64 * dp).toInt(), (64 * dp).toInt()
|
||||
).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL; bottomMargin = (16 * dp).toInt() }
|
||||
frame.removeAllViews()
|
||||
frame.addView(LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt())
|
||||
addView(ImageView(ctx).apply {
|
||||
setImageDrawable(spinner)
|
||||
layoutParams = LinearLayout.LayoutParams((48 * dp).toInt(), (48 * dp).toInt()).apply {
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
bottomMargin = (12 * dp).toInt()
|
||||
}
|
||||
})
|
||||
addView(TextView(ctx).apply {
|
||||
text = "Processing..."
|
||||
textSize = 16f
|
||||
gravity = Gravity.CENTER
|
||||
setTextColor(com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorOnSurface, Color.BLACK))
|
||||
})
|
||||
})
|
||||
container.addView(android.widget.TextView(ctx).apply {
|
||||
text = "$currency $amount"
|
||||
textSize = 28f
|
||||
setTypeface(null, android.graphics.Typeface.BOLD)
|
||||
setTextColor(com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK))
|
||||
gravity = android.view.Gravity.CENTER
|
||||
layoutParams = android.widget.LinearLayout.LayoutParams(
|
||||
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL; bottomMargin = (8 * dp).toInt() }
|
||||
})
|
||||
container.addView(android.widget.TextView(ctx).apply {
|
||||
text = merchant
|
||||
textSize = 14f
|
||||
setTextColor(com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, android.graphics.Color.GRAY))
|
||||
gravity = android.view.Gravity.CENTER
|
||||
layoutParams = android.widget.LinearLayout.LayoutParams(
|
||||
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
android.widget.LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL }
|
||||
})
|
||||
MaterialAlertDialogBuilder(ctx)
|
||||
.setTitle(R.string.bml_qr_payment_success)
|
||||
.setView(container)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
.setCancelable(false)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun showSuccessInDialog(
|
||||
dialog: AlertDialog,
|
||||
frame: android.widget.FrameLayout,
|
||||
amountCurrency: String,
|
||||
amountValue: String,
|
||||
fromName: String,
|
||||
toName: String,
|
||||
onDone: () -> Unit
|
||||
) {
|
||||
val ctx = requireContext()
|
||||
val dp = resources.displayMetrics.density
|
||||
val colorOnSurface = com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorOnSurface, Color.BLACK)
|
||||
val colorMuted = com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorOnSurfaceVariant, Color.GRAY)
|
||||
val colorPrimary = com.google.android.material.color.MaterialColors.getColor(
|
||||
requireView(), com.google.android.material.R.attr.colorPrimary, Color.BLUE)
|
||||
val MATCH = LinearLayout.LayoutParams.MATCH_PARENT
|
||||
val WRAP = LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
|
||||
frame.removeAllViews()
|
||||
frame.addView(LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
setPadding((24 * dp).toInt(), (20 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
|
||||
|
||||
// Checkmark
|
||||
addView(ImageView(ctx).apply {
|
||||
setImageResource(R.drawable.ic_check_circle)
|
||||
setColorFilter(Color.parseColor("#4CAF50"))
|
||||
layoutParams = LinearLayout.LayoutParams((64 * dp).toInt(), (64 * dp).toInt()).apply {
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
bottomMargin = (16 * dp).toInt()
|
||||
}
|
||||
})
|
||||
|
||||
// Currency + amount
|
||||
addView(LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
layoutParams = LinearLayout.LayoutParams(MATCH, WRAP).apply {
|
||||
bottomMargin = (16 * dp).toInt()
|
||||
}
|
||||
addView(TextView(ctx).apply {
|
||||
text = "$amountCurrency "
|
||||
textSize = 16f
|
||||
setTextColor(colorMuted)
|
||||
layoutParams = LinearLayout.LayoutParams(WRAP, WRAP)
|
||||
})
|
||||
addView(TextView(ctx).apply {
|
||||
text = amountValue
|
||||
textSize = 28f
|
||||
setTypeface(null, Typeface.BOLD)
|
||||
setTextColor(colorPrimary)
|
||||
})
|
||||
})
|
||||
|
||||
// From row
|
||||
addView(LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
layoutParams = LinearLayout.LayoutParams(MATCH, WRAP)
|
||||
addView(TextView(ctx).apply {
|
||||
text = "From "
|
||||
textSize = 12f
|
||||
setTextColor(colorMuted)
|
||||
layoutParams = LinearLayout.LayoutParams(WRAP, WRAP)
|
||||
})
|
||||
addView(TextView(ctx).apply {
|
||||
text = fromName
|
||||
textSize = 13f
|
||||
setTypeface(null, Typeface.BOLD)
|
||||
setTextColor(colorOnSurface)
|
||||
gravity = Gravity.CENTER
|
||||
})
|
||||
})
|
||||
|
||||
// To row
|
||||
addView(LinearLayout(ctx).apply {
|
||||
orientation = LinearLayout.HORIZONTAL
|
||||
gravity = Gravity.CENTER_HORIZONTAL
|
||||
layoutParams = LinearLayout.LayoutParams(MATCH, WRAP).apply {
|
||||
topMargin = (4 * dp).toInt()
|
||||
}
|
||||
addView(TextView(ctx).apply {
|
||||
text = "To "
|
||||
textSize = 12f
|
||||
setTextColor(colorMuted)
|
||||
layoutParams = LinearLayout.LayoutParams(WRAP, WRAP)
|
||||
})
|
||||
addView(TextView(ctx).apply {
|
||||
text = toName
|
||||
textSize = 13f
|
||||
setTypeface(null, Typeface.BOLD)
|
||||
setTextColor(colorOnSurface)
|
||||
gravity = Gravity.CENTER
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
val okBtn = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
|
||||
okBtn?.visibility = View.VISIBLE
|
||||
okBtn?.text = "OK"
|
||||
okBtn?.setOnClickListener { dialog.dismiss(); onDone() }
|
||||
}
|
||||
|
||||
|
||||
private fun doMibTransfer(
|
||||
src: BankAccount,
|
||||
destAccount: String,
|
||||
@@ -1620,6 +2085,7 @@ class TransferFragment : Fragment() {
|
||||
resolvedAccountNumber = ""
|
||||
resolvedRecipientName = ""
|
||||
resolvedBankName = ""
|
||||
resolvedDestCurrency = ""
|
||||
resolvedToOwnAccount = null
|
||||
selectedFahipayService = null
|
||||
binding.cardToInfo.visibility = View.GONE
|
||||
@@ -1718,8 +2184,17 @@ class TransferFragment : Fragment() {
|
||||
requireActivity().title = getString(R.string.transfer)
|
||||
}
|
||||
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
// Persist form state so it can be restored when the view is recreated
|
||||
savedAmount = binding.etAmount.text?.toString() ?: ""
|
||||
savedRemarks = binding.etRemarks.text?.toString() ?: ""
|
||||
savedToText = if (resolvedAccountNumber.isEmpty()) binding.etTo.text?.toString() ?: "" else ""
|
||||
// Reset in-progress OTP flow — it cannot sensibly resume after the view is gone
|
||||
bmlOtpState = BmlOtpState.NONE
|
||||
pendingBmlTransfer = null
|
||||
bmlOtpChannel = null
|
||||
_binding = null
|
||||
}
|
||||
|
||||
|
||||
@@ -209,13 +209,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()
|
||||
|
||||
@@ -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
|
||||
@@ -111,6 +113,32 @@ class TransferReceiptFragment : Fragment() {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -34,6 +37,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 +64,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 +98,7 @@ 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
|
||||
}
|
||||
@@ -83,6 +106,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(" ", "")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -627,6 +627,18 @@ 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> =
|
||||
|
||||
@@ -35,6 +35,21 @@ 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
|
||||
@@ -90,16 +105,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,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"]
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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" />
|
||||
|
||||
@@ -87,6 +87,7 @@ app:menu="@menu/bottom_nav_menu" />
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="start"
|
||||
android:fitsSystemWindows="true"
|
||||
app:headerLayout="@layout/nav_header"
|
||||
app:menu="@menu/drawer_menu" />
|
||||
|
||||
</androidx.drawerlayout.widget.DrawerLayout>
|
||||
|
||||
@@ -73,22 +73,42 @@
|
||||
android:singleLine="true" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilOtpSeed"
|
||||
<LinearLayout
|
||||
android:id="@+id/rowOtpSeed"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/otp_seed"
|
||||
android:layout_marginBottom="8dp"
|
||||
app:endIconMode="password_toggle"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etOtpSeed"
|
||||
android:layout_width="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilOtpSeed"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword"
|
||||
android:imeOptions="actionDone"
|
||||
android:singleLine="true" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
android:layout_weight="1"
|
||||
android:hint="@string/otp_seed"
|
||||
app:endIconMode="password_toggle"
|
||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
||||
<com.google.android.material.textfield.TextInputEditText
|
||||
android:id="@+id/etOtpSeed"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="textPassword"
|
||||
android:imeOptions="actionDone"
|
||||
android:singleLine="true" />
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnScanOtpSeed"
|
||||
style="@style/Widget.Material3.Button.IconButton"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
app:icon="@drawable/ic_qr_scan"
|
||||
android:contentDescription="@string/scan_otp_qr"
|
||||
android:tooltipText="@string/scan_otp_qr" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
android:id="@+id/tilTotpCode"
|
||||
|
||||
@@ -44,6 +44,14 @@
|
||||
android:layout_weight="1"
|
||||
android:text="@string/settings_nav_drawer" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnNavCircular"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/settings_nav_circular" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnNavBottom"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
|
||||
@@ -139,21 +139,11 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="4dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:enabled="false"
|
||||
android:text="@string/paymvqr_save_image"
|
||||
app:icon="@drawable/ic_save" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnScanQr"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_weight="1"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="4dp"
|
||||
android:text="@string/transfer_scan_qr"
|
||||
app:icon="@drawable/ic_qr_scan" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
android:orientation="vertical"
|
||||
android:background="?attr/colorSurface">
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/receiptContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:overScrollMode="never"
|
||||
android:scrollbars="none">
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
||||
<!-- Renderable receipt card -->
|
||||
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
||||
@@ -207,10 +215,13 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
||||
<!-- Action buttons — outside renderable area -->
|
||||
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
||||
<LinearLayout
|
||||
android:id="@+id/btnRow"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
android:orientation="vertical"
|
||||
android:background="?attr/colorSurface">
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/receiptContainer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:overScrollMode="never"
|
||||
android:scrollbars="none">
|
||||
|
||||
<!-- Renderable receipt card (header grows to fill remaining space) -->
|
||||
<LinearLayout
|
||||
android:id="@+id/receiptCard"
|
||||
@@ -236,8 +244,11 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
<!-- Action buttons — outside renderable area -->
|
||||
<LinearLayout
|
||||
android:id="@+id/btnRow"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="?attr/colorSurface">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="24dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/ivLogo"
|
||||
android:layout_width="72dp"
|
||||
android:layout_height="72dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:src="@drawable/ic_logo"
|
||||
android:contentDescription="@string/app_name" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvAppName"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:textAppearance="?attr/textAppearanceHeadlineSmall"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvVersion"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:alpha="0.6"
|
||||
android:layout_marginBottom="20dp" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="?attr/colorOutlineVariant"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/about_legal"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:layout_marginBottom="24dp" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="?attr/colorOutlineVariant"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/about_terms"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:layout_marginBottom="4dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/rowMibTerms"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingVertical="12dp"
|
||||
android:background="?attr/selectableItemBackground">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:src="@drawable/mib_logo"
|
||||
android:scaleType="fitCenter"
|
||||
android:contentDescription="MIB" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Maldives Islamic Bank"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:src="@drawable/ic_arrow_right"
|
||||
android:alpha="0.4"
|
||||
android:contentDescription="@null" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/rowBmlTerms"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingVertical="12dp"
|
||||
android:background="?attr/selectableItemBackground">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:src="@drawable/bml_icon"
|
||||
android:scaleType="fitCenter"
|
||||
android:contentDescription="BML" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Bank of Maldives"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:src="@drawable/ic_arrow_right"
|
||||
android:alpha="0.4"
|
||||
android:contentDescription="@null" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/rowFahipayTerms"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingVertical="12dp"
|
||||
android:background="?attr/selectableItemBackground">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:src="@drawable/fahipay_logo"
|
||||
android:scaleType="fitCenter"
|
||||
android:contentDescription="Fahipay" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Fahipay"
|
||||
android:textAppearance="?attr/textAppearanceBodyLarge" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:src="@drawable/ic_arrow_right"
|
||||
android:alpha="0.4"
|
||||
android:contentDescription="@null" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/sectionDonate"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="?attr/colorOutlineVariant"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/about_donate_title"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:layout_marginBottom="6dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/about_donate_desc"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:alpha="0.7"
|
||||
android:layout_marginBottom="16dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnDonateMvr"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:text="@string/about_donate_mvr" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnDonateUsd"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/about_donate_usd" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
@@ -35,6 +35,14 @@
|
||||
android:layout_weight="1"
|
||||
android:text="@string/settings_nav_drawer" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnNavCircular"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/settings_nav_circular" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btnNavBottom"
|
||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||
@@ -70,6 +78,31 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Circular nav shortcuts — shown only when circular nav is active -->
|
||||
<LinearLayout
|
||||
android:id="@+id/sectionCircularSlots"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginBottom="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_circular_shortcuts"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rvCircularSlots"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:nestedScrollingEnabled="false"
|
||||
android:overScrollMode="never" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Bottom bar shortcuts — shown only when bottom nav is active -->
|
||||
<LinearLayout
|
||||
android:id="@+id/sectionBottomBarShortcuts"
|
||||
|
||||
@@ -182,6 +182,55 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Default account divider + row (shown only for non-card accounts) -->
|
||||
<View
|
||||
android:id="@+id/dividerDefaultAccount"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginTop="16dp"
|
||||
android:background="?attr/colorOutlineVariant"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/llDefaultAccountRow"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_marginTop="12dp"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Default account"
|
||||
android:textAppearance="?attr/textAppearanceBodyMedium"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Auto-selected for transfers and PayMV QR"
|
||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.materialswitch.MaterialSwitch
|
||||
android:id="@+id/switchDefaultAccount"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
@@ -26,15 +26,6 @@
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnTransferContact"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@drawable/ic_send"
|
||||
android:padding="6dp"
|
||||
android:contentDescription="@string/transfer" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnEditContact"
|
||||
android:layout_width="36dp"
|
||||
@@ -54,6 +45,15 @@
|
||||
android:tint="?attr/colorError"
|
||||
android:contentDescription="@string/contact_delete" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btnTransferContact"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:src="@drawable/ic_send"
|
||||
android:padding="6dp"
|
||||
android:contentDescription="@string/transfer" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingTop="24dp"
|
||||
android:paddingBottom="16dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:src="@mipmap/ic_launcher"
|
||||
android:contentDescription="@string/app_name" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:text="@string/app_name"
|
||||
android:textAppearance="?attr/textAppearanceTitleMedium" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<!-- Drag handle -->
|
||||
<View
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="4dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginTop="12dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:background="@drawable/drag_handle_bg" />
|
||||
|
||||
<!-- Header row -->
|
||||
<LinearLayout
|
||||
android:id="@+id/notifHeader"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="20dp"
|
||||
android:paddingVertical="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvNotifTitle"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Notifications"
|
||||
android:textAppearance="?attr/textAppearanceTitleLarge"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/btnMarkAllRead"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Mark all read"
|
||||
android:textAppearance="?attr/textAppearanceLabelMedium"
|
||||
android:padding="8dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:focusable="true"
|
||||
android:clickable="true"
|
||||
android:textColor="?attr/colorPrimary" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Divider -->
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:alpha="0.12"
|
||||
android:background="?attr/colorOnSurface" />
|
||||
|
||||
<!-- Tabs -->
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/notifTabs"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:tabMode="fixed"
|
||||
app:tabGravity="fill" />
|
||||
|
||||
<!-- Pager fills remaining height -->
|
||||
<androidx.viewpager2.widget.ViewPager2
|
||||
android:id="@+id/notifPager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.button.MaterialButton
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
style="@style/Widget.Material3.Button.TonalButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minWidth="0dp"
|
||||
android:minHeight="0dp"
|
||||
android:paddingTop="14dp"
|
||||
android:paddingBottom="14dp"
|
||||
android:text="@string/cancel"
|
||||
android:textSize="13sp"
|
||||
app:icon="@drawable/ic_block"
|
||||
app:iconSize="22dp"
|
||||
app:iconGravity="top"
|
||||
app:iconPadding="6dp" />
|
||||
@@ -2,6 +2,12 @@
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
|
||||
<item
|
||||
android:id="@+id/action_notifications"
|
||||
android:icon="@drawable/ic_bell_read"
|
||||
android:title="Notifications"
|
||||
app:showAsAction="always" />
|
||||
|
||||
<item
|
||||
android:id="@+id/action_hide_amounts"
|
||||
android:icon="@drawable/ic_visibility"
|
||||
|
||||
@@ -4,4 +4,5 @@
|
||||
<color name="seed_primary">#3F65AD</color>
|
||||
<color name="seed_secondary">#9AD141</color>
|
||||
<color name="color_unpaid">#E85D04</color>
|
||||
<color name="ic_logo_background">#E8B547</color>
|
||||
</resources>
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<string name="password">Password</string>
|
||||
<string name="otp_seed">OTP Seed (TOTP Secret)</string>
|
||||
<string name="otp_seed_hint">The Base32 secret from your authenticator setup</string>
|
||||
<string name="scan_otp_qr">Scan OTP QR</string>
|
||||
<string name="login">Login</string>
|
||||
|
||||
<!-- Lock screen -->
|
||||
@@ -138,7 +139,7 @@
|
||||
<string name="settings_biometrics">Use biometrics</string>
|
||||
<string name="settings_biometrics_unavailable">No biometrics enrolled on this device</string>
|
||||
<string name="settings_biometrics_unlock">To unlock app</string>
|
||||
<string name="settings_biometrics_transfer">Confirm transfer</string>
|
||||
<string name="settings_biometrics_transfer">Confirm transaction</string>
|
||||
<string name="biometric_transfer_title">Confirm Transfer</string>
|
||||
<string name="settings_autolock">Auto-lock</string>
|
||||
<string name="autolock_off">Off</string>
|
||||
@@ -175,7 +176,9 @@
|
||||
<string name="settings_navigation">Navigation</string>
|
||||
<string name="settings_nav_drawer">Drawer</string>
|
||||
<string name="settings_nav_bottom">Bottom Bar</string>
|
||||
<string name="settings_nav_circular">Circular</string>
|
||||
<string name="settings_appearance">Appearance</string>
|
||||
<string name="settings_circular_shortcuts">Circular Nav Shortcuts</string>
|
||||
<string name="settings_bottom_bar_shortcuts">Bottom Bar Shortcuts</string>
|
||||
<string name="settings_bottom_bar_show_labels">Always show bottom bar labels</string>
|
||||
<string name="settings_bottom_bar_select">Choose button</string>
|
||||
@@ -189,6 +192,27 @@
|
||||
<string name="settings_desc_appearance">Theme, language, and display options</string>
|
||||
<string name="settings_desc_privacy_security">App lock, PIN, and security preferences</string>
|
||||
<string name="settings_desc_storage">Manage cached data and storage usage</string>
|
||||
<string name="settings_notifications">Notifications</string>
|
||||
<string name="settings_desc_notifications">Background alerts for new bank activity</string>
|
||||
<string name="settings_notif_section">Background Polling</string>
|
||||
<string name="settings_notif_enable">Enable background notifications</string>
|
||||
<string name="settings_notif_enable_desc">Receive alerts for new transactions and activity</string>
|
||||
<string name="settings_notif_description">Keeps the app running in the background and notifies you of new bank activity. A persistent status bar notification is shown while active — you can silence or hide it in notification channels.</string>
|
||||
<string name="settings_notif_open_system">Notification channels</string>
|
||||
<string name="settings_notif_channels_desc">Manage sounds, alerts, and silence the background service notification</string>
|
||||
<string name="notif_service_title">Thijooree</string>
|
||||
<string name="notif_service_desc">Checking for new bank notifications</string>
|
||||
<string name="notif_channel_service">Background service</string>
|
||||
<string name="settings_about">About</string>
|
||||
<string name="settings_desc_about">App info, version, and legal</string>
|
||||
<string name="about_version">Version %s</string>
|
||||
<string name="about_short_desc">Thijooree is a native Android client for Maldivian banking services.</string>
|
||||
<string name="about_terms">Terms of Service</string>
|
||||
<string name="about_donate_title">Support Development</string>
|
||||
<string name="about_donate_desc">If you find this app useful, a small donation goes a long way in keeping it alive and improving.</string>
|
||||
<string name="about_donate_mvr">Donate in MVR</string>
|
||||
<string name="about_donate_usd">Donate in USD</string>
|
||||
<string name="about_legal">Thijooree is an independent, third-party app. It is not affiliated with, endorsed by, or officially supported by any bank or financial institution.\n\nThis app works by logging into your internet banking services directly using your credentials and communicating with bank APIs. It is built using techniques derived from reverse-engineered official bank apps. Behaviour may change or break without notice if banks update their systems.\n\nDhiraagu and Ooredoo APIs are used to look up details about phone numbers.\n\nThis app does not collect any analytics, telemetry, or personal data, and does not transmit any information about you or your usage to the developer. Everything stays entirely on your device.</string>
|
||||
<string name="settings_logout">Log out</string>
|
||||
<string name="settings_logout_confirm_title">Log out of %s?</string>
|
||||
<string name="settings_logout_confirm_message">All cached data will be cleared and remaining accounts will be refreshed.</string>
|
||||
@@ -241,11 +265,13 @@
|
||||
<string name="transfer_scan_qr">Scan to Pay</string>
|
||||
<string name="qr_pick_image">Pick image</string>
|
||||
<string name="transfer_qr_invalid">Invalid or unsupported QR code</string>
|
||||
<string name="card_qr_paymv_unsupported">PayMV QR is not supported for card payments — switching to transfer</string>
|
||||
<string name="qr_camera_permission_title">Camera permission required</string>
|
||||
<string name="qr_camera_permission_message">Camera access is needed to scan QR codes. Please grant the permission in Settings.</string>
|
||||
<string name="camera_permission_profile_message">Camera access is needed to take a photo. Please grant the permission in Settings.</string>
|
||||
<string name="go_to_settings">Go to Settings</string>
|
||||
<string name="transfer_select_source_first">Select a source account first</string>
|
||||
<string name="transfer_no_from_account">Please set a default account or select From account first</string>
|
||||
<string name="transfer_enter_account_first">Enter an account number first</string>
|
||||
<string name="transfer_account_not_found">Account not found</string>
|
||||
<string name="transfer_session_unavailable">Session unavailable — please re-login</string>
|
||||
@@ -254,7 +280,8 @@
|
||||
<string name="transfer_confirm">Confirm</string>
|
||||
<string name="transfer_success">Transfer Successful</string>
|
||||
<string name="transfer_bml_contact_required_title">Contact Required</string>
|
||||
<string name="transfer_bml_contact_required_msg">To send USD to a MIB account from BML, the recipient must be saved as a BML contact first. This is required by BML\'s API.\n\nPlease add this account as a BML contact, then try again.</string>
|
||||
<string name="transfer_bml_contact_required_msg">We couldn\'t verify the recipient\'s currency for this transfer.\n\nPlease save them as a contact, manually select the correct currency, then try again.</string>
|
||||
<string name="transfer_bml_contact_required_msg_bml_limit">BML\'s API requires the recipient to be saved as a contact when sending USD to a non-BML account, even though we verified the account.\n\nPlease save them as a contact, then try again.</string>
|
||||
<string name="transfer_missing_internal_id">Account data is incomplete — please re-login to refresh.</string>
|
||||
<string name="transfer_verify_payment">Verify Payment</string>
|
||||
<string name="transfer_send_otp_via">Send verification code via</string>
|
||||
@@ -330,12 +357,30 @@
|
||||
<string name="card_pay_qr">Scan to Pay</string>
|
||||
<string name="card_pay_nfc">Tap to Pay</string>
|
||||
<string name="mib_qr_nfc_not_supported">Skill issue on MIB side, Not supported</string>
|
||||
<string name="nfc_unsupported_title">Not Supported</string>
|
||||
<string name="nfc_unsupported_message">Tap to Pay is not supported on this device.</string>
|
||||
<string name="nfc_disabled_title">NFC is Off</string>
|
||||
<string name="nfc_disabled_message">Turn on NFC to use Tap to Pay.</string>
|
||||
<string name="nfc_open_settings">NFC Settings</string>
|
||||
<string name="nfc_not_default_title">Set Default Payment App</string>
|
||||
<string name="nfc_not_default_message">Set %1$s as the default contactless payment app to use Tap to Pay.</string>
|
||||
<string name="nfc_payment_open_settings">Payment Settings</string>
|
||||
<string name="card_manage">Manage Card</string>
|
||||
<string name="card_set_as_default">Set as Default Card</string>
|
||||
<string name="card_hide_from_dashboard">Hide from Dashboard</string>
|
||||
<string name="card_action_change_pin">Change PIN</string>
|
||||
<string name="card_action_freeze">Freeze</string>
|
||||
<string name="card_action_unfreeze">Unfreeze</string>
|
||||
<string name="card_action_block">Block</string>
|
||||
<string name="card_freeze_confirm_title">Freeze card?</string>
|
||||
<string name="card_freeze_confirm_message">This will temporarily stop the card from being used. You can unfreeze it anytime you want to use it again.</string>
|
||||
<string name="card_unfreeze_confirm_title">Unfreeze card?</string>
|
||||
<string name="card_unfreeze_confirm_message">This will re-enable the card for transactions.</string>
|
||||
<string name="card_freeze_success">Card frozen</string>
|
||||
<string name="card_unfreeze_success">Card unfrozen</string>
|
||||
<string name="card_freeze_failed">Failed to update card status</string>
|
||||
<string name="card_freeze_comments_hint">Reason (optional)</string>
|
||||
<string name="card_status_temp_blocked">Temporary blocked by client</string>
|
||||
<string name="cards_empty">No cards found</string>
|
||||
|
||||
<!-- Connectivity banner -->
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Thijooree Documentation
|
||||
|
||||
---
|
||||
|
||||
## App Internals
|
||||
|
||||
| Section | Description |
|
||||
|---|---|
|
||||
| [thijooree/](thijooree/README.md) | UI flows, routing logic, parsers, and security audit for the Android client |
|
||||
|
||||
---
|
||||
|
||||
## Bank & Service APIs
|
||||
|
||||
| Section | Description |
|
||||
|---|---|
|
||||
| [bmlapi/](bmlapi/README.md) | Bank of Maldives — hybrid web/OAuth login, dashboard, transfers, cards, QR payments, tap-to-pay |
|
||||
| [mibapi/](mibapi/README.md) | MIB Faisanet — Blowfish-encrypted API + WebView session, accounts, transfers, contacts |
|
||||
| [fahipayapi/](fahipayapi/README.md) | Fahipay digital wallet — login, balance, history, contacts |
|
||||
| [dhiraaguapi/](dhiraaguapi/README.md) | Dhiraagu Easy Pay — number lookup for reload / bill pay |
|
||||
| [ooredooapi/](ooredooapi/README.md) | Ooredoo Quick Pay — number validation for Raastas / bill pay |
|
||||
@@ -211,6 +211,16 @@ GET https://www.bankofmaldives.com.mv/internetbanking/web/profile
|
||||
|
||||
`302` redirect (to `/web/redirect` or similar). The server has auto-activated the sole profile and set the `blaze_identity` cookie. Skip Step 6 and proceed directly to [OAuth Token Exchange](03-oauth-token.md).
|
||||
|
||||
In this fast-path the client has no real `profile_id` to track, so a placeholder `BmlProfile` is synthesized (`BmlLoginFlow.kt:127-135`):
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `profileId` | `username` (used as a stable temporary ID — replaced by the real customer number after `fetchUserInfo`) |
|
||||
| `name` | `"Personal"` |
|
||||
| `type` | `"Profile"` |
|
||||
| `profileType` | `"default"` |
|
||||
| `autoActivated` | `true` — sentinel; `activateProfile` skips the Step 6 GET and goes straight to OAuth |
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Activate Profile
|
||||
|
||||
@@ -135,19 +135,21 @@ POST https://www.bankofmaldives.com.mv/internetbanking/oauth/token
|
||||
|
||||
**Content-Type:** `application/x-www-form-urlencoded`
|
||||
|
||||
**HTTP `User-Agent` header**: the browser/web UA (same as the initial token exchange), not the app UA. See `BmlLoginFlow.kt:341`.
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| `grant_type` | `refresh_token` |
|
||||
| `refresh_token` | Stored refresh token |
|
||||
| `client_id` | `98C83590-513F-4716-B02B-EC68B7D9E7E7` |
|
||||
| `Device-ID` | Same device ID from the original login |
|
||||
| `User-Agent` | App user agent string |
|
||||
| `User-Agent` | App user agent string (form field — distinct from the HTTP header above) |
|
||||
| `x-app-version` | `2.1.44.348` |
|
||||
|
||||
```bash
|
||||
curl --request POST \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/oauth/token' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'User-Agent: Mozilla/5.0 (Linux; Android {version}; {model}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/... Mobile Safari/537.36' \
|
||||
--data 'grant_type=refresh_token' \
|
||||
--data 'refresh_token=def50200aabbcc...' \
|
||||
--data 'client_id=98C83590-513F-4716-B02B-EC68B7D9E7E7' \
|
||||
|
||||
@@ -78,7 +78,7 @@ curl --request GET \
|
||||
"currency": "MVR",
|
||||
"account_status": "Active",
|
||||
"prepaid_card": false,
|
||||
"product_code": "VISA",
|
||||
"product_code": "C1007",
|
||||
"account_visible": false,
|
||||
"cardBalance": {
|
||||
"AvailableLimit": 0.0,
|
||||
@@ -94,7 +94,7 @@ curl --request GET \
|
||||
"currency": "MVR",
|
||||
"account_status": "Active",
|
||||
"prepaid_card": true,
|
||||
"product_code": "VISA",
|
||||
"product_code": "C1007",
|
||||
"account_visible": true,
|
||||
"cardBalance": {
|
||||
"AvailableLimit": 200.00,
|
||||
@@ -164,7 +164,7 @@ curl --request GET \
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `prepaid_card` | `bool` | `true` for prepaid cards |
|
||||
| `product_code` | `string` | Card scheme (e.g. `"VISA"`) |
|
||||
| `product_code` | `string` | BML product code, always `Cxxxx` (e.g. `"C1007"`) — see mapping below |
|
||||
| `account_visible` | `bool` | `true` for credit cards, `false` for debit cards |
|
||||
| `cardBalance.AvailableLimit` | `number` | Available credit/prepaid balance |
|
||||
| `cardBalance.CurrentBalance` | `number` | Current outstanding balance |
|
||||
@@ -179,7 +179,12 @@ curl --request GET \
|
||||
|
||||
### Product Code → Card Name
|
||||
|
||||
The `product_code` field identifies the specific card product. Known mappings:
|
||||
The `product_code` field identifies the specific card product. Resolution is two-tiered (`util/bmlapi/BmlCardParser.kt`):
|
||||
|
||||
1. **Network icon** (Visa / Mastercard / Amex chip in the corner) — by prefix: `C1*` → Visa, `C3*` → Amex, `C8*` → Mastercard, with `C8905` and `C8995` overridden to Visa.
|
||||
2. **Card image asset** — by exact-match list below; unknown codes fall back to `defaultcard.png`.
|
||||
|
||||
Known asset mappings:
|
||||
|
||||
| `product_code` | Card name | Asset |
|
||||
|---|---|---|
|
||||
@@ -202,12 +207,12 @@ The `product_code` field identifies the specific card product. Known mappings:
|
||||
| `C3009`, `C3019`, `C3029`, `C3099`, `C3088`, `C3188` | Amex Credit Gold | `amex_credit_gold` |
|
||||
| `C1001`, `C1011`, `C1082`, `C1081`, `C1101`, `C1111`, `C1181`, `C1182` | Visa Debit Generic | `visa_debit_generic` |
|
||||
| `C1003`, `C1013`, `C1083`, `C1084`, `C1103`, `C1113`, `C1183`, `C1184` | Visa Gold | `visa_gold` |
|
||||
| `C1005`, `C1006`, `C1089` | Visa Debit Islamic | `visa_debit_islamic` |
|
||||
| `C1005`, `C1006`, `C1030`, `C1089` | Visa Debit Islamic | `visa_debit_islamic` |
|
||||
| `C1007`, `C1027`, `C1097`, `C1107`, `C1197`, `C1077`, `C1177` | Visa Debit | `visa_debit` |
|
||||
| `C1009`, `C1019`, `C1085`, `C1086`, `C1109`, `C1119`, `C1185`, `C1186` | Visa Platinum | `visa_platinum` |
|
||||
| `C1017` | Visa Infinite | `visa_infinite` |
|
||||
| `C1020`, `C1021` | Visa Debit Platinum | `visa_debit_platinum` |
|
||||
| `C1030`, `C1090`, `C1130`, `C1033`, `C1133` | Visa Corporate | `visa_corporate` |
|
||||
| `C1090`, `C1130`, `C1033`, `C1133` | Visa Corporate | `visa_corporate` |
|
||||
| `C1040`, `C1041`, `C1047`, `C1048`, `C1050`, `C1051`, `C1087`, `C1088`, `C1140`, `C1141`, `C1147`, `C1148`, `C1150`, `C1151`, `C1187`, `C1188` | Visa Student Black | `visa_student_black` |
|
||||
| `C1059`, `C1062`, `C1070`, `C1072`, `C1159`, `C1162` | Mastercard Prepaid Business | `master_prepaid_business` |
|
||||
| `C1061`, `C1063`, `C1071`, `C1073`, `C1161`, `C1163` | Mastercard | `master` |
|
||||
|
||||
@@ -187,6 +187,76 @@ Fall back to `bookingDate` as-is.
|
||||
|
||||
---
|
||||
|
||||
## Pending History
|
||||
|
||||
Locked / pending amounts for a CASA account (e.g. unsettled card authorisations, holds). Returned as a flat list — no pagination.
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/history/pending/{accountId}
|
||||
```
|
||||
|
||||
| Path parameter | Description |
|
||||
|---|---|
|
||||
| `accountId` | Internal account ID (`id` field from [dashboard](04-dashboard.md)) |
|
||||
|
||||
### Headers
|
||||
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `Authorization` | `Bearer <access_token>` |
|
||||
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||
| `x-app-version` | `2.1.44.348` |
|
||||
|
||||
```bash
|
||||
curl --request GET \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/history/pending/abc123def456' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348'
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"payload": [
|
||||
{
|
||||
"LockedID": "L00012345",
|
||||
"FromDate": "2026-05-16",
|
||||
"LockedAmount": 75.00,
|
||||
"Description": "Card authorisation — Merchant Name"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Response Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `success` | `bool` | `true` on success |
|
||||
| `payload` | `array` | List of pending entries (top-level array, **not** under `payload.history`) |
|
||||
|
||||
### Pending Entry
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `LockedID` | `string` | Unique ID for the pending entry |
|
||||
| `FromDate` | `string` | Date the hold was placed |
|
||||
| `LockedAmount` | `number` | Held amount — always a positive number on the wire; the client treats it as a **debit** by negating (`BmlHistoryClient.kt:184`) |
|
||||
| `Description` | `string` | Free-form description (counterparty/merchant) |
|
||||
|
||||
> **Amount sign:** the server returns `LockedAmount` as a positive number with no debit/credit indicator. All pending entries are debits (funds reserved out of the available balance), so the client negates the value before display.
|
||||
|
||||
> **Currency:** not returned by the server. The client assumes MVR.
|
||||
|
||||
Called from `AccountHistoryFragment.kt:263` to populate the pending tab of the account history view.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
@@ -224,7 +224,14 @@ curl --request POST \
|
||||
}
|
||||
```
|
||||
|
||||
`success: false` — the `message` field contains the reason. Common causes: wrong OTP, insufficient balance, invalid account.
|
||||
`success: false` — the error text may appear in either of two fields. The client prefers `payload` (when it is a non-blank string and not `"null"`) and falls back to `message` (`BmlTransferClient.kt:86-88`):
|
||||
|
||||
| Field | When used |
|
||||
|---|---|
|
||||
| `payload` (string) | Validation-style errors — e.g. insufficient balance, account-specific failures |
|
||||
| `message` | Generic errors — e.g. invalid OTP, generic transfer failure |
|
||||
|
||||
Common causes: wrong OTP, insufficient balance, invalid account, currency mismatch.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -148,6 +148,10 @@ curl --request GET \
|
||||
| `name` | `string` | Account holder name |
|
||||
| `agnt` | `string` | BIC of MIB — send as the `bank` field in the [transfer](08-transfer.md) request |
|
||||
|
||||
> **Client-synthesized fields**: when the app wraps this response for downstream code (`BmlValidateClient.kt:68`), it sets `trnType = "DOT"` and `validationType = "MIB"`. Neither is returned by the server.
|
||||
>
|
||||
> **No currency**: this endpoint does NOT return the MIB account's currency. The client sets `currency = ""` (`BmlValidateClient.kt:74-75`). Important for USD-vs-MVR transfer routing — currency must be sourced elsewhere (e.g. MIB's own lookup, see [MIB transfer docs](../mibapi/08-transfer.md)).
|
||||
|
||||
### Failure
|
||||
|
||||
```json
|
||||
|
||||
@@ -144,4 +144,4 @@ Each channel object:
|
||||
|
||||
---
|
||||
|
||||
[← Account Validation](10-validate.md)
|
||||
[← Account Validation](10-validate.md) · [Next → Tap-to-Pay](12-tap-to-pay.md)
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
# Tap-to-Pay (NFC / HCE)
|
||||
|
||||
BML supports contactless NFC payments via Host Card Emulation (HCE). The app fetches single-use payment tokens from the server, then emulates an EMV mag-stripe contactless card using Android's `HostApduService`.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
1. Fetch tokens → POST /api/mobile/walletpayments/gettoken (TOTP-authenticated)
|
||||
2. HCE exchange → Android NFC subsystem drives the APDU exchange with the POS terminal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Fetch Payment Tokens
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
POST https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/gettoken
|
||||
```
|
||||
|
||||
### Headers
|
||||
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `Authorization` | `Bearer <access_token>` |
|
||||
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||
| `x-app-version` | `2.1.44.348` |
|
||||
| `Content-Type` | `application/json` |
|
||||
|
||||
### Three-Step OTP Flow
|
||||
|
||||
Token retrieval requires TOTP verification and completes in three POSTs to the same endpoint.
|
||||
|
||||
#### Step 1a — Initiate
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "track2",
|
||||
"cardid": "<cardId>",
|
||||
"quantity": 3
|
||||
}
|
||||
```
|
||||
|
||||
Expected response: `{ "code": 99 }` (OTP required)
|
||||
|
||||
If `"code": 0` is returned directly the payload contains tokens immediately (skip to parsing).
|
||||
|
||||
#### Step 1b — Request OTP Channel
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "track2",
|
||||
"cardid": "<cardId>",
|
||||
"quantity": 3,
|
||||
"channel": "token"
|
||||
}
|
||||
```
|
||||
|
||||
Expected response: `{ "code": 22 }` (OTP generated on BML side; TOTP is used locally)
|
||||
|
||||
#### Step 1c — Submit TOTP
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "track2",
|
||||
"cardid": "<cardId>",
|
||||
"quantity": 3,
|
||||
"channel": "token",
|
||||
"otp": "<TOTP>"
|
||||
}
|
||||
```
|
||||
|
||||
Expected response: `{ "code": 0, "payload": [...] }`
|
||||
|
||||
The OTP is a standard TOTP (RFC 6238, SHA-1, 30-second window, 6 digits) derived from the stored BML authenticator seed.
|
||||
|
||||
### Failure Handling
|
||||
|
||||
Each of the three POSTs validates the server's `code` field and throws on mismatch (`BmlTapToPayClient.kt:37, 42, 47`). The exception message is the server's `message` field:
|
||||
|
||||
| Step | Expected code | Throws if |
|
||||
|---|---|---|
|
||||
| 1a | `0` (rare) or `99` | code is neither — `message` propagated |
|
||||
| 1b | `22` | code is not `22` — `message` propagated |
|
||||
| 1c | `0` | code is not `0` — `message` propagated |
|
||||
|
||||
### Token Response
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"payload": [
|
||||
{
|
||||
"token": "4761360000000000",
|
||||
"expiry": "2512",
|
||||
"app_code": "A0000000031010",
|
||||
"service_code": "000",
|
||||
"data": "0960919802623742",
|
||||
"valid_until": "2025-12-01 12:00:00.000"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Token Fields
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `token` | PAN-equivalent single-use token (used as Track 2 primary account number) |
|
||||
| `expiry` | Expiry in `YYMM` format (e.g. `"2512"` = December 2025) |
|
||||
| `app_code` | AID (Application Identifier) hex string — identifies the card network |
|
||||
| `service_code` | 3-digit service code for Track 2 |
|
||||
| `data` | Discretionary data appended to Track 2 |
|
||||
| `valid_until` | Server-side expiry timestamp for the token |
|
||||
|
||||
### AID to Card Network Mapping
|
||||
|
||||
| AID prefix | Network |
|
||||
|---|---|
|
||||
| `A0000000031010` | Visa |
|
||||
| `A0000000041010` | Mastercard |
|
||||
| `A000000025...` | Amex |
|
||||
| (other) | BML |
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — HCE APDU Exchange
|
||||
|
||||
Once a token is set, Android's NFC subsystem routes contactless commands to the app's `HostApduService`. The flow follows the EMV mag-stripe contactless profile.
|
||||
|
||||
### APDU Exchange Flow
|
||||
|
||||
```
|
||||
POS Terminal Android HCE
|
||||
| |
|
||||
| SELECT PPSE (INS=A4) |
|
||||
|--------------------------------------->|
|
||||
| FCI Template (6F) + 9000 |
|
||||
|<---------------------------------------|
|
||||
| |
|
||||
| SELECT AID (INS=A4) |
|
||||
|--------------------------------------->|
|
||||
| FCI Template (6F) + 9000 |
|
||||
|<---------------------------------------|
|
||||
| |
|
||||
| GET PROCESSING OPTIONS (INS=A8) |
|
||||
|--------------------------------------->|
|
||||
| Response Message Template (80) + 9000 |
|
||||
|<---------------------------------------|
|
||||
| |
|
||||
| READ RECORD (INS=B2) |
|
||||
|--------------------------------------->|
|
||||
| Record Template (70) + 9000 |
|
||||
|<---------------------------------------|
|
||||
```
|
||||
|
||||
### APDU Command Bytes
|
||||
|
||||
| INS | Hex | Command |
|
||||
|---|---|---|
|
||||
| `SELECT` | `0xA4` | Select PPSE or AID |
|
||||
| `GET PROCESSING OPTIONS` | `0xA8` | Request AIP + AFL |
|
||||
| `READ RECORD` | `0xB2` | Read Track 2 data |
|
||||
|
||||
### SELECT PPSE Response
|
||||
|
||||
PPSE AID: `2PAY.SYS.DDF01` = `325041592E5359532E4444463031`
|
||||
|
||||
```
|
||||
6F <len>
|
||||
84 <len> 325041592E5359532E4444463031 ← DF Name (PPSE)
|
||||
A5 <len>
|
||||
BF0C <len>
|
||||
61 <len>
|
||||
4F <len> <AID> ← ADF Name
|
||||
87 01 01 ← Application Priority Indicator
|
||||
9000
|
||||
```
|
||||
|
||||
### SELECT AID Response
|
||||
|
||||
```
|
||||
6F <len>
|
||||
84 <len> <AID> ← Dedicated File Name
|
||||
A5 <len>
|
||||
50 <len> <label-ascii-as-hex> ← Application Label (e.g. "VISA")
|
||||
9F38 02 9F6602 ← PDOL: TTQ (2 bytes)
|
||||
9000
|
||||
```
|
||||
|
||||
The application label is derived from the AID prefix (see mapping table above).
|
||||
|
||||
### GET PROCESSING OPTIONS Response
|
||||
|
||||
```
|
||||
80 06 0080 08010100
|
||||
9000
|
||||
```
|
||||
|
||||
| Field | Value | Meaning |
|
||||
|---|---|---|
|
||||
| Tag `80` | — | Response Message Template 1 |
|
||||
| AIP | `0080` | Mag-stripe mode |
|
||||
| AFL | `08010100` | SFI=1, records 1–1, 0 offline auth records |
|
||||
|
||||
### READ RECORD Response
|
||||
|
||||
```
|
||||
70 <len>
|
||||
57 <len> <track2-data> ← Track 2 Equivalent Data
|
||||
9000
|
||||
```
|
||||
|
||||
Track 2 format:
|
||||
```
|
||||
{token} D {expiry} {serviceCode} {data} [F]
|
||||
```
|
||||
|
||||
The trailing `F` nibble is appended when the total length is odd (standard Track 2 padding).
|
||||
|
||||
Example from a real token:
|
||||
```
|
||||
4761360000000000 D 2512 000 0960919802623742
|
||||
→ 4761360000000000D2512000096091980262374 2F (padded)
|
||||
```
|
||||
|
||||
### Status Words
|
||||
|
||||
| SW | Meaning |
|
||||
|---|---|
|
||||
| `9000` | Success |
|
||||
| `6F00` | Generic / unknown error |
|
||||
| `6D00` | Instruction not supported |
|
||||
|
||||
---
|
||||
|
||||
## TLV Encoding
|
||||
|
||||
All APDU responses use BER-TLV encoding. Tags are 1 or 2 bytes (hex string). Length follows DER short/long form:
|
||||
|
||||
| Length range | Encoding |
|
||||
|---|---|
|
||||
| 0–127 bytes | `LL` (1 byte) |
|
||||
| 128–255 bytes | `81 LL` (2 bytes) |
|
||||
| 256–65535 bytes | `82 HH LL` (3 bytes) |
|
||||
|
||||
---
|
||||
|
||||
## Lifecycle
|
||||
|
||||
The HCE service (`BmlHostCardEmulatorService`) keeps a single active `BmlWalletToken` in a volatile companion-object field. Tokens are single-use — exactly one tap consumes one token.
|
||||
|
||||
### Companion API
|
||||
|
||||
```kotlin
|
||||
BmlHostCardEmulatorService.setToken(token: BmlWalletToken)
|
||||
BmlHostCardEmulatorService.clearToken()
|
||||
BmlHostCardEmulatorService.onTransactionComplete: (success: Boolean) -> Unit
|
||||
```
|
||||
|
||||
| Call | When |
|
||||
|---|---|
|
||||
| `setToken(token)` | After fetching a token, before prompting the user to tap |
|
||||
| `clearToken()` | After the tap completes, when the prompt is dismissed, or on error |
|
||||
| `onTransactionComplete(true)` | Fired immediately after the `READ RECORD` response (`BmlHostCardEmulatorService.kt:78`) |
|
||||
| `onTransactionComplete(false)` | Fired from `onDeactivated` if GPO was never seen (`BmlHostCardEmulatorService.kt:35-38`) — i.e. the reader walked away before completing the EMV exchange |
|
||||
|
||||
### State Rules
|
||||
|
||||
- A token MUST be installed via `setToken` before the user taps. With no active token, `SELECT PPSE` launches `BmlTapToPayActivity` (a redirector to `MainActivity`) and returns `6F00`.
|
||||
- The service tracks `gpoSent` to distinguish "user pulled the phone away" from a successful read. A successful `handleReadRecord` resets `gpoSent` to `false` via `onDeactivated` after the success callback has already fired.
|
||||
- `BmlTapToPayActivity` provides the "Tap your phone…" prompt UI. The activity is responsible for calling `setToken` before showing the prompt and `clearToken` when dismissed.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Valid `access_token` from [OAuth Token Exchange](03-oauth-token.md)
|
||||
- TOTP seed enrolled via BML app (same seed used for login 2FA)
|
||||
- `cardId` from the dashboard — see [Dashboard](04-dashboard.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← Foreign Limits](11-foreign-limits.md) · [Next → QR Payment](13-qr-payment.md)
|
||||
@@ -0,0 +1,245 @@
|
||||
# QR Payment
|
||||
|
||||
BML supports QR-based payments via the PayMV network. There are two QR types — static merchant QRs (no preset amount) and gateway QRs (amount preset by merchant). Both are paid via the same 3-step TOTP-authenticated flow.
|
||||
|
||||
---
|
||||
|
||||
## QR Code Types
|
||||
|
||||
| Type code | Name | Amount |
|
||||
|---|---|---|
|
||||
| `QRS` | Static QR | `0.00` — user enters amount |
|
||||
| `QRR` | Gateway / dynamic QR | Preset by merchant |
|
||||
|
||||
---
|
||||
|
||||
## QR Code Formats
|
||||
|
||||
BML QR codes appear in two formats.
|
||||
|
||||
### 1. Plain URL QR
|
||||
|
||||
```
|
||||
https://pay.bml.com.mv/app/<base64-encoded-url>
|
||||
```
|
||||
|
||||
The entire URL is base64-encoded and passed directly to the payrequest lookup API.
|
||||
|
||||
### 2. Combined EMV-style QR
|
||||
|
||||
Used in Fahipay/PayMV combo QRs that embed multiple payment networks. The BML gateway URL is embedded as a TLV value at a fixed path.
|
||||
|
||||
TLV path: **root tag `35` → sub-tag `20` → sub-sub-tag `01`**
|
||||
|
||||
The value at tag `01` is the full `https://pay.bml.com.mv/app/...` URL.
|
||||
|
||||
---
|
||||
|
||||
## PayMV QR Format (TLV)
|
||||
|
||||
PayMV QRs (static, PayMV-native) use a decimal TLV encoding (not BER-TLV):
|
||||
|
||||
```
|
||||
<2-digit decimal tag><2-digit decimal length><value>...
|
||||
```
|
||||
|
||||
### Root-level tags (key fields for scanning)
|
||||
|
||||
| Tag | Field |
|
||||
|---|---|
|
||||
| `26` | Merchant account information (container) |
|
||||
| `54` | Transaction amount |
|
||||
| `59` | Merchant / recipient name |
|
||||
| `62` | Additional data (container) |
|
||||
|
||||
### Sub-tags
|
||||
|
||||
| Parent | Tag | Field |
|
||||
|---|---|---|
|
||||
| `26` | `03` | Account number |
|
||||
| `62` | `08` | Payment purpose / reference |
|
||||
|
||||
> For the full PayMV QR format spec including generation (receive-payment QRs), acquirer BIC mapping, CRC algorithm, and all tags — see [PayMV QR Format](../thijooree/18-paymv-qr-format.md).
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Resolve QR to Merchant Details
|
||||
|
||||
### Endpoint
|
||||
|
||||
```
|
||||
GET https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/{base64Url}
|
||||
```
|
||||
|
||||
`{base64Url}` is the full QR URL (e.g. `https://pay.bml.com.mv/app/...`) base64-encoded with standard encoding (with padding).
|
||||
|
||||
### Headers
|
||||
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `Authorization` | `Bearer <access_token>` |
|
||||
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||
| `x-app-version` | `2.1.44.348` |
|
||||
|
||||
```bash
|
||||
curl --request GET \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/payrequest/<base64Url>' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348'
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"payload": {
|
||||
"trxn_hash": "<base64Url>",
|
||||
"narrative1": "Merchant Name",
|
||||
"narrative2": "Address Line 1",
|
||||
"narrative3": "Address Line 2",
|
||||
"amount": "1.03",
|
||||
"currency": "MVR"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Response Fields
|
||||
|
||||
| Field | Description |
|
||||
|---|---|
|
||||
| `trxn_hash` | The base64 URL — used as `requestId` in payment steps |
|
||||
| `narrative1` | Merchant name |
|
||||
| `narrative2` | Merchant address line 1 |
|
||||
| `narrative3` | Merchant address line 2 |
|
||||
| `amount` | Payment amount (`"0.00"` for static QRS) |
|
||||
| `currency` | Currency code (typically `"MVR"`) |
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Pay (3-Step TOTP Flow)
|
||||
|
||||
All three steps POST to the same endpoint:
|
||||
|
||||
```
|
||||
POST https://www.bankofmaldives.com.mv/internetbanking/api/mobile/walletpayments/pay
|
||||
```
|
||||
|
||||
### Headers
|
||||
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `Authorization` | `Bearer <access_token>` |
|
||||
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||
| `x-app-version` | `2.1.44.348` |
|
||||
| `Content-Type` | `application/json` |
|
||||
| `Accept` | `application/json` |
|
||||
|
||||
### Step 2a — Initiate (no channel)
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "approve",
|
||||
"debitAccount": "<internalAccountId>",
|
||||
"requestId": "<trxn_hash>",
|
||||
"amount": 1.03,
|
||||
"currency": "MVR"
|
||||
}
|
||||
```
|
||||
|
||||
Expected response: `{ "success": true, "code": 99 }` (OTP required)
|
||||
|
||||
> **When this step is used:** the client only calls `preInitiatePayment` for **gateway QRs** — QR URLs that begin with `https://pay.bml.com.mv/app/` (`TransferFragment.kt:349, 1419-1423`). For PayMV-native static QRs (`QRS`), Step 2a is skipped and the flow starts at Step 2b.
|
||||
|
||||
### Step 2b — Request OTP Channel
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "approve",
|
||||
"debitAccount": "<internalAccountId>",
|
||||
"requestId": "<trxn_hash>",
|
||||
"amount": 1.03,
|
||||
"currency": "MVR",
|
||||
"channel": "token"
|
||||
}
|
||||
```
|
||||
|
||||
Expected response: `{ "success": true, "code": 22 }` (OTP generated)
|
||||
|
||||
### Step 2c — Confirm with TOTP
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "approve",
|
||||
"debitAccount": "<internalAccountId>",
|
||||
"requestId": "<trxn_hash>",
|
||||
"amount": 1.03,
|
||||
"currency": "MVR",
|
||||
"channel": "token",
|
||||
"otp": "<TOTP>"
|
||||
}
|
||||
```
|
||||
|
||||
Expected response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 0,
|
||||
"payload": {
|
||||
"merchant": "Merchant Name",
|
||||
"amount": "1.03",
|
||||
"currency": "MVR"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **Currency fallback:** if the server's `payload.currency` is blank, the client falls back to the `currency` value sent in the request (`BmlQrPayClient.kt:148`). The same applies to `merchant` and `amount` at the UI layer.
|
||||
|
||||
On failure:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Payment failed"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Request Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `action` | `string` | Always `"approve"` |
|
||||
| `debitAccount` | `string` | Internal account UUID (not the display account number) — from dashboard `internalId` field |
|
||||
| `requestId` | `string` | The `trxn_hash` from the payrequest lookup |
|
||||
| `amount` | `number` | Payment amount as a number (e.g. `1.03`) |
|
||||
| `currency` | `string` | Currency code (e.g. `"MVR"`) |
|
||||
| `channel` | `string` | `"token"` — present in steps 2b and 2c only |
|
||||
| `otp` | `string` | TOTP code — present in step 2c only |
|
||||
|
||||
> The `debitAccount` field takes the internal UUID from the dashboard response, **not** the displayed account number. See [Dashboard](04-dashboard.md) for the account object structure.
|
||||
|
||||
---
|
||||
|
||||
## OTP
|
||||
|
||||
The OTP is a standard TOTP (RFC 6238, SHA-1, 30-second window, 6 digits) derived from the stored BML authenticator seed — the same seed used for login 2FA.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Valid `access_token` from [OAuth Token Exchange](03-oauth-token.md)
|
||||
- TOTP seed enrolled via BML app
|
||||
- Account `internalId` from [Dashboard](04-dashboard.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← Tap-to-Pay](12-tap-to-pay.md) **Next →** [Notifications](14-notifications.md)
|
||||
@@ -0,0 +1,171 @@
|
||||
# Notifications
|
||||
|
||||
In-app notifications (transaction alerts, security events, marketing) are served from a separate host. Notifications are fetched in pages and can be bulk-marked as read.
|
||||
|
||||
The polling service runs in the background and posts an Android system notification for each unseen item (`service/NotificationPollingService.kt:64`).
|
||||
|
||||
---
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
https://app.bankofmaldives.com.mv/api/v2
|
||||
```
|
||||
|
||||
Distinct from the main `internetbanking/api/mobile` host, but uses the same Bearer token.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Valid `access_token` from [OAuth Token Exchange](03-oauth-token.md)
|
||||
|
||||
---
|
||||
|
||||
## Fetch Notifications
|
||||
|
||||
```
|
||||
GET https://app.bankofmaldives.com.mv/api/v2/notifications?group={group}&page={page}
|
||||
```
|
||||
|
||||
### Query Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|---|---|---|
|
||||
| `group` | `string` | Filter by group — `ALL` (default), or a specific group (e.g. `ALERTS`) |
|
||||
| `page` | `int` | 1-based page number |
|
||||
|
||||
### Headers
|
||||
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `Authorization` | `Bearer <access_token>` |
|
||||
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||
| `x-app-version` | `2.1.44.348` |
|
||||
|
||||
```bash
|
||||
curl --request GET \
|
||||
--url 'https://app.bankofmaldives.com.mv/api/v2/notifications?group=ALL&page=1' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348'
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"total": 137,
|
||||
"payload": [
|
||||
{
|
||||
"id": "abc123",
|
||||
"group": "ALERTS",
|
||||
"type": "TRANSACTION",
|
||||
"title": "Transaction Alert",
|
||||
"message": "MVR 100.00 debited from 7730000000001",
|
||||
"created_at": "2026-05-16T15:10:25",
|
||||
"is_read": false,
|
||||
"data": {
|
||||
"account_number": "7730000000001",
|
||||
"amount": "100.00",
|
||||
"currency": "MVR",
|
||||
"reference": "FT20260516123456"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Top-level Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `success` | `bool` | `true` on success |
|
||||
| `total` | `int` | Total notification count across all pages |
|
||||
| `payload` | `array` | List of notifications for this page |
|
||||
|
||||
### Notification Object
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `id` | `string` | Unique notification ID |
|
||||
| `group` | `string` | Logical grouping (e.g. `ALERTS`) — also the value passed back to the `group` filter |
|
||||
| `type` | `string` | Sub-type within the group (e.g. `TRANSACTION`) |
|
||||
| `title` | `string` | Short headline |
|
||||
| `message` | `string` | Body text |
|
||||
| `created_at` | `string` | Timestamp — `yyyy-MM-dd'T'HH:mm:ss` (no timezone) |
|
||||
| `is_read` | `bool` | Read state |
|
||||
| `data` | `object?` | Optional structured detail payload — fields vary by type |
|
||||
|
||||
### `data` Field Flattening
|
||||
|
||||
Where present, the `data` object is flattened into the notification's detail view as key-value rows. The client transforms each `data` key with underscore → space and title-case (`BmlNotificationsClient.kt:93-94`):
|
||||
|
||||
```
|
||||
"account_number" → "Account Number"
|
||||
"reference" → "Reference"
|
||||
```
|
||||
|
||||
Three synthetic rows are prepended:
|
||||
|
||||
| Row | Value |
|
||||
|---|---|
|
||||
| `Bank` | `BML` |
|
||||
| `Group` | from `group` field |
|
||||
| `Type` | from `type` field |
|
||||
|
||||
---
|
||||
|
||||
## Mark All Read
|
||||
|
||||
```
|
||||
PUT https://app.bankofmaldives.com.mv/api/v2/notifications/read
|
||||
```
|
||||
|
||||
### Headers
|
||||
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `Authorization` | `Bearer <access_token>` |
|
||||
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||
| `x-app-version` | `2.1.44.348` |
|
||||
| `accept` | `application/json` |
|
||||
| `Content-Type` | `application/json` |
|
||||
|
||||
### Request Body
|
||||
|
||||
```json
|
||||
{
|
||||
"all": true
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
curl --request PUT \
|
||||
--url 'https://app.bankofmaldives.com.mv/api/v2/notifications/read' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348' \
|
||||
--header 'accept: application/json' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{"all":true}'
|
||||
```
|
||||
|
||||
### Response
|
||||
|
||||
The client treats any 2xx response as success — the response body is discarded.
|
||||
|
||||
---
|
||||
|
||||
## Polling
|
||||
|
||||
`service/NotificationPollingService.kt:64` polls page 1 of every active BML session at a fixed interval, diffs the result against a local cache, and posts an Android system notification for each new item.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← QR Payment](13-qr-payment.md) **Next →** [Card Freeze](15-card-freeze.md)
|
||||
@@ -0,0 +1,121 @@
|
||||
# Card Freeze / Unfreeze
|
||||
|
||||
Lock or unlock a BML card to block / allow new authorisations. The same endpoint handles both actions, distinguished by the `action` field.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
POST https://www.bankofmaldives.com.mv/internetbanking/api/mobile/services/card/freeze
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Valid `access_token` from [OAuth Token Exchange](03-oauth-token.md)
|
||||
- `cardId` is the **internal card UUID** (`BankAccount.internalId`, sourced from the `id` field of a `Card` entry in the [dashboard](04-dashboard.md) response) — NOT the displayed 16-digit card number
|
||||
|
||||
---
|
||||
|
||||
## Request
|
||||
|
||||
### Body
|
||||
|
||||
**Content-Type:** `application/json`
|
||||
|
||||
```json
|
||||
{
|
||||
"card": "<internalId>",
|
||||
"action": "freeze"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `card` | `string` | Internal card UUID — the `id` from the dashboard Card object |
|
||||
| `action` | `string` | `"freeze"` to lock the card; `"unfreeze"` to unlock |
|
||||
|
||||
### Headers
|
||||
|
||||
| Header | Value |
|
||||
|---|---|
|
||||
| `Authorization` | `Bearer <access_token>` |
|
||||
| `User-Agent` | `bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})` |
|
||||
| `x-app-version` | `2.1.44.348` |
|
||||
| `accept` | `application/json` |
|
||||
| `Content-Type` | `application/json` |
|
||||
|
||||
```bash
|
||||
curl --request POST \
|
||||
--url 'https://www.bankofmaldives.com.mv/internetbanking/api/mobile/services/card/freeze' \
|
||||
--header 'Authorization: Bearer <access_token>' \
|
||||
--header 'User-Agent: bml-mobile-banking/348 ({manufacturer}; Android {version}; {model})' \
|
||||
--header 'x-app-version: 2.1.44.348' \
|
||||
--header 'accept: application/json' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{"card":"abc-123-def-456","action":"freeze"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"code": 0,
|
||||
"payload": "Card frozen successfully",
|
||||
"message": ""
|
||||
}
|
||||
```
|
||||
|
||||
### Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `success` | `bool` | `true` on success |
|
||||
| `code` | `int` | `0` on success; non-zero indicates an error |
|
||||
| `payload` | `string` | Human-readable confirmation text (may be blank) |
|
||||
| `message` | `string` | Fallback error/info text |
|
||||
|
||||
Success is determined by **both** `success == true` AND `code == 0` (`BmlCardClient.kt:46`). Either condition alone is not enough.
|
||||
|
||||
### Display Message
|
||||
|
||||
The client prefers `payload` for the confirmation text and falls back to `message` when `payload` is blank (`BmlCardClient.kt:49`):
|
||||
|
||||
```
|
||||
payload (if non-blank) → fallback message
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Failure
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"code": 1,
|
||||
"payload": "",
|
||||
"message": "Card cannot be frozen at this time"
|
||||
}
|
||||
```
|
||||
|
||||
Returned with HTTP `200` for application-level errors. The `message` (or `payload`) field contains the reason.
|
||||
|
||||
### Server / Auth Errors
|
||||
|
||||
| HTTP Code | Behaviour |
|
||||
|---|---|
|
||||
| `401` / `419` | Throws `AuthExpiredException` — refresh the token or re-login |
|
||||
| `5xx` | Throws `BankServerException("BML")` — server-side failure, retry |
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
[← Notifications](14-notifications.md)
|
||||
@@ -25,6 +25,7 @@ The login process is stateful and must be executed in order:
|
||||
| Web login / OAuth | `https://www.bankofmaldives.com.mv/internetbanking` |
|
||||
| REST API (authenticated) | `https://www.bankofmaldives.com.mv/internetbanking/api/mobile` |
|
||||
| Foreign limits API | `https://app.bankofmaldives.com.mv/api/v2` |
|
||||
| Notifications API | `https://app.bankofmaldives.com.mv/api/v2` |
|
||||
|
||||
---
|
||||
|
||||
@@ -188,6 +189,10 @@ The access token expires after `expires_in` seconds (typically 3600). On a `401`
|
||||
| 9 | [Contacts](09-contacts.md) | Saved beneficiaries — list, save, delete |
|
||||
| 10 | [Account Validation](10-validate.md) | Validate BML accounts, aliases, and MIB accounts |
|
||||
| 11 | [Foreign Limits](11-foreign-limits.md) | USD foreign transaction limits by card and channel |
|
||||
| 12 | [Tap-to-Pay](12-tap-to-pay.md) | NFC HCE contactless payment — token fetch and EMV APDU exchange |
|
||||
| 13 | [QR Payment](13-qr-payment.md) | PayMV QR payment — QR formats, payrequest lookup, 3-step pay flow |
|
||||
| 14 | [Notifications](14-notifications.md) | Notifications list, mark-as-read, and polling |
|
||||
| 15 | [Card Freeze](15-card-freeze.md) | Freeze / unfreeze a BML card |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,112 +1,43 @@
|
||||
# Profile Picture
|
||||
|
||||
Fetch the authenticated user's profile picture. The endpoint redirects to the actual image URL.
|
||||
Fahipay profile pictures are **stored locally** by the app. There is no Fahipay endpoint involved.
|
||||
|
||||
The official Fahipay app exposes `GET https://fahipay.mv/images/profiles/picture/?t={timestamp}` (a 302 redirect to the actual image), but this client never calls it — Fahipay accounts get a user-set picture saved on the device only.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint
|
||||
## Storage
|
||||
|
||||
```
|
||||
GET https://fahipay.mv/images/profiles/picture/?t={timestamp}
|
||||
```
|
||||
`util/ProfileImageStore.kt` — keyed file storage under `filesDir/profile_images/`.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Valid `authID` from [login](01-login.md) or [OTP](02-otp.md)
|
||||
- Valid `__Secure-sess` session cookie
|
||||
|
||||
---
|
||||
|
||||
## Request
|
||||
|
||||
### Headers
|
||||
|
||||
| Header | Value |
|
||||
| Bank | Key |
|
||||
|---|---|
|
||||
| `authid` | `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` |
|
||||
| `User-Agent` | `okhttp/4.12.0` |
|
||||
| `Accept-Encoding` | `gzip` |
|
||||
| `Connection` | `Keep-Alive` |
|
||||
| `Cookie` | `__Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` |
|
||||
| Fahipay | `fahipay_{loginId}` |
|
||||
| BML | `bml_{profileId}` |
|
||||
| MIB | (n/a — fetched from server via [P41](../mibapi/02-login.md)) |
|
||||
|
||||
### Query Parameters
|
||||
Helpers:
|
||||
|
||||
| Parameter | Description | Example |
|
||||
|---|---|---|
|
||||
| `t` | Cache-busting timestamp string | `Sat May 16 2026 14:57:52 GMT+0500` |
|
||||
```kotlin
|
||||
ProfileImageStore.fahipayKey(loginId) // "fahipay_abc123"
|
||||
ProfileImageStore.save(context, key, bitmap)
|
||||
ProfileImageStore.load(context, key) // Bitmap?
|
||||
ProfileImageStore.delete(context, key)
|
||||
```
|
||||
|
||||
The `t` parameter is a URL-encoded timestamp used to prevent browser caching. The value can be any string — the server ignores it for routing purposes.
|
||||
Files are JPEGs at quality 90. The filename is the key with non-alphanumerics replaced by `_`.
|
||||
|
||||
---
|
||||
|
||||
## curl Example
|
||||
## UI entry points
|
||||
|
||||
```bash
|
||||
curl --request GET \
|
||||
--url 'https://fahipay.mv/images/profiles/picture/?t=Sat%20Jan%2001%202026%2012:00:00%20GMT+0500' \
|
||||
--compressed \
|
||||
--header 'Accept-Encoding: gzip' \
|
||||
--header 'Connection: Keep-Alive' \
|
||||
--header 'Cookie: __Secure-sess=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
|
||||
--header 'User-Agent: okhttp/4.12.0' \
|
||||
--header 'authid: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
|
||||
```
|
||||
Set/replace/remove a Fahipay profile picture from **Settings → Logins** (`ui/home/SettingsLoginsFragment.kt`). The pencil icon next to a Fahipay login opens a chooser:
|
||||
|
||||
---
|
||||
- Pick from gallery (`ACTION_OPEN_DOCUMENT` image/*)
|
||||
- Take a photo (camera launcher, temp file at `cacheDir/profile_photo_tmp.jpg`)
|
||||
- Remove current picture
|
||||
|
||||
## Response
|
||||
|
||||
### Success
|
||||
|
||||
The server responds with `HTTP 302` and a `Location` header pointing to the actual image URL.
|
||||
|
||||
```
|
||||
HTTP/1.1 302 Found
|
||||
Location: https://fahipay.mv/images/profiles/0000/avatar.jpg?v=0000000000
|
||||
```
|
||||
|
||||
Follow the redirect to download the image. The final response is the raw image bytes (`image/jpeg` or `image/png`).
|
||||
|
||||
---
|
||||
|
||||
### No Picture Set
|
||||
|
||||
If the user has not uploaded a profile picture, the redirect points to a default placeholder image:
|
||||
|
||||
```
|
||||
Location: https://fahipay.mv/images/profiles/default.png
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Error
|
||||
|
||||
If the session is invalid, the server returns `HTTP 401` or redirects to an error page.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- HTTP clients that follow redirects automatically (e.g. `OkHttpClient` with `followRedirects(true)`) will return the image bytes directly.
|
||||
- Use `followRedirects(false)` and read the `Location` header if you need the resolved image URL separately.
|
||||
- The image URL contains the user's `profileID` in the path — this matches the `profileID` field from the [profile response](03-profile.md).
|
||||
- The `v=` query parameter in the image URL is a version/cache key. It changes when the user updates their picture.
|
||||
|
||||
---
|
||||
|
||||
## Suggested Usage
|
||||
|
||||
```
|
||||
timestamp = current time formatted as URL-safe string
|
||||
GET /images/profiles/picture/?t={timestamp}
|
||||
→ 302 Location: <image URL>
|
||||
→ GET <image URL>
|
||||
→ image bytes
|
||||
```
|
||||
|
||||
Cache the downloaded image by `profileID` and re-fetch when the user explicitly refreshes, rather than on every app launch.
|
||||
Saved images are surfaced anywhere the account is shown — accounts list, contact picker, receipts — via a `localProfileImageLoader` that resolves the key with `ProfileImageStore.load`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user