Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
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: |
|
run: |
|
||||||
echo "${{ secrets.KEYSTORE_BASE64 }}" | tr -d '\n' | base64 -d > app/key.jks
|
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 "${{ 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
|
- name: Build APK
|
||||||
working-directory: .build/release
|
working-directory: .build/release
|
||||||
|
|||||||
Generated
+2
-2
@@ -4,10 +4,10 @@
|
|||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
<DropdownSelection timestamp="2026-05-28T18:41:19.777722821Z">
|
<DropdownSelection timestamp="2026-06-03T08:28:30.389803148Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=4254e2f" />
|
<DeviceId pluginId="Default" identifier="serial=10.0.1.245:5555;connection=d182cf37" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ A native Android client for Maldivian banking services. It is a pure client: req
|
|||||||
|
|
||||||
## Privacy
|
## 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
|
## Disclaimer
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -1,8 +1,18 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
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 {
|
android {
|
||||||
namespace = "sh.sar.basedbank"
|
namespace = "sh.sar.basedbank"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
@@ -11,10 +21,13 @@ android {
|
|||||||
applicationId = "sh.sar.basedbank"
|
applicationId = "sh.sar.basedbank"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 11
|
versionCode = 17
|
||||||
versionName = "1.0.12"
|
versionName = "1.0.18"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
buildConfigField("String", "ACCOUNT_MVR", "\"${localOrEnv("account.mvr", "ACCOUNT_MVR")}\"")
|
||||||
|
buildConfigField("String", "ACCOUNT_USD", "\"${localOrEnv("account.usd", "ACCOUNT_USD")}\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -49,6 +62,7 @@ android {
|
|||||||
}
|
}
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding = true
|
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,7 @@
|
|||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.NFC" />
|
<uses-permission android:name="android.permission.NFC" />
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
|
||||||
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
|
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
|
||||||
|
|
||||||
@@ -81,6 +82,20 @@
|
|||||||
android:resource="@xml/bml_aid_list" />
|
android:resource="@xml/bml_aid_list" />
|
||||||
</service>
|
</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
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
@@ -124,8 +124,17 @@ class LockActivity : AppCompatActivity() {
|
|||||||
else
|
else
|
||||||
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
||||||
val btn = MaterialButton(this, null, style).apply {
|
val btn = MaterialButton(this, null, style).apply {
|
||||||
text = key
|
if (key == "⌫" || key == "✓") {
|
||||||
textSize = 24f
|
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
|
insetTop = 0; insetBottom = 0
|
||||||
minimumWidth = 0; minimumHeight = 0
|
minimumWidth = 0; minimumHeight = 0
|
||||||
cornerRadius = btnSize / 2
|
cornerRadius = btnSize / 2
|
||||||
@@ -279,10 +288,12 @@ class LockActivity : AppCompatActivity() {
|
|||||||
val navDest = intent.getIntExtra("nav_destination", -1)
|
val navDest = intent.getIntExtra("nav_destination", -1)
|
||||||
val autoScan = intent.getBooleanExtra("auto_scan", false)
|
val autoScan = intent.getBooleanExtra("auto_scan", false)
|
||||||
val autoTapMode = intent.getBooleanExtra("auto_tap_mode", false)
|
val autoTapMode = intent.getBooleanExtra("auto_tap_mode", false)
|
||||||
|
val shareQrText = intent.getStringExtra("share_qr_text")
|
||||||
startActivity(Intent(this, HomeActivity::class.java).apply {
|
startActivity(Intent(this, HomeActivity::class.java).apply {
|
||||||
if (navDest != -1) putExtra("nav_destination", navDest)
|
if (navDest != -1) putExtra("nav_destination", navDest)
|
||||||
if (autoScan) putExtra("auto_scan", true)
|
if (autoScan) putExtra("auto_scan", true)
|
||||||
if (autoTapMode) putExtra("auto_tap_mode", true)
|
if (autoTapMode) putExtra("auto_tap_mode", true)
|
||||||
|
if (shareQrText != null) putExtra("share_qr_text", shareQrText)
|
||||||
})
|
})
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,28 @@ import sh.sar.basedbank.R
|
|||||||
|
|
||||||
class MainActivity : AppCompatActivity() {
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
@@ -21,6 +43,17 @@ class MainActivity : AppCompatActivity() {
|
|||||||
val store = CredentialStore(this)
|
val store = CredentialStore(this)
|
||||||
val hasCredentials = store.hasMibCredentials() || store.hasBmlCredentials() || store.hasFahipayCredentials()
|
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) {
|
val navDestination = when (intent?.action) {
|
||||||
"sh.sar.basedbank.OPEN_TRANSFER" -> R.id.nav_transfer
|
"sh.sar.basedbank.OPEN_TRANSFER" -> R.id.nav_transfer
|
||||||
"sh.sar.basedbank.OPEN_SCAN_QR" -> 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 (navDestination != -1) putExtra("nav_destination", navDestination)
|
||||||
if (autoScan) putExtra("auto_scan", true)
|
if (autoScan) putExtra("auto_scan", true)
|
||||||
if (autoTapMode) putExtra("auto_tap_mode", true)
|
if (autoTapMode) putExtra("auto_tap_mode", true)
|
||||||
|
if (shareQrText != null) putExtra("share_qr_text", shareQrText)
|
||||||
})
|
})
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,46 @@ class BmlHistoryClient {
|
|||||||
} catch (_: Exception) { emptyList() }
|
} catch (_: Exception) { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
if (code in 500..599) throw BankServerException("BML")
|
||||||
|
return try {
|
||||||
|
val root = JSONObject(json)
|
||||||
|
if (!root.optBoolean("success")) return emptyList()
|
||||||
|
val payload = root.optJSONArray("payload") ?: return emptyList()
|
||||||
|
(0 until payload.length()).map { i ->
|
||||||
|
val item = payload.getJSONObject(i)
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
|
||||||
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
|
// "12-05-2026 041675" → first 4 digits of time part as HH:mm
|
||||||
private fun parsePurchaseNarrative1(narrative1: String): String? {
|
private fun parsePurchaseNarrative1(narrative1: String): String? {
|
||||||
return try {
|
return try {
|
||||||
|
|||||||
@@ -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 {
|
try {
|
||||||
val json = JSONObject(bodyStr)
|
val json = JSONObject(bodyStr)
|
||||||
if (!json.optBoolean("success")) {
|
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 {
|
} else {
|
||||||
val payload = json.optJSONObject("payload")
|
val payload = json.optJSONObject("payload")
|
||||||
BmlTransferResult(
|
BmlTransferResult(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
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 = 30
|
||||||
|
): 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ class BmlHostCardEmulatorService : HostApduService() {
|
|||||||
INS_SELECT -> handleSelect(apdu)
|
INS_SELECT -> handleSelect(apdu)
|
||||||
INS_GPO -> handleGpo()
|
INS_GPO -> handleGpo()
|
||||||
INS_READ -> handleReadRecord()
|
INS_READ -> handleReadRecord()
|
||||||
else -> SW_UNKNOWN_ERROR
|
else -> SW_INS_NOT_SUPPORTED
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,8 +56,7 @@ class BmlHostCardEmulatorService : HostApduService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun launchPromptActivity() {
|
private fun launchPromptActivity() {
|
||||||
val intent = Intent("sh.sar.basedbank.TAP_TO_PAY").apply {
|
val intent = Intent(applicationContext, BmlTapToPayActivity::class.java).apply {
|
||||||
setPackage(applicationContext.packageName)
|
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
}
|
}
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
@@ -141,7 +140,7 @@ class BmlHostCardEmulatorService : HostApduService() {
|
|||||||
val data: ByteArray?
|
val data: ByteArray?
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (raw.size < 4) {
|
if (raw.size < 5) {
|
||||||
isError = true; ins = -1; data = null
|
isError = true; ins = -1; data = null
|
||||||
} else {
|
} else {
|
||||||
isError = false
|
isError = false
|
||||||
@@ -166,8 +165,9 @@ class BmlHostCardEmulatorService : HostApduService() {
|
|||||||
0x32,0x50,0x41,0x59,0x2E,0x53,0x59,0x53,0x2E,0x44,0x44,0x46,0x30,0x31
|
0x32,0x50,0x41,0x59,0x2E,0x53,0x59,0x53,0x2E,0x44,0x44,0x46,0x30,0x31
|
||||||
)
|
)
|
||||||
|
|
||||||
private const val SW_OK_HEX = "9000"
|
private const val SW_OK_HEX = "9000"
|
||||||
private val SW_UNKNOWN_ERROR = byteArrayOf(0x6F.toByte(), 0x00.toByte())
|
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 activeToken: BmlWalletToken? = null
|
||||||
@Volatile var onTransactionComplete: ((success: Boolean) -> Unit)? = null
|
@Volatile var onTransactionComplete: ((success: Boolean) -> Unit)? = null
|
||||||
|
|||||||
@@ -27,9 +27,10 @@ class AccountHistoryAdapter(
|
|||||||
|
|
||||||
private sealed class Item {
|
private sealed class Item {
|
||||||
data class DateHeader(val label: String) : 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 val displayItems = mutableListOf<Item>()
|
||||||
private var lastInsertedDateKey = ""
|
private var lastInsertedDateKey = ""
|
||||||
private val imageCache = mutableMapOf<String, Bitmap>()
|
private val imageCache = mutableMapOf<String, Bitmap>()
|
||||||
@@ -37,15 +38,22 @@ class AccountHistoryAdapter(
|
|||||||
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
var onImageNeeded: ((counterpartyName: String) -> Unit)? = null
|
||||||
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
var onIconUrlNeeded: ((url: String) -> Unit)? = null
|
||||||
var onTransferClick: ((BankAccount) -> Unit)? = null
|
var onTransferClick: ((BankAccount) -> Unit)? = null
|
||||||
|
var onDefaultToggle: ((Boolean) -> Unit)? = null
|
||||||
private var hideAmounts: Boolean = false
|
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) {
|
fun setHideAmounts(hide: Boolean) {
|
||||||
if (hideAmounts == hide) return
|
if (hideAmounts == hide) return
|
||||||
hideAmounts = hide
|
hideAmounts = hide
|
||||||
notifyItemChanged(0) // refresh header card
|
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) {
|
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
|
imageCache[counterpartyName] = bitmap
|
||||||
displayItems.forEachIndexed { i, item ->
|
displayItems.forEachIndexed { i, item ->
|
||||||
if (item is Item.Trx && item.transaction.counterpartyName == counterpartyName)
|
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,19 @@ class AccountHistoryAdapter(
|
|||||||
iconUrlCache[url] = bitmap
|
iconUrlCache[url] = bitmap
|
||||||
displayItems.forEachIndexed { i, item ->
|
displayItems.forEachIndexed { i, item ->
|
||||||
if (item is Item.Trx && item.transaction.iconUrl == url)
|
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>) {
|
||||||
|
pendingItems.clear()
|
||||||
|
if (transactions.isNotEmpty()) {
|
||||||
|
pendingItems.add(Item.DateHeader("Pending"))
|
||||||
|
for (trx in transactions) pendingItems.add(Item.Trx(trx, showDate = true))
|
||||||
|
}
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
private var _showLoadingFooter = false
|
private var _showLoadingFooter = false
|
||||||
var showLoadingFooter: Boolean
|
var showLoadingFooter: Boolean
|
||||||
get() = _showLoadingFooter
|
get() = _showLoadingFooter
|
||||||
@@ -122,18 +139,24 @@ class AccountHistoryAdapter(
|
|||||||
displayItems.add(Item.Trx(trx))
|
displayItems.add(Item.Trx(trx))
|
||||||
}
|
}
|
||||||
val added = displayItems.size - oldCount
|
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
|
// 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
|
// 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 {
|
override fun getItemViewType(position: Int) = when {
|
||||||
position == 0 -> TYPE_HEADER
|
position == 0 -> TYPE_HEADER
|
||||||
_showLoadingFooter && position == itemCount - 1 -> TYPE_LOADING
|
_showLoadingFooter && position == itemCount - 1 -> TYPE_LOADING
|
||||||
else -> when (displayItems[position - 1]) {
|
else -> when (itemAt(position)) {
|
||||||
is Item.DateHeader -> TYPE_DATE_HEADER
|
is Item.DateHeader -> TYPE_DATE_HEADER
|
||||||
is Item.Trx -> TYPE_TRANSACTION
|
is Item.Trx -> TYPE_TRANSACTION
|
||||||
}
|
}
|
||||||
@@ -152,8 +175,11 @@ class AccountHistoryAdapter(
|
|||||||
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
|
||||||
when (holder) {
|
when (holder) {
|
||||||
is HeaderVH -> holder.bind(display)
|
is HeaderVH -> holder.bind(display)
|
||||||
is DateHeaderVH -> holder.bind((displayItems[position - 1] as Item.DateHeader).label)
|
is DateHeaderVH -> holder.bind((itemAt(position) as Item.DateHeader).label)
|
||||||
is TransactionVH -> holder.bind((displayItems[position - 1] as Item.Trx).transaction)
|
is TransactionVH -> {
|
||||||
|
val item = itemAt(position) as Item.Trx
|
||||||
|
holder.bind(item.transaction, item.showDate)
|
||||||
|
}
|
||||||
else -> Unit
|
else -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,6 +200,20 @@ class AccountHistoryAdapter(
|
|||||||
b.llHeaderBlocked.visibility = View.GONE
|
b.llHeaderBlocked.visibility = View.GONE
|
||||||
}
|
}
|
||||||
b.btnHeaderTransfer.setOnClickListener { onTransferClick?.invoke(account) }
|
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 +224,7 @@ class AccountHistoryAdapter(
|
|||||||
|
|
||||||
inner class TransactionVH(private val b: ItemTransactionBinding) :
|
inner class TransactionVH(private val b: ItemTransactionBinding) :
|
||||||
RecyclerView.ViewHolder(b.root) {
|
RecyclerView.ViewHolder(b.root) {
|
||||||
fun bind(trx: BankTransaction) {
|
fun bind(trx: BankTransaction, showDate: Boolean = false) {
|
||||||
val isCredit = trx.amount >= 0
|
val isCredit = trx.amount >= 0
|
||||||
val color = sourceColor(trx.source)
|
val color = sourceColor(trx.source)
|
||||||
val name = trx.counterpartyName ?: trx.description
|
val name = trx.counterpartyName ?: trx.description
|
||||||
@@ -220,7 +260,7 @@ class AccountHistoryAdapter(
|
|||||||
b.tvCounterparty.visibility = View.GONE
|
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) {
|
if (hideAmounts) {
|
||||||
b.tvAmount.text = "${trx.currency} ••••••"
|
b.tvAmount.text = "${trx.currency} ••••••"
|
||||||
@@ -267,6 +307,7 @@ class AccountHistoryAdapter(
|
|||||||
private val MIB_FMT = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
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 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_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 TIME_FMT = SimpleDateFormat("h:mm a", Locale.getDefault())
|
||||||
private val FULL_DATE_FMT = SimpleDateFormat("MMM d, yyyy · h:mm a", Locale.getDefault())
|
private val FULL_DATE_FMT = SimpleDateFormat("MMM d, yyyy · h:mm a", Locale.getDefault())
|
||||||
|
|
||||||
@@ -288,6 +329,11 @@ class AccountHistoryAdapter(
|
|||||||
return DATE_HEADER_FMT.format(date)
|
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 {
|
fun formatTime(raw: String): String {
|
||||||
val date = parseDate(raw) ?: return ""
|
val date = parseDate(raw) ?: return ""
|
||||||
return TIME_FMT.format(date)
|
return TIME_FMT.format(date)
|
||||||
|
|||||||
@@ -24,12 +24,15 @@ import sh.sar.basedbank.BasedBankApp
|
|||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.api.models.BankAccount
|
import sh.sar.basedbank.api.models.BankAccount
|
||||||
import sh.sar.basedbank.api.models.BankServerException
|
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.mib.MibContactsClient
|
||||||
import sh.sar.basedbank.api.models.BankTransaction
|
import sh.sar.basedbank.api.models.BankTransaction
|
||||||
import sh.sar.basedbank.api.mib.TransactionCache
|
import sh.sar.basedbank.api.mib.TransactionCache
|
||||||
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
|
import sh.sar.basedbank.databinding.FragmentAccountHistoryBinding
|
||||||
import sh.sar.basedbank.util.AccountHistoryParser
|
import sh.sar.basedbank.util.AccountHistoryParser
|
||||||
|
import sh.sar.basedbank.util.AccountListParser
|
||||||
import sh.sar.basedbank.util.ContactImageCache
|
import sh.sar.basedbank.util.ContactImageCache
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.util.HistoryFetcher
|
import sh.sar.basedbank.util.HistoryFetcher
|
||||||
import sh.sar.basedbank.util.MerchantIconCache
|
import sh.sar.basedbank.util.MerchantIconCache
|
||||||
|
|
||||||
@@ -80,6 +83,23 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
adapter.setHideAmounts(viewModel.hideAmounts.value ?: false)
|
||||||
viewModel.hideAmounts.observe(viewLifecycleOwner) { adapter.setHideAmounts(it) }
|
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.layoutManager = LinearLayoutManager(requireContext())
|
||||||
binding.recyclerView.adapter = adapter
|
binding.recyclerView.adapter = adapter
|
||||||
|
|
||||||
@@ -119,6 +139,7 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
(activity as? HomeActivity)?.setRefreshing(true)
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
loadNextPage()
|
loadNextPage()
|
||||||
|
loadPendingTransactions()
|
||||||
|
|
||||||
binding.swipeRefresh.setOnRefreshListener {
|
binding.swipeRefresh.setOnRefreshListener {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -131,7 +152,12 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.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() {
|
private fun filterAndDisplay() {
|
||||||
@@ -160,6 +186,7 @@ class AccountHistoryFragment : Fragment() {
|
|||||||
binding.emptyView.visibility = View.GONE
|
binding.emptyView.visibility = View.GONE
|
||||||
}
|
}
|
||||||
loadNextPage()
|
loadNextPage()
|
||||||
|
loadPendingTransactions()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadNextPage() {
|
private fun loadNextPage() {
|
||||||
@@ -226,6 +253,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) {
|
private fun loadContactImage(name: String) {
|
||||||
if (!pendingImageNames.add(name)) return
|
if (!pendingImageNames.add(name)) return
|
||||||
val contact = viewModel.contacts.value?.firstOrNull { it.benefNickName == name } ?: return
|
val contact = viewModel.contacts.value?.firstOrNull { it.benefNickName == name } ?: return
|
||||||
|
|||||||
@@ -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.FragmentBmlQrPayBinding
|
||||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.RecentPick
|
||||||
|
import sh.sar.basedbank.util.RecentsCache
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
|
|
||||||
class BmlQrPayFragment : Fragment() {
|
class BmlQrPayFragment : Fragment() {
|
||||||
@@ -150,6 +152,19 @@ class BmlQrPayFragment : Fragment() {
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
merchantInfo = info
|
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)
|
populateMerchant(info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,6 +330,7 @@ class BmlQrPayFragment : Fragment() {
|
|||||||
.setTitle(R.string.bml_qr_payment_success)
|
.setTitle(R.string.bml_qr_payment_success)
|
||||||
.setView(container)
|
.setView(container)
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||||
|
(activity as? HomeActivity)?.triggerRefresh()
|
||||||
requireActivity().onBackPressedDispatcher.onBackPressed()
|
requireActivity().onBackPressedDispatcher.onBackPressed()
|
||||||
}
|
}
|
||||||
.setCancelable(false)
|
.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -168,6 +168,10 @@ class ContactPickerSheetFragment : BottomSheetDialogFragment() {
|
|||||||
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
|
val account = accounts.firstOrNull { it.accountNumber == accountNumber }
|
||||||
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
|
val bundle = bundleOf(KEY_ACCOUNT_NUMBER to accountNumber, KEY_LABEL to label)
|
||||||
when {
|
when {
|
||||||
|
accountNumber.startsWith("bmlqr:") -> {
|
||||||
|
bundle.putString(KEY_SUBTITLE, "BML QR Merchant")
|
||||||
|
bundle.putString(KEY_COLOR, "#0066A1")
|
||||||
|
}
|
||||||
account != null -> {
|
account != null -> {
|
||||||
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
|
bundle.putString(KEY_SUBTITLE, "${account.accountNumber} · ${account.currencyName} ${account.availableBalance}")
|
||||||
bundle.putString(KEY_COLOR, "#FE860E")
|
bundle.putString(KEY_COLOR, "#FE860E")
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ class ContactsFragment : Fragment() {
|
|||||||
colorHex = contact.bankColor,
|
colorHex = contact.bankColor,
|
||||||
imageHash = contact.imageHash
|
imageHash = contact.imageHash
|
||||||
)
|
)
|
||||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, fragment)
|
(requireActivity() as HomeActivity).showWithBackStack(fragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun confirmDelete(contact: ContactDisplay) {
|
private fun confirmDelete(contact: ContactDisplay) {
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import android.app.Activity
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.ImageView
|
import android.widget.ImageView
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
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.MibCard
|
||||||
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
import sh.sar.basedbank.api.mib.MibFinanceDeal
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||||
import sh.sar.basedbank.util.PaymvQrParser
|
|
||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.NfcPaymentUtil
|
||||||
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import sh.sar.basedbank.databinding.FragmentDashboardBinding
|
import sh.sar.basedbank.databinding.FragmentDashboardBinding
|
||||||
import sh.sar.basedbank.databinding.ItemForeignLimitBinding
|
import sh.sar.basedbank.databinding.ItemForeignLimitBinding
|
||||||
@@ -36,21 +37,35 @@ class DashboardFragment : Fragment() {
|
|||||||
private var _binding: FragmentDashboardBinding? = null
|
private var _binding: FragmentDashboardBinding? = null
|
||||||
private val binding get() = _binding!!
|
private val binding get() = _binding!!
|
||||||
private val viewModel: HomeViewModel by activityViewModels()
|
private val viewModel: HomeViewModel by activityViewModels()
|
||||||
|
private var pendingQrCardNumber: String? = null
|
||||||
private var pendingQrAccountNumber: String? = null
|
|
||||||
|
|
||||||
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
private val qrLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult
|
||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
|
val cardNumber = pendingQrCardNumber.also { pendingQrCardNumber = null }
|
||||||
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
||||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||||
(requireActivity() as HomeActivity).navigateTo(
|
(requireActivity() as HomeActivity).navigateTo(
|
||||||
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, pendingQrAccountNumber)
|
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, cardNumber)
|
||||||
)
|
)
|
||||||
} else {
|
} 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 {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
@@ -85,11 +100,11 @@ class DashboardFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
binding.cardPendingFinances.setOnClickListener {
|
binding.cardPendingFinances.setOnClickListener {
|
||||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
|
(activity as? HomeActivity)?.showWithBackStackAndNav(FinancingFragment(), R.id.nav_finances)
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.cardOverdue.setOnClickListener {
|
binding.cardOverdue.setOnClickListener {
|
||||||
(activity as? HomeActivity)?.navigateTo(R.id.nav_finances)
|
(activity as? HomeActivity)?.showWithBackStackAndNav(FinancingFragment(), R.id.nav_finances)
|
||||||
}
|
}
|
||||||
|
|
||||||
val cardAdapter = DashboardCardAdapter()
|
val cardAdapter = DashboardCardAdapter()
|
||||||
@@ -120,7 +135,7 @@ class DashboardFragment : Fragment() {
|
|||||||
|
|
||||||
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
val bottomPaddingBase = (16 * resources.displayMetrics.density).toInt()
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(binding.buttonBar) { v, insets ->
|
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 navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
val extraBottom = if (isBottomNav) 0 else navBar.bottom
|
||||||
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
v.setPadding(v.paddingLeft, v.paddingTop, v.paddingRight, bottomPaddingBase + extraBottom)
|
||||||
@@ -131,13 +146,32 @@ class DashboardFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.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()
|
refreshQuickActions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
requireActivity().findViewById<com.google.android.material.appbar.MaterialToolbar>(R.id.toolbar).logo = null
|
||||||
|
}
|
||||||
|
|
||||||
private fun refreshQuickActions() {
|
private fun refreshQuickActions() {
|
||||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
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) {
|
if (isBottom) {
|
||||||
binding.buttonBar.visibility = View.GONE
|
binding.buttonBar.visibility = View.GONE
|
||||||
return
|
return
|
||||||
@@ -382,7 +416,7 @@ class DashboardFragment : Fragment() {
|
|||||||
if (isMib) {
|
if (isMib) {
|
||||||
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||||
} else {
|
} else {
|
||||||
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
|
pendingQrCardNumber = (item as CardItem.Bml).account.accountNumber
|
||||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -393,11 +427,13 @@ class DashboardFragment : Fragment() {
|
|||||||
if (isMib) {
|
if (isMib) {
|
||||||
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||||
} else {
|
} else {
|
||||||
val accountNumber = (item as CardItem.Bml).account.accountNumber
|
NfcPaymentUtil.checkAndProceed(requireContext()) {
|
||||||
(requireActivity() as HomeActivity).navigateTo(
|
val accountNumber = (item as CardItem.Bml).account.accountNumber
|
||||||
R.id.nav_pay_with_card,
|
(requireActivity() as HomeActivity).navigateTo(
|
||||||
CardsFragment.newInstanceWithAutoTapMode(accountNumber)
|
R.id.nav_pay_with_card,
|
||||||
)
|
CardsFragment.newInstanceWithAutoTapMode(accountNumber)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
private val viewModel: HomeViewModel by viewModels()
|
private val viewModel: HomeViewModel by viewModels()
|
||||||
private lateinit var toggle: ActionBarDrawerToggle
|
private lateinit var toggle: ActionBarDrawerToggle
|
||||||
private var suppressBottomNavCallback = false
|
private var suppressBottomNavCallback = false
|
||||||
|
private var cachedTransferFragment: TransferFragment? = null
|
||||||
|
private val navBackStack = ArrayDeque<Int>()
|
||||||
|
|
||||||
private var backPressedOnce = false
|
private var backPressedOnce = false
|
||||||
private val backPressHandler = Handler(Looper.getMainLooper())
|
private val backPressHandler = Handler(Looper.getMainLooper())
|
||||||
@@ -89,6 +91,10 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
private val warningRunnable = Runnable { showAutolockWarning() }
|
private val warningRunnable = Runnable { showAutolockWarning() }
|
||||||
|
|
||||||
private var isLocked = false
|
private var isLocked = false
|
||||||
|
private var pendingWheelUnlock = false
|
||||||
|
|
||||||
|
private var hasUnreadNotifications = false
|
||||||
|
private var notifMenuItem: MenuItem? = null
|
||||||
|
|
||||||
private val autolockRunnable = Runnable {
|
private val autolockRunnable = Runnable {
|
||||||
countdownTimer?.cancel(); countdownTimer = null
|
countdownTimer?.cancel(); countdownTimer = null
|
||||||
@@ -98,6 +104,21 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
if (securitySet) lock()
|
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() {
|
private fun lock() {
|
||||||
isLocked = true
|
isLocked = true
|
||||||
startActivity(
|
startActivity(
|
||||||
@@ -156,7 +177,7 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
R.id.nav_dashboard -> DashboardFragment()
|
R.id.nav_dashboard -> DashboardFragment()
|
||||||
R.id.nav_accounts -> AccountsFragment()
|
R.id.nav_accounts -> AccountsFragment()
|
||||||
R.id.nav_contacts -> ContactsFragment()
|
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_pay_mv_qr -> PayMvQrFragment()
|
||||||
R.id.nav_more -> MoreFragment()
|
R.id.nav_more -> MoreFragment()
|
||||||
R.id.nav_activities -> ActivitiesFragment()
|
R.id.nav_activities -> ActivitiesFragment()
|
||||||
@@ -238,16 +259,30 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
val navDest = intent.getIntExtra("nav_destination", -1)
|
val navDest = intent.getIntExtra("nav_destination", -1)
|
||||||
val autoScan = intent.getBooleanExtra("auto_scan", false)
|
val autoScan = intent.getBooleanExtra("auto_scan", false)
|
||||||
val autoTapMode = intent.getBooleanExtra("auto_tap_mode", false)
|
val autoTapMode = intent.getBooleanExtra("auto_tap_mode", false)
|
||||||
if (navDest != -1) {
|
val shareQrText = intent.getStringExtra("share_qr_text")
|
||||||
val fragment = when {
|
when {
|
||||||
autoScan && navDest == R.id.nav_transfer -> TransferFragment.newInstanceWithAutoScan()
|
shareQrText != null -> {
|
||||||
autoTapMode && navDest == R.id.nav_pay_with_card -> CardsFragment.newInstanceWithAutoTapMode()
|
show(DashboardFragment())
|
||||||
else -> null
|
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
|
// Let CardsFragment handle back if in manage mode
|
||||||
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
|
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
|
||||||
if (currentFrag is CardsFragment && currentFrag.onBackPressed()) return
|
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)
|
// Pop fragment back stack if there's anything on it (e.g. showWithBackStack)
|
||||||
if (supportFragmentManager.backStackEntryCount > 0) {
|
if (supportFragmentManager.backStackEntryCount > 0) {
|
||||||
supportFragmentManager.popBackStack()
|
supportFragmentManager.popBackStack()
|
||||||
|
navBackStack.removeLastOrNull()?.let { updateNavSelection(it) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// In bottom nav mode, pressing back navigates up the hierarchy
|
// In bottom nav mode, pressing back navigates up the hierarchy
|
||||||
val isBottomNav = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
|
if (navMode == NavCustomization.NAV_MODE_BOTTOM && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
|
||||||
if (isBottomNav && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
|
|
||||||
// Sub-page reached via More (e.g. Settings, Activities) — go back to More
|
// Sub-page reached via More (e.g. Settings, Activities) — go back to More
|
||||||
if (binding.bottomNavigation.selectedItemId == R.id.nav_more && currentFrag !is MoreFragment) {
|
if (binding.bottomNavigation.selectedItemId == R.id.nav_more && currentFrag !is MoreFragment) {
|
||||||
show(MoreFragment())
|
show(MoreFragment())
|
||||||
@@ -324,21 +385,44 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
.commit()
|
.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() {
|
fun applyNavMode() {
|
||||||
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
when (NavCustomization.getNavMode(prefs)) {
|
||||||
if (isBottom) {
|
NavCustomization.NAV_MODE_BOTTOM -> {
|
||||||
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
||||||
toggle.isDrawerIndicatorEnabled = false
|
toggle.isDrawerIndicatorEnabled = false
|
||||||
supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
supportActionBar?.setDisplayHomeAsUpEnabled(false)
|
||||||
binding.bottomNavigation.visibility = View.VISIBLE
|
binding.bottomNavigation.visibility = View.VISIBLE
|
||||||
rebuildBottomNav(prefs)
|
rebuildBottomNav(prefs)
|
||||||
applyNavLabelVisibility()
|
applyNavLabelVisibility()
|
||||||
} else {
|
}
|
||||||
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_UNLOCKED)
|
NavCustomization.NAV_MODE_CIRCULAR -> {
|
||||||
toggle.isDrawerIndicatorEnabled = true
|
binding.drawerLayout.setDrawerLockMode(androidx.drawerlayout.widget.DrawerLayout.LOCK_MODE_LOCKED_CLOSED)
|
||||||
toggle.syncState()
|
toggle.isDrawerIndicatorEnabled = false
|
||||||
binding.bottomNavigation.visibility = View.GONE
|
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) {
|
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) {
|
val dest = fragment ?: when (itemId) {
|
||||||
R.id.nav_dashboard -> DashboardFragment()
|
R.id.nav_dashboard -> DashboardFragment()
|
||||||
R.id.nav_accounts -> AccountsFragment()
|
R.id.nav_accounts -> AccountsFragment()
|
||||||
R.id.nav_contacts -> ContactsFragment()
|
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_pay_mv_qr -> PayMvQrFragment()
|
||||||
R.id.nav_activities -> ActivitiesFragment()
|
R.id.nav_activities -> ActivitiesFragment()
|
||||||
R.id.nav_transfer_history -> TransferHistoryFragment()
|
R.id.nav_transfer_history -> TransferHistoryFragment()
|
||||||
@@ -389,25 +477,16 @@ fun applyNavLabelVisibility() {
|
|||||||
R.id.nav_otp -> OtpFragment()
|
R.id.nav_otp -> OtpFragment()
|
||||||
R.id.nav_settings -> SettingsFragment()
|
R.id.nav_settings -> SettingsFragment()
|
||||||
R.id.nav_pay_with_card -> CardsFragment()
|
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 }
|
else -> { Toast.makeText(this, R.string.work_in_progress, Toast.LENGTH_SHORT).show(); return }
|
||||||
}
|
}
|
||||||
show(dest)
|
show(dest)
|
||||||
binding.navigationView.setCheckedItem(itemId)
|
updateNavSelection(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 setBottomNavVisible(visible: Boolean) {
|
fun setBottomNavVisible(visible: Boolean) {
|
||||||
val isBottom = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
|
val prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
if (isBottom) {
|
if (NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM) {
|
||||||
binding.bottomNavigation.visibility = if (visible) View.VISIBLE else View.GONE
|
binding.bottomNavigation.visibility = if (visible) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -436,6 +515,33 @@ fun applyNavLabelVisibility() {
|
|||||||
.commit()
|
.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() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
// Returning from LockActivity — refresh sessions since they may have expired.
|
// Returning from LockActivity — refresh sessions since they may have expired.
|
||||||
@@ -444,6 +550,11 @@ fun applyNavLabelVisibility() {
|
|||||||
pauseTime = 0L
|
pauseTime = 0L
|
||||||
resetAutolockTimer()
|
resetAutolockTimer()
|
||||||
autoRefresh(CredentialStore(this))
|
autoRefresh(CredentialStore(this))
|
||||||
|
if (pendingWheelUnlock) {
|
||||||
|
pendingWheelUnlock = false
|
||||||
|
(supportFragmentManager.findFragmentById(R.id.contentFrame) as? CircularNavFragment)
|
||||||
|
?.unlockWheelLock()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// If we were away long enough to have hit the autolock timeout (e.g. while
|
// If we were away long enough to have hit the autolock timeout (e.g. while
|
||||||
@@ -526,9 +637,19 @@ fun applyNavLabelVisibility() {
|
|||||||
eyeItem?.isVisible = true
|
eyeItem?.isVisible = true
|
||||||
val hidden = viewModel.hideAmounts.value ?: false
|
val hidden = viewModel.hideAmounts.value ?: false
|
||||||
eyeItem?.setIcon(if (hidden) R.drawable.ic_visibility_off else R.drawable.ic_visibility)
|
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
|
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 {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
if (item.itemId == R.id.action_lock) {
|
if (item.itemId == R.id.action_lock) {
|
||||||
val avd = getDrawable(R.drawable.avd_lock) as? android.graphics.drawable.AnimatedVectorDrawable
|
val avd = getDrawable(R.drawable.avd_lock) as? android.graphics.drawable.AnimatedVectorDrawable
|
||||||
@@ -541,6 +662,10 @@ fun applyNavLabelVisibility() {
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
if (item.itemId == R.id.action_notifications) {
|
||||||
|
openNotificationsSheet()
|
||||||
|
return true
|
||||||
|
}
|
||||||
if (item.itemId == R.id.action_hide_amounts) {
|
if (item.itemId == R.id.action_hide_amounts) {
|
||||||
val newHidden = !(viewModel.hideAmounts.value ?: false)
|
val newHidden = !(viewModel.hideAmounts.value ?: false)
|
||||||
viewModel.hideAmounts.value = newHidden
|
viewModel.hideAmounts.value = newHidden
|
||||||
@@ -554,6 +679,16 @@ fun applyNavLabelVisibility() {
|
|||||||
return super.onOptionsItemSelected(item)
|
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() {
|
fun relogin() {
|
||||||
val store = CredentialStore(this)
|
val store = CredentialStore(this)
|
||||||
|
|||||||
@@ -7,6 +7,20 @@ import sh.sar.basedbank.R
|
|||||||
|
|
||||||
object NavCustomization {
|
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(
|
data class NavItemDef(
|
||||||
val id: Int,
|
val id: Int,
|
||||||
val key: String,
|
val key: String,
|
||||||
@@ -62,8 +76,31 @@ object NavCustomization {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Items that belong in the "More" screen — those not occupying a bottom nav slot. */
|
/** 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> {
|
fun getMoreItems(prefs: SharedPreferences): List<NavItemDef> {
|
||||||
|
if (getNavMode(prefs) == NAV_MODE_CIRCULAR) return getCircularMoreItems(prefs)
|
||||||
val slots = getSlots(prefs).toSet()
|
val slots = getSlots(prefs).toSet()
|
||||||
return ALL_SWAPPABLE.filter { it.id !in slots }
|
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,568 @@
|
|||||||
|
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.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.core.view.isVisible
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 refreshFromNetwork() {
|
||||||
|
val bmlSessions = app.bmlSessions.toMap()
|
||||||
|
val mibSessions = app.mibSessions.toMap()
|
||||||
|
|
||||||
|
lifecycleScope.launch {
|
||||||
|
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 result = withContext(Dispatchers.IO) {
|
||||||
|
mibClient.fetchUntilEnough(session, loginId)
|
||||||
|
}
|
||||||
|
if (result.items.isNotEmpty() && isAdded) {
|
||||||
|
val readIds = NotificationsCache.getMibReadIds(requireContext())
|
||||||
|
val resolved = result.items.map { it.copy(isRead = it.id in readIds) }
|
||||||
|
allNotifications.removeAll { it.bank == "MIB" && it.loginId == loginId }
|
||||||
|
allNotifications.addAll(resolved)
|
||||||
|
allNotifications.sortByDescending { it.timestampMs }
|
||||||
|
mibNextStart[loginId] = result.nextStart
|
||||||
|
mibDone[loginId] = result.nextStart > result.totalCount
|
||||||
|
NotificationsCache.saveMib(requireContext(), loginId, result.items)
|
||||||
|
refreshAdapters()
|
||||||
|
broadcastUnread()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
val start = mibNextStart[loginId] ?: 1
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
mibClient.fetchActivity(session, loginId, start, start + 29)
|
||||||
|
}
|
||||||
|
if (result.items.isNotEmpty() && isAdded) {
|
||||||
|
val readIds = NotificationsCache.getMibReadIds(requireContext())
|
||||||
|
val resolved = result.items.map { it.copy(isRead = it.id in readIds) }
|
||||||
|
allNotifications.addAll(resolved.filter { n -> allNotifications.none { it.id == n.id } })
|
||||||
|
allNotifications.sortByDescending { it.timestampMs }
|
||||||
|
mibNextStart[loginId] = result.nextStart
|
||||||
|
mibDone[loginId] = result.nextStart > result.totalCount
|
||||||
|
val allForLogin = allNotifications.filter { it.bank == "MIB" && it.loginId == loginId }
|
||||||
|
NotificationsCache.saveMib(requireContext(), loginId, allForLogin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingMore = false
|
||||||
|
if (isAdded) 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>()
|
||||||
|
|
||||||
|
fun update(filtered: List<AppNotification>) {
|
||||||
|
displayItems.clear()
|
||||||
|
displayItems.addAll(toGroupedList(filtered))
|
||||||
|
notifyDataSetChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount() = if (displayItems.isEmpty()) 1 else displayItems.size
|
||||||
|
|
||||||
|
override fun getItemViewType(position: Int): Int {
|
||||||
|
if (displayItems.isEmpty()) return 2 // empty
|
||||||
|
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))
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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.Bundle
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import android.app.Activity
|
|
||||||
import android.content.Intent
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.*
|
import android.widget.*
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import androidx.appcompat.content.res.AppCompatResources
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.core.view.ViewCompat
|
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.util.CredentialStore
|
||||||
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
import sh.sar.basedbank.databinding.ItemAccountDropdownBinding
|
||||||
import sh.sar.basedbank.util.AccountListParser
|
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.BmlCardParser
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
import sh.sar.basedbank.util.bmlapi.BmlDashboardParser
|
||||||
import java.io.File
|
import java.io.File
|
||||||
@@ -56,31 +52,6 @@ class PayMvQrFragment : Fragment() {
|
|||||||
private var generateJob: Job? = null
|
private var generateJob: Job? = null
|
||||||
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
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(
|
override fun onCreateView(
|
||||||
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
|
||||||
): View {
|
): View {
|
||||||
@@ -105,9 +76,6 @@ class PayMvQrFragment : Fragment() {
|
|||||||
binding.btnSave.isEnabled = false
|
binding.btnSave.isEnabled = false
|
||||||
binding.btnShare.setOnClickListener { shareQr() }
|
binding.btnShare.setOnClickListener { shareQr() }
|
||||||
binding.btnSave.setOnClickListener { saveQr() }
|
binding.btnSave.setOnClickListener { saveQr() }
|
||||||
binding.btnScanQr.setOnClickListener {
|
|
||||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupDropdown() {
|
private fun setupDropdown() {
|
||||||
@@ -124,6 +92,20 @@ class PayMvQrFragment : Fragment() {
|
|||||||
selectedAccount = picked
|
selectedAccount = picked
|
||||||
scheduleGenerate()
|
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.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.graphics.drawable.GradientDrawable
|
import android.graphics.drawable.GradientDrawable
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
@@ -13,7 +14,6 @@ import android.widget.ImageView
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
|
||||||
import android.view.animation.AccelerateInterpolator
|
import android.view.animation.AccelerateInterpolator
|
||||||
import android.view.animation.DecelerateInterpolator
|
import android.view.animation.DecelerateInterpolator
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
@@ -48,6 +48,7 @@ import sh.sar.basedbank.util.CardsCache
|
|||||||
import sh.sar.basedbank.util.CredentialStore
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||||
|
import sh.sar.basedbank.util.NfcPaymentUtil
|
||||||
import sh.sar.basedbank.util.PaymvQrParser
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
@@ -60,8 +61,37 @@ class CardsFragment : Fragment() {
|
|||||||
private var cards: List<CardItem> = emptyList()
|
private var cards: List<CardItem> = emptyList()
|
||||||
private var currentCardPosition: Int = 0
|
private var currentCardPosition: Int = 0
|
||||||
private var cardWidth: Int = 0
|
private var cardWidth: Int = 0
|
||||||
private var pendingQrAccountNumber: String? = null
|
private var pendingQrCardNumber: String? = null
|
||||||
private var isManageMode: Boolean = false
|
private var isManageMode: 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 isTapMode: Boolean = false
|
||||||
private var tapAnimView: NfcTapAnimationView? = null
|
private var tapAnimView: NfcTapAnimationView? = null
|
||||||
private var autoTapModeTriggered = false
|
private var autoTapModeTriggered = false
|
||||||
@@ -78,20 +108,6 @@ class CardsFragment : Fragment() {
|
|||||||
private lateinit var stackAdapter: CardStackAdapter
|
private lateinit var stackAdapter: CardStackAdapter
|
||||||
private val store by lazy { CredentialStore(requireContext()) }
|
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 {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentCardsBinding.inflate(inflater, container, false)
|
_binding = FragmentCardsBinding.inflate(inflater, container, false)
|
||||||
return binding.root
|
return binding.root
|
||||||
@@ -203,7 +219,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
if (item is CardItem.Mib) {
|
if (item is CardItem.Mib) {
|
||||||
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||||
} else {
|
} else {
|
||||||
pendingQrAccountNumber = (item as CardItem.Bml).account.accountNumber
|
pendingQrCardNumber = (item as CardItem.Bml).account.accountNumber
|
||||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -217,11 +233,13 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
return@setOnClickListener
|
return@setOnClickListener
|
||||||
}
|
}
|
||||||
val bmlItem = item as CardItem.Bml
|
val bmlItem = item as CardItem.Bml
|
||||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
NfcPaymentUtil.checkAndProceed(requireContext()) {
|
||||||
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
showBiometricPromptForTap(bmlItem)
|
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
|
||||||
} else {
|
showBiometricPromptForTap(bmlItem)
|
||||||
setTapMode(true, bmlItem)
|
} else {
|
||||||
|
setTapMode(true, bmlItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,15 +513,37 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
tapAnimView = animView
|
tapAnimView = animView
|
||||||
|
|
||||||
val dp = resources.displayMetrics.density
|
val dp = resources.displayMetrics.density
|
||||||
val cancelBtn = MaterialButton(requireContext(), null,
|
val cancelBtn = (layoutInflater.inflate(R.layout.view_cancel_button, null, false) as MaterialButton).apply {
|
||||||
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
setOnClickListener { setTapMode(false) }
|
||||||
).apply { setText(R.string.cancel); 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 {
|
val cancelWrapper = LinearLayout(requireContext()).apply {
|
||||||
orientation = LinearLayout.VERTICAL
|
orientation = LinearLayout.VERTICAL
|
||||||
gravity = Gravity.CENTER_HORIZONTAL
|
gravity = Gravity.CENTER_HORIZONTAL
|
||||||
setPadding(0, 0, 0, (24 * dp).toInt())
|
setPadding((16 * dp).toInt(), (8 * dp).toInt(), (16 * dp).toInt(), baseCancelPaddingBottom)
|
||||||
addView(cancelBtn)
|
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 {
|
val container = LinearLayout(requireContext()).apply {
|
||||||
orientation = LinearLayout.VERTICAL
|
orientation = LinearLayout.VERTICAL
|
||||||
@@ -514,6 +554,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
addView(animView.apply {
|
addView(animView.apply {
|
||||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 3f)
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 3f)
|
||||||
})
|
})
|
||||||
|
addView(tapDivider)
|
||||||
addView(cancelWrapper.apply {
|
addView(cancelWrapper.apply {
|
||||||
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
|
||||||
})
|
})
|
||||||
@@ -620,10 +661,12 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(true)
|
||||||
val otp = Totp.generate(otpSeed)
|
val otp = Totp.generate(otpSeed)
|
||||||
val result = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
runCatching { BmlTapToPayClient().fetchTokens(session, item.account.internalId, otp) }
|
runCatching { BmlTapToPayClient().fetchTokens(session, item.account.internalId, otp) }
|
||||||
}
|
}
|
||||||
|
(activity as? HomeActivity)?.setRefreshing(false)
|
||||||
val token = result.getOrNull()?.firstOrNull()
|
val token = result.getOrNull()?.firstOrNull()
|
||||||
|
|
||||||
if (!isTapMode) return@launch // user cancelled while we were fetching
|
if (!isTapMode) return@launch // user cancelled while we were fetching
|
||||||
@@ -643,7 +686,10 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
view?.post {
|
view?.post {
|
||||||
if (!isTapMode) return@post
|
if (!isTapMode) return@post
|
||||||
setTapMode(false)
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -699,11 +745,13 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
currentCardPosition = pos
|
currentCardPosition = pos
|
||||||
binding.rvCards.scrollToPosition(pos)
|
binding.rvCards.scrollToPosition(pos)
|
||||||
}
|
}
|
||||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
NfcPaymentUtil.checkAndProceed(requireContext()) {
|
||||||
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
showBiometricPromptForTap(targetCard)
|
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
|
||||||
} else {
|
showBiometricPromptForTap(targetCard)
|
||||||
setTapMode(true, targetCard)
|
} else {
|
||||||
|
setTapMode(true, targetCard)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -875,7 +923,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
|
|
||||||
val dp = resources.displayMetrics.density
|
val dp = resources.displayMetrics.density
|
||||||
val progress = animator.animatedFraction
|
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,
|
val colorOnSurface = MaterialColors.getColor(this,
|
||||||
com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK)
|
com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK)
|
||||||
@@ -884,70 +932,59 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
val colorSurfaceVariant = MaterialColors.getColor(this,
|
val colorSurfaceVariant = MaterialColors.getColor(this,
|
||||||
com.google.android.material.R.attr.colorSurfaceVariant, android.graphics.Color.LTGRAY)
|
com.google.android.material.R.attr.colorSurfaceVariant, android.graphics.Color.LTGRAY)
|
||||||
|
|
||||||
// Phone (left of center)
|
// POS terminal (top center)
|
||||||
val phoneW = 36 * dp; val phoneH = 62 * dp
|
val posW = 44 * dp; val posH = 72 * dp
|
||||||
val phoneX = cx - 72 * dp - phoneW; val phoneY = cy - phoneH / 2f
|
val posX = cx - posW / 2f; val posY = cy - 170 * dp
|
||||||
|
|
||||||
// POS terminal (right of center)
|
// Phone (bottom center)
|
||||||
val posW = 30 * dp; val posH = 50 * dp
|
val phoneW = 52 * dp; val phoneH = 90 * dp
|
||||||
val posX = cx + 72 * dp; val posY = cy - posH / 2f
|
val phoneX = cx - phoneW / 2f; val phoneY = cy + 30 * dp
|
||||||
|
|
||||||
// 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
|
|
||||||
|
|
||||||
// POS terminal body
|
// POS terminal body
|
||||||
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
|
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
|
||||||
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 * dp; paint.color = colorOnSurface
|
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * 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)
|
||||||
|
|
||||||
// POS screen
|
// POS screen
|
||||||
paint.style = Paint.Style.FILL; paint.color = colorPrimary; paint.alpha = 70
|
paint.style = Paint.Style.FILL; paint.color = colorPrimary; paint.alpha = 70
|
||||||
canvas.drawRoundRect(posX + 3 * dp, posY + 4 * dp,
|
canvas.drawRoundRect(posX + 4 * dp, posY + 6 * dp,
|
||||||
posX + posW - 3 * dp, posY + posH * 0.45f, 3 * dp, 3 * dp, paint)
|
posX + posW - 4 * dp, posY + posH * 0.45f, 4 * dp, 4 * dp, paint)
|
||||||
paint.alpha = 255
|
paint.alpha = 255
|
||||||
|
|
||||||
// POS card slot
|
// POS card slot
|
||||||
paint.style = Paint.Style.STROKE; paint.strokeWidth = 1.5f * dp; paint.color = colorOnSurface
|
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2 * dp; paint.color = colorOnSurface
|
||||||
canvas.drawLine(posX + 4 * dp, posY + posH * 0.72f, posX + posW - 4 * dp, posY + posH * 0.72f, paint)
|
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
|
// Phone body
|
||||||
val gapStart = arcOriginX + 28 * dp
|
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
|
||||||
val gapEnd = posX - 4 * dp
|
canvas.drawRoundRect(phoneX, phoneY, phoneX + phoneW, phoneY + phoneH, 8 * dp, 8 * dp, paint)
|
||||||
val midX = (gapStart + gapEnd) / 2f
|
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * dp; paint.color = colorOnSurface
|
||||||
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * dp
|
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) {
|
for (i in 0..2) {
|
||||||
val p = ((progress + i / 3f) % 1f)
|
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)
|
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
|
paint.alpha = 255
|
||||||
|
|
||||||
// Label
|
// Label
|
||||||
paint.style = Paint.Style.FILL; paint.color = colorOnSurface; paint.alpha = 160
|
paint.style = Paint.Style.FILL; paint.color = colorOnSurface; paint.alpha = 160
|
||||||
paint.textSize = 14 * dp; paint.textAlign = Paint.Align.CENTER
|
paint.textSize = 15 * dp; paint.textAlign = Paint.Align.CENTER
|
||||||
canvas.drawText(context.getString(R.string.card_pay_nfc), cx, cy + 60 * dp, paint)
|
canvas.drawText(context.getString(R.string.card_pay_nfc), cx, phoneY + phoneH + 28 * dp, paint)
|
||||||
paint.alpha = 255; paint.textAlign = Paint.Align.LEFT
|
paint.alpha = 255; paint.textAlign = Paint.Align.LEFT
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ class QrScannerActivity : AppCompatActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
window.statusBarColor = android.graphics.Color.TRANSPARENT
|
||||||
|
window.navigationBarColor = android.graphics.Color.TRANSPARENT
|
||||||
binding = ActivityQrScannerBinding.inflate(layoutInflater)
|
binding = ActivityQrScannerBinding.inflate(layoutInflater)
|
||||||
setContentView(binding.root)
|
setContentView(binding.root)
|
||||||
// Black camera background — always use light (white) system bar icons
|
// 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.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
|
import android.content.res.Configuration
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.InputType
|
import android.text.InputType
|
||||||
@@ -16,6 +17,8 @@ import android.widget.Toast
|
|||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
|
import androidx.appcompat.app.AppCompatDelegate.setApplicationLocales
|
||||||
import androidx.core.os.LocaleListCompat
|
import androidx.core.os.LocaleListCompat
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
import androidx.recyclerview.widget.ItemTouchHelper
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
@@ -36,8 +39,10 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
private lateinit var prefs: SharedPreferences
|
private lateinit var prefs: SharedPreferences
|
||||||
private val slots = mutableListOf<Int>()
|
private val slots = mutableListOf<Int>()
|
||||||
private val quickActions = mutableListOf<Int>()
|
private val quickActions = mutableListOf<Int>()
|
||||||
|
private val circularSlots = mutableListOf<Int>()
|
||||||
private lateinit var slotAdapter: NavItemAdapter
|
private lateinit var slotAdapter: NavItemAdapter
|
||||||
private lateinit var quickActionAdapter: NavItemAdapter
|
private lateinit var quickActionAdapter: NavItemAdapter
|
||||||
|
private lateinit var circularSlotAdapter: NavItemAdapter
|
||||||
|
|
||||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false)
|
_binding = FragmentSettingsAppearanceBinding.inflate(inflater, container, false)
|
||||||
@@ -46,13 +51,30 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
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
|
// Navigation mode
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
val currentMode = NavCustomization.getNavMode(prefs)
|
||||||
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer)
|
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 ->
|
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
if (!isChecked) return@addOnButtonCheckedListener
|
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()
|
(activity as? HomeActivity)?.applyNavMode()
|
||||||
updateShortcutsVisibility()
|
updateShortcutsVisibility()
|
||||||
}
|
}
|
||||||
@@ -63,10 +85,22 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
quickActionAdapter = NavItemAdapter(
|
quickActionAdapter = NavItemAdapter(
|
||||||
items = quickActions,
|
items = quickActions,
|
||||||
onSave = { NavCustomization.saveQuickActions(prefs, 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) {
|
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
|
// Bottom bar shortcuts
|
||||||
@@ -78,10 +112,10 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
NavCustomization.saveSlots(prefs, slots)
|
NavCustomization.saveSlots(prefs, slots)
|
||||||
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
|
(activity as? HomeActivity)?.rebuildBottomNav(prefs)
|
||||||
},
|
},
|
||||||
isEnabled = { prefs.getBoolean("bottom_nav", false) }
|
isEnabled = { NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM }
|
||||||
)
|
)
|
||||||
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) {
|
setupNavItemRecyclerView(binding.rvNavSlots, slotAdapter, slots) {
|
||||||
prefs.getBoolean("bottom_nav", false)
|
NavCustomization.getNavMode(prefs) == NavCustomization.NAV_MODE_BOTTOM
|
||||||
}
|
}
|
||||||
// Show labels toggle
|
// Show labels toggle
|
||||||
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
|
val showLabels = prefs.getBoolean("bottom_nav_show_labels", true)
|
||||||
@@ -102,6 +136,7 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
})
|
})
|
||||||
binding.themeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
binding.themeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
if (!isChecked) return@addOnButtonCheckedListener
|
if (!isChecked) return@addOnButtonCheckedListener
|
||||||
|
val previousKey = prefs.getString("theme", "system")
|
||||||
val (key, mode) = when (checkedId) {
|
val (key, mode) = when (checkedId) {
|
||||||
R.id.btnThemeLight -> "light" to AppCompatDelegate.MODE_NIGHT_NO
|
R.id.btnThemeLight -> "light" to AppCompatDelegate.MODE_NIGHT_NO
|
||||||
R.id.btnThemeDark -> "dark" to AppCompatDelegate.MODE_NIGHT_YES
|
R.id.btnThemeDark -> "dark" to AppCompatDelegate.MODE_NIGHT_YES
|
||||||
@@ -111,6 +146,16 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
AppCompatDelegate.setDefaultNightMode(mode)
|
AppCompatDelegate.setDefaultNightMode(mode)
|
||||||
updateAccentState(key == "system")
|
updateAccentState(key == "system")
|
||||||
updatePitchBlackState(key == "dark")
|
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
|
// Pitch black
|
||||||
@@ -125,7 +170,7 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
// Accent color
|
// Accent color
|
||||||
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
val savedPreset = prefs.getString("accent_preset", ThemeHelper.PRESET_BLUE)
|
||||||
binding.accentToggle.check(when (savedPreset) {
|
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_GREEN -> R.id.btnAccentGreen
|
||||||
ThemeHelper.PRESET_CUSTOM -> R.id.btnAccentCustom
|
ThemeHelper.PRESET_CUSTOM -> R.id.btnAccentCustom
|
||||||
else -> R.id.btnAccentBlue
|
else -> R.id.btnAccentBlue
|
||||||
@@ -191,11 +236,15 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun updateShortcutsVisibility() {
|
private fun updateShortcutsVisibility() {
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
val mode = NavCustomization.getNavMode(prefs)
|
||||||
binding.sectionQuickActions.alpha = if (isBottom) 0.38f else 1f
|
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.sectionBottomBarShortcuts.alpha = if (isBottom) 1f else 0.38f
|
||||||
binding.switchShowLabels.isClickable = isBottom
|
binding.switchShowLabels.isClickable = isBottom
|
||||||
quickActionAdapter.notifyDataSetChanged()
|
quickActionAdapter.notifyDataSetChanged()
|
||||||
|
circularSlotAdapter.notifyDataSetChanged()
|
||||||
slotAdapter.notifyDataSetChanged()
|
slotAdapter.notifyDataSetChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,9 +311,10 @@ class SettingsAppearanceFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
|
private fun showItemPicker(items: MutableList<Int>, slotIndex: Int, adapter: NavItemAdapter) {
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
val mode = NavCustomization.getNavMode(prefs)
|
||||||
if (items === slots && !isBottom) return
|
if (items === slots && mode != NavCustomization.NAV_MODE_BOTTOM) return
|
||||||
if (items === quickActions && isBottom) return
|
if (items === quickActions && mode == NavCustomization.NAV_MODE_BOTTOM) return
|
||||||
|
if (items === circularSlots && mode != NavCustomization.NAV_MODE_CIRCULAR) return
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
|
val otherIds = items.filterIndexed { i, _ -> i != slotIndex }.toSet()
|
||||||
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
|
val available = NavCustomization.ALL_SWAPPABLE.filter { it.id !in otherIds }
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import android.widget.LinearLayout
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
|
|
||||||
@@ -26,12 +28,21 @@ class SettingsFragment : Fragment() {
|
|||||||
SettingsItem(R.drawable.ic_settings_appearance, R.string.settings_appearance, R.string.settings_desc_appearance) { SettingsAppearanceFragment() },
|
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_lock, R.string.settings_privacy_security, R.string.settings_desc_privacy_security) { SettingsSecurityFragment() },
|
||||||
SettingsItem(R.drawable.ic_settings_storage, R.string.settings_storage, R.string.settings_desc_storage) { SettingsStorageFragment() },
|
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 =
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View =
|
||||||
inflater.inflate(R.layout.fragment_settings, container, false)
|
inflater.inflate(R.layout.fragment_settings, container, false)
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
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 list = view.findViewById<LinearLayout>(R.id.settingsList)
|
||||||
val inflater = LayoutInflater.from(requireContext())
|
val inflater = LayoutInflater.from(requireContext())
|
||||||
for (item in items) {
|
for (item in items) {
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import android.widget.TextView
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
@@ -333,6 +335,14 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
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 {
|
binding.btnAddAccount.setOnClickListener {
|
||||||
startActivity(Intent(requireContext(), LoginActivity::class.java))
|
startActivity(Intent(requireContext(), LoginActivity::class.java))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import android.view.LayoutInflater
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.biometric.BiometricManager
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.databinding.FragmentSettingsSecurityBinding
|
import sh.sar.basedbank.databinding.FragmentSettingsSecurityBinding
|
||||||
@@ -22,6 +24,14 @@ class SettingsSecurityFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
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)
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
// Change lock
|
// Change lock
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import android.view.LayoutInflater
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
@@ -31,6 +33,14 @@ class SettingsStorageFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
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 {
|
binding.btnClearCache.setOnClickListener {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
clearAllCaches(ctx)
|
clearAllCaches(ctx)
|
||||||
|
|||||||
@@ -92,9 +92,18 @@ class TransferFragment : Fragment() {
|
|||||||
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
|
// Values: "FAHIPAY_TRANSFER", "RAASTAS", "OOREDOO_BILL"
|
||||||
private var selectedFahipayService: String? = null
|
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)
|
// BML QR merchant payment mode (set when navigated from a card QR scan)
|
||||||
private var bmlQrInfo: BmlQrPayInfo? = null
|
private var bmlQrInfo: BmlQrPayInfo? = null
|
||||||
private var bmlGatewayQr = false // true for pay.bml.com.mv QRs (requires pre-initiate step)
|
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>()
|
private val dropdownProfileImageCache = mutableMapOf<String, Bitmap>()
|
||||||
|
|
||||||
// BML business profile OTP flow state
|
// BML business profile OTP flow state
|
||||||
@@ -139,6 +148,28 @@ class TransferFragment : Fragment() {
|
|||||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||||
return@registerForActivityResult
|
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.amount != null) binding.etAmount.setText(qr.amount)
|
||||||
if (qr.purpose != null) binding.etRemarks.setText(qr.purpose)
|
if (qr.purpose != null) binding.etRemarks.setText(qr.purpose)
|
||||||
prefillToFromContact(qr.accountNumber, "")
|
prefillToFromContact(qr.accountNumber, "")
|
||||||
@@ -191,13 +222,15 @@ class TransferFragment : Fragment() {
|
|||||||
accountNumber: String,
|
accountNumber: String,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
amount: String?,
|
amount: String?,
|
||||||
remarks: String?
|
remarks: String?,
|
||||||
|
fromAccountNumber: String? = null
|
||||||
) = TransferFragment().apply {
|
) = TransferFragment().apply {
|
||||||
arguments = Bundle().apply {
|
arguments = Bundle().apply {
|
||||||
putString(ARG_ACCOUNT, accountNumber)
|
putString(ARG_ACCOUNT, accountNumber)
|
||||||
putString(ARG_NAME, displayName)
|
putString(ARG_NAME, displayName)
|
||||||
putString(ARG_SUBTITLE, accountNumber)
|
putString(ARG_SUBTITLE, accountNumber)
|
||||||
putString(ARG_COLOR, "#607D8B")
|
putString(ARG_COLOR, "#607D8B")
|
||||||
|
if (fromAccountNumber != null) putString(ARG_FROM_ACCOUNT, fromAccountNumber)
|
||||||
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
|
if (amount != null) putString(ARG_AMOUNT_PREFILL, amount)
|
||||||
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
|
if (remarks != null) putString(ARG_REMARKS_PREFILL, remarks)
|
||||||
}
|
}
|
||||||
@@ -221,11 +254,27 @@ class TransferFragment : Fragment() {
|
|||||||
|
|
||||||
childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
|
childFragmentManager.setFragmentResultListener(ContactPickerSheetFragment.REQUEST_KEY, viewLifecycleOwner) { _, bundle ->
|
||||||
val accountNumber = bundle.getString(ContactPickerSheetFragment.KEY_ACCOUNT_NUMBER) ?: return@setFragmentResultListener
|
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 label = bundle.getString(ContactPickerSheetFragment.KEY_LABEL) ?: ""
|
||||||
val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber
|
val subtitle = bundle.getString(ContactPickerSheetFragment.KEY_SUBTITLE) ?: accountNumber
|
||||||
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
|
val colorHex = bundle.getString(ContactPickerSheetFragment.KEY_COLOR) ?: "#607D8B"
|
||||||
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
|
val imageHash = bundle.getString(ContactPickerSheetFragment.KEY_IMAGE_HASH)
|
||||||
prefillToDirectly(accountNumber, label, subtitle, colorHex, imageHash)
|
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 {
|
binding.btnPickContact.setOnClickListener {
|
||||||
@@ -265,9 +314,37 @@ class TransferFragment : Fragment() {
|
|||||||
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
|
if (arguments?.getBoolean(ARG_AUTO_SCAN, false) == true) {
|
||||||
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
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) {
|
private fun lookupBmlQrMerchant(qrUrl: String) {
|
||||||
|
bmlQrLookupAttempted = true
|
||||||
bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/")
|
bmlGatewayQr = qrUrl.startsWith("https://pay.bml.com.mv/app/")
|
||||||
val base64Url = android.util.Base64.encodeToString(
|
val base64Url = android.util.Base64.encodeToString(
|
||||||
qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP)
|
qrUrl.toByteArray(Charsets.UTF_8), android.util.Base64.NO_WRAP)
|
||||||
@@ -292,6 +369,16 @@ class TransferFragment : Fragment() {
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
bmlQrInfo = info
|
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
|
// Auto-select the user's default BML card if no card was pre-selected
|
||||||
if (selectedAccount == null) {
|
if (selectedAccount == null) {
|
||||||
@@ -398,6 +485,35 @@ class TransferFragment : Fragment() {
|
|||||||
updateTransferButton()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,8 +689,21 @@ class TransferFragment : Fragment() {
|
|||||||
|
|
||||||
private fun lookupAccount() {
|
private fun lookupAccount() {
|
||||||
if (selectedAccount == null) {
|
if (selectedAccount == null) {
|
||||||
Toast.makeText(requireContext(), R.string.transfer_select_source_first, Toast.LENGTH_SHORT).show()
|
val defaultNum = CredentialStore(requireContext()).getDefaultAccountNumber()
|
||||||
return
|
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() ?: "")
|
val accountNumber = AccountInputParser.normalize(binding.etTo.text?.toString()?.trim() ?: "")
|
||||||
if (accountNumber.isBlank()) {
|
if (accountNumber.isBlank()) {
|
||||||
@@ -670,6 +799,13 @@ class TransferFragment : Fragment() {
|
|||||||
resolvedAccountNumber = info.accountNumber
|
resolvedAccountNumber = info.accountNumber
|
||||||
resolvedRecipientName = info.accountName
|
resolvedRecipientName = info.accountName
|
||||||
resolvedBankName = info.bankId
|
resolvedBankName = info.bankId
|
||||||
|
savedToSubtitle = "${info.accountNumber} · ${info.bankId}"
|
||||||
|
savedToColorHex = colorHex
|
||||||
|
savedToImageHash = when {
|
||||||
|
matchedAcc?.profileImageHash != null -> matchedAcc.profileImageHash
|
||||||
|
matchedCont?.customerImgHash != null -> matchedCont.customerImgHash
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
if (matchedAcc != null) {
|
if (matchedAcc != null) {
|
||||||
showToCard(matchedAcc)
|
showToCard(matchedAcc)
|
||||||
@@ -802,6 +938,9 @@ class TransferFragment : Fragment() {
|
|||||||
) {
|
) {
|
||||||
resolvedAccountNumber = accountNumber
|
resolvedAccountNumber = accountNumber
|
||||||
resolvedRecipientName = displayName
|
resolvedRecipientName = displayName
|
||||||
|
savedToSubtitle = subtitle
|
||||||
|
savedToColorHex = colorHex
|
||||||
|
savedToImageHash = imageHash
|
||||||
val contacts = viewModel.contacts.value ?: emptyList()
|
val contacts = viewModel.contacts.value ?: emptyList()
|
||||||
resolvedBankName = contacts.firstOrNull { it.benefAccount == accountNumber }?.benefBankName ?: ""
|
resolvedBankName = contacts.firstOrNull { it.benefAccount == accountNumber }?.benefBankName ?: ""
|
||||||
|
|
||||||
@@ -857,13 +996,20 @@ class TransferFragment : Fragment() {
|
|||||||
message: String? = null,
|
message: String? = null,
|
||||||
customView: android.view.View? = null,
|
customView: android.view.View? = null,
|
||||||
biometricSubtitle: String,
|
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())
|
val builder = MaterialAlertDialogBuilder(requireContext())
|
||||||
.setTitle(title)
|
.setTitle(title)
|
||||||
.setPositiveButton(R.string.transfer_confirm) { _, _ -> onConfirmed() }
|
.setPositiveButton(R.string.transfer_confirm, null)
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
.setNegativeButton(android.R.string.cancel) { d, _ -> d.dismiss() }
|
||||||
if (customView != null) builder.setView(customView) else builder.setMessage(message)
|
.setCancelable(false)
|
||||||
|
if (customView != null) builder.setView(frame) else builder.setMessage(message)
|
||||||
val dialog = builder.show()
|
val dialog = builder.show()
|
||||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false)
|
val biometricTransferConfirm = prefs.getBoolean("biometrics_transfer_confirm", false)
|
||||||
@@ -874,8 +1020,7 @@ class TransferFragment : Fragment() {
|
|||||||
val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()),
|
val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()),
|
||||||
object : BiometricPrompt.AuthenticationCallback() {
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||||
dialog.dismiss()
|
onConfirmed(dialog, frame)
|
||||||
onConfirmed()
|
|
||||||
}
|
}
|
||||||
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
|
||||||
if (errorCode != BiometricPrompt.ERROR_CANCELED &&
|
if (errorCode != BiometricPrompt.ERROR_CANCELED &&
|
||||||
@@ -894,6 +1039,10 @@ class TransferFragment : Fragment() {
|
|||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
|
||||||
|
onConfirmed(dialog, frame)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -912,11 +1061,27 @@ class TransferFragment : Fragment() {
|
|||||||
Toast.makeText(requireContext(), R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.transfer_missing_internal_id, Toast.LENGTH_SHORT).show()
|
||||||
return
|
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(
|
showConfirmWithBiometric(
|
||||||
title = getString(R.string.transfer),
|
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}",
|
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
|
return
|
||||||
}
|
}
|
||||||
@@ -971,18 +1136,17 @@ class TransferFragment : Fragment() {
|
|||||||
val isUsdToMvr = currency.equals("USD", ignoreCase = true) && destCurrency.equals("MVR", ignoreCase = true)
|
val isUsdToMvr = currency.equals("USD", ignoreCase = true) && destCurrency.equals("MVR", ignoreCase = true)
|
||||||
val isSrcCredit = src.profileType == "BML_CREDIT"
|
val isSrcCredit = src.profileType == "BML_CREDIT"
|
||||||
|
|
||||||
val mainMsg = "Send $currency $amountStr to $destDisplay?\n\nFrom: ${src.accountBriefName} · ${src.accountNumber}"
|
val doTransfer: (AlertDialog, android.widget.FrameLayout) -> Unit = { dialog, frame ->
|
||||||
|
|
||||||
val doTransfer: () -> Unit = {
|
|
||||||
if (isBmlBusiness) {
|
if (isBmlBusiness) {
|
||||||
// Business profile: async OTP channel selection flow
|
// Business profile: async OTP channel selection flow — dismiss dialog first
|
||||||
|
dialog.dismiss()
|
||||||
startBmlBusinessOtpFlow(
|
startBmlBusinessOtpFlow(
|
||||||
src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks,
|
src, resolvedAccountNumber, destDisplay, amount, amountStr, remarks,
|
||||||
isSrcCard, isDestMib, currency, allAccounts, allContacts, capturedToAvatar
|
isSrcCard, isDestMib, currency, allAccounts, allContacts, capturedToAvatar
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
showProcessingInDialog(dialog, frame)
|
||||||
binding.btnTransfer.isEnabled = false
|
binding.btnTransfer.isEnabled = false
|
||||||
(activity as? HomeActivity)?.setRefreshing(true)
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
|
val (ok, msg, receipt) = withContext(Dispatchers.IO) {
|
||||||
if (!isSrcBml) {
|
if (!isSrcBml) {
|
||||||
@@ -992,14 +1156,15 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
binding.btnTransfer.isEnabled = true
|
binding.btnTransfer.isEnabled = true
|
||||||
(activity as? HomeActivity)?.setRefreshing(false)
|
|
||||||
if (ok && receipt != null) {
|
if (ok && receipt != null) {
|
||||||
ReceiptStore.save(requireContext(), receipt)
|
ReceiptStore.save(requireContext(), receipt)
|
||||||
clearForm()
|
clearForm()
|
||||||
val activity = requireActivity() as HomeActivity
|
val activity = requireActivity() as HomeActivity
|
||||||
activity.triggerRefresh()
|
activity.triggerRefresh()
|
||||||
|
dialog.dismiss()
|
||||||
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
|
activity.showWithBackStack(TransferReceiptFragment.newInstance(receipt, capturedToAvatar))
|
||||||
} else if (!ok) {
|
} else if (!ok) {
|
||||||
|
dialog.dismiss()
|
||||||
if (msg == "CONNECTIVITY") {
|
if (msg == "CONNECTIVITY") {
|
||||||
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
|
(activity as? HomeActivity)?.showConnectivityBanner(getString(R.string.connectivity_no_internet))
|
||||||
} else {
|
} else {
|
||||||
@@ -1010,56 +1175,202 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val warningView: android.view.View? = if (isUsdToMvr || isSrcCredit) {
|
val fromTypeLabel = AccountListParser.from(src)?.typeLabel
|
||||||
val ctx = requireContext()
|
?: if (src.bank == "BML") BmlDashboardParser.productLabel(src.accountTypeName)
|
||||||
val dp = resources.displayMetrics.density
|
else src.accountTypeName.ifBlank { src.profileType }
|
||||||
LinearLayout(ctx).apply {
|
val fromBankLabel = when (src.bank) {
|
||||||
orientation = LinearLayout.VERTICAL
|
"BML" -> "BML"
|
||||||
setPadding((24 * dp).toInt(), (16 * dp).toInt(), (24 * dp).toInt(), 0)
|
"FAHIPAY" -> "Fahipay"
|
||||||
addView(TextView(ctx).apply { text = mainMsg })
|
"MIB" -> "MIB"
|
||||||
if (isUsdToMvr) addView(TextView(ctx).apply {
|
else -> src.bank
|
||||||
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)
|
val fromDetail = listOfNotNull(fromBankLabel.ifBlank { null }, fromTypeLabel.ifBlank { null }).joinToString(" · ")
|
||||||
textSize = 16f
|
|
||||||
typeface = Typeface.DEFAULT_BOLD
|
val toTypeLabel = resolvedToOwnAccount?.let { acc ->
|
||||||
setPadding(0, (16 * dp).toInt(), 0, 0)
|
AccountListParser.from(acc)?.typeLabel
|
||||||
})
|
?: if (acc.bank == "BML") BmlDashboardParser.productLabel(acc.accountTypeName)
|
||||||
if (isSrcCredit) addView(TextView(ctx).apply {
|
else acc.accountTypeName.ifBlank { acc.profileType }
|
||||||
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)
|
val toBankLabel = resolvedToOwnAccount?.let { acc ->
|
||||||
textSize = 16f
|
when (acc.bank) {
|
||||||
typeface = Typeface.DEFAULT_BOLD
|
"BML" -> "BML"
|
||||||
setPadding(0, (16 * dp).toInt(), 0, 0)
|
"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(
|
showConfirmWithBiometric(
|
||||||
title = getString(R.string.transfer),
|
title = getString(R.string.transfer),
|
||||||
message = if (warningView == null) mainMsg else null,
|
customView = confirmView,
|
||||||
customView = warningView,
|
biometricSubtitle = "$currency ${"%.2f".format(amount)} → $destDisplay",
|
||||||
biometricSubtitle = "$currency $amountStr → $destDisplay",
|
onConfirmed = { dialog, frame -> doTransfer(dialog, frame) }
|
||||||
onConfirmed = { doTransfer() }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
private fun executeBmlQrPayment(
|
||||||
src: BankAccount,
|
src: BankAccount,
|
||||||
debitAccount: String,
|
debitAccount: String,
|
||||||
info: BmlQrPayInfo,
|
info: BmlQrPayInfo,
|
||||||
amount: Double
|
amount: Double,
|
||||||
|
dialog: AlertDialog,
|
||||||
|
frame: android.widget.FrameLayout
|
||||||
) {
|
) {
|
||||||
val app = requireActivity().application as BasedBankApp
|
val app = requireActivity().application as BasedBankApp
|
||||||
val loginId = src.loginTag.removePrefix("bml_")
|
val loginId = src.loginTag.removePrefix("bml_")
|
||||||
val session = bmlSessionFor(src) ?: run {
|
val session = bmlSessionFor(src) ?: run {
|
||||||
|
dialog.dismiss()
|
||||||
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.transfer_session_unavailable, Toast.LENGTH_SHORT).show()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
val otp = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
||||||
?.let { Totp.generate(it) }
|
?.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
|
binding.btnTransfer.isEnabled = false
|
||||||
(activity as? HomeActivity)?.setRefreshing(true)
|
|
||||||
|
|
||||||
viewLifecycleOwner.lifecycleScope.launch {
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
val result = withContext(Dispatchers.IO) {
|
val result = withContext(Dispatchers.IO) {
|
||||||
@@ -1080,75 +1391,174 @@ class TransferFragment : Fragment() {
|
|||||||
sh.sar.basedbank.api.bml.BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
|
sh.sar.basedbank.api.bml.BmlQrPayResult(false, errorMessage = e.message ?: "Payment failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(activity as? HomeActivity)?.setRefreshing(false)
|
|
||||||
if (_binding == null) return@launch
|
if (_binding == null) return@launch
|
||||||
|
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
|
dialog.dismiss()
|
||||||
binding.btnTransfer.isEnabled = true
|
binding.btnTransfer.isEnabled = true
|
||||||
Toast.makeText(requireContext(), "Failed to initiate payment", Toast.LENGTH_LONG).show()
|
Toast.makeText(requireContext(), "Failed to initiate payment", Toast.LENGTH_LONG).show()
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
showBmlQrSuccessDialog(
|
showSuccessInDialog(
|
||||||
merchant = result.merchant.ifBlank { info.merchantName },
|
dialog, frame,
|
||||||
amount = result.amount.ifBlank { "%.2f".format(amount) },
|
amountCurrency = result.currency.ifBlank { info.currency },
|
||||||
currency = 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 {
|
} else {
|
||||||
|
dialog.dismiss()
|
||||||
binding.btnTransfer.isEnabled = true
|
binding.btnTransfer.isEnabled = true
|
||||||
Toast.makeText(requireContext(), result.errorMessage, Toast.LENGTH_LONG).show()
|
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 ctx = requireContext()
|
||||||
val dp = resources.displayMetrics.density
|
val dp = resources.displayMetrics.density
|
||||||
val container = android.widget.LinearLayout(ctx).apply {
|
val spinner = CircularProgressDrawable(ctx).apply {
|
||||||
orientation = android.widget.LinearLayout.VERTICAL
|
setStyle(CircularProgressDrawable.LARGE)
|
||||||
gravity = android.view.Gravity.CENTER_HORIZONTAL
|
setColorSchemeColors(com.google.android.material.color.MaterialColors.getColor(
|
||||||
setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (8 * dp).toInt())
|
requireView(), com.google.android.material.R.attr.colorPrimary, Color.GRAY))
|
||||||
|
start()
|
||||||
}
|
}
|
||||||
container.addView(android.widget.ImageView(ctx).apply {
|
frame.removeAllViews()
|
||||||
setImageResource(R.drawable.ic_check_circle)
|
frame.addView(LinearLayout(ctx).apply {
|
||||||
setColorFilter(android.graphics.Color.parseColor("#4CAF50"))
|
orientation = LinearLayout.VERTICAL
|
||||||
layoutParams = android.widget.LinearLayout.LayoutParams(
|
gravity = Gravity.CENTER_HORIZONTAL
|
||||||
(64 * dp).toInt(), (64 * dp).toInt()
|
setPadding((24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt(), (24 * dp).toInt())
|
||||||
).apply { gravity = android.view.Gravity.CENTER_HORIZONTAL; bottomMargin = (16 * 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(
|
private fun doMibTransfer(
|
||||||
src: BankAccount,
|
src: BankAccount,
|
||||||
destAccount: String,
|
destAccount: String,
|
||||||
@@ -1718,8 +2128,17 @@ class TransferFragment : Fragment() {
|
|||||||
requireActivity().title = getString(R.string.transfer)
|
requireActivity().title = getString(R.string.transfer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
super.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
|
_binding = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.content.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.view.ViewCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
@@ -111,6 +113,32 @@ class TransferReceiptFragment : Fragment() {
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
receiptCard.setOnClickListener { showFullScreenReceipt() }
|
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 {
|
view.findViewById<MaterialButton>(R.id.btnDone).setOnClickListener {
|
||||||
parentFragmentManager.popBackStack()
|
parentFragmentManager.popBackStack()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package sh.sar.basedbank.ui.login
|
package sh.sar.basedbank.ui.login
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.ClipboardManager
|
import android.content.ClipboardManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@@ -13,11 +14,13 @@ import android.os.Looper
|
|||||||
import android.text.Editable
|
import android.text.Editable
|
||||||
import android.text.TextWatcher
|
import android.text.TextWatcher
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.sar.basedbank.util.OtpauthParser
|
||||||
import sh.sar.basedbank.util.Totp
|
import sh.sar.basedbank.util.Totp
|
||||||
import sh.sar.basedbank.BasedBankApp
|
import sh.sar.basedbank.BasedBankApp
|
||||||
import sh.sar.basedbank.R
|
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.util.CredentialStore
|
||||||
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
|
import sh.sar.basedbank.databinding.FragmentCredentialsBinding
|
||||||
import sh.sar.basedbank.ui.home.HomeActivity
|
import sh.sar.basedbank.ui.home.HomeActivity
|
||||||
|
import sh.sar.basedbank.ui.home.QrScannerActivity
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||||
|
|
||||||
class CredentialsFragment : Fragment() {
|
class CredentialsFragment : Fragment() {
|
||||||
@@ -60,6 +64,25 @@ class CredentialsFragment : Fragment() {
|
|||||||
private var bmlLoginId: String = ""
|
private var bmlLoginId: String = ""
|
||||||
private var bmlAccumulatedAccounts = mutableListOf<BankAccount>()
|
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 {
|
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||||
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
|
_binding = FragmentCredentialsBinding.inflate(inflater, container, false)
|
||||||
return binding.root
|
return binding.root
|
||||||
@@ -75,7 +98,7 @@ class CredentialsFragment : Fragment() {
|
|||||||
binding.ivBankLogo.setImageResource(R.drawable.fahipay_logo_long)
|
binding.ivBankLogo.setImageResource(R.drawable.fahipay_logo_long)
|
||||||
binding.tvSignInDesc.setText(R.string.fahipay_sign_in_desc)
|
binding.tvSignInDesc.setText(R.string.fahipay_sign_in_desc)
|
||||||
binding.tilUsername.hint = getString(R.string.fahipay_id_card)
|
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.isEnabled = false
|
||||||
binding.etOtpSeed.isFocusable = false
|
binding.etOtpSeed.isFocusable = false
|
||||||
}
|
}
|
||||||
@@ -83,6 +106,9 @@ class CredentialsFragment : Fragment() {
|
|||||||
|
|
||||||
binding.btnLogin.isEnabled = false
|
binding.btnLogin.isEnabled = false
|
||||||
binding.btnLogin.setOnClickListener { attemptLogin() }
|
binding.btnLogin.setOnClickListener { attemptLogin() }
|
||||||
|
binding.btnScanOtpSeed.setOnClickListener {
|
||||||
|
qrLauncher.launch(Intent(requireContext(), QrScannerActivity::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
binding.cardOtp.setOnClickListener {
|
binding.cardOtp.setOnClickListener {
|
||||||
val code = binding.tvOtpCode.text.toString().replace(" ", "")
|
val code = binding.tvOtpCode.text.toString().replace(" ", "")
|
||||||
|
|||||||
@@ -31,9 +31,14 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
|||||||
ThemeHelper.applyAccent(this)
|
ThemeHelper.applyAccent(this)
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
// If security is already configured, onboarding is complete. Redirect to lock screen
|
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
||||||
// to prevent overwriting an existing PIN/pattern via direct activity launch.
|
|
||||||
if (CredentialStore(this).loadSecurityHash() != null) {
|
// 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))
|
startActivity(Intent(this, LockActivity::class.java))
|
||||||
finish()
|
finish()
|
||||||
return
|
return
|
||||||
@@ -50,7 +55,6 @@ class OnboardingActivity : AppCompatActivity(), SecuritySetupFragment.Callback {
|
|||||||
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
|
val ta = obtainStyledAttributes(intArrayOf(android.R.attr.colorBackground))
|
||||||
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
|
window.statusBarColor = ta.getColor(0, if (isLight) android.graphics.Color.WHITE else android.graphics.Color.BLACK)
|
||||||
ta.recycle()
|
ta.recycle()
|
||||||
prefs = getSharedPreferences("prefs", MODE_PRIVATE)
|
|
||||||
val originalBottomPadding = binding.bottomBar.paddingBottom
|
val originalBottomPadding = binding.bottomBar.paddingBottom
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets ->
|
ViewCompat.setOnApplyWindowInsetsListener(binding.bottomBar) { view, insets ->
|
||||||
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
val navBar = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import androidx.biometric.BiometricManager
|
|||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.databinding.FragmentOnboardingConfigureBinding
|
import sh.sar.basedbank.databinding.FragmentOnboardingConfigureBinding
|
||||||
|
import sh.sar.basedbank.ui.home.NavCustomization
|
||||||
|
|
||||||
class OnboardingConfigureFragment : Fragment() {
|
class OnboardingConfigureFragment : Fragment() {
|
||||||
|
|
||||||
@@ -24,12 +25,20 @@ class OnboardingConfigureFragment : Fragment() {
|
|||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
|
||||||
// Navigation — default Drawer
|
// Navigation
|
||||||
val isBottom = prefs.getBoolean("bottom_nav", false)
|
binding.navModeToggle.check(when (NavCustomization.getNavMode(prefs)) {
|
||||||
binding.navModeToggle.check(if (isBottom) R.id.btnNavBottom else R.id.btnNavDrawer)
|
NavCustomization.NAV_MODE_BOTTOM -> R.id.btnNavBottom
|
||||||
|
NavCustomization.NAV_MODE_CIRCULAR -> R.id.btnNavCircular
|
||||||
|
else -> R.id.btnNavDrawer
|
||||||
|
})
|
||||||
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
binding.navModeToggle.addOnButtonCheckedListener { _, checkedId, isChecked ->
|
||||||
if (!isChecked) return@addOnButtonCheckedListener
|
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
|
// Theme — default System
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class OnboardingFragment : Fragment() {
|
|||||||
|
|
||||||
private fun notifyScrolledToBottom() {
|
private fun notifyScrolledToBottom() {
|
||||||
if (scrolledToBottom) return
|
if (scrolledToBottom) return
|
||||||
|
if (!isAdded) return
|
||||||
scrolledToBottom = true
|
scrolledToBottom = true
|
||||||
parentFragmentManager.setFragmentResult(RESULT_SCROLLED_TO_BOTTOM, Bundle.EMPTY)
|
parentFragmentManager.setFragmentResult(RESULT_SCROLLED_TO_BOTTOM, Bundle.EMPTY)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import android.view.View
|
|||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import com.google.android.material.button.MaterialButton
|
import com.google.android.material.button.MaterialButton
|
||||||
import sh.sar.basedbank.R
|
import sh.sar.basedbank.R
|
||||||
import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding
|
import sh.sar.basedbank.databinding.FragmentSecuritySetupBinding
|
||||||
@@ -102,8 +103,17 @@ class SecuritySetupFragment : Fragment() {
|
|||||||
else
|
else
|
||||||
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
||||||
val btn = MaterialButton(requireContext(), null, style).apply {
|
val btn = MaterialButton(requireContext(), null, style).apply {
|
||||||
text = key
|
if (key == "⌫" || key == "✓") {
|
||||||
textSize = 24f
|
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
|
insetTop = 0; insetBottom = 0
|
||||||
minimumWidth = 0; minimumHeight = 0
|
minimumWidth = 0; minimumHeight = 0
|
||||||
cornerRadius = btnSize / 2
|
cornerRadius = btnSize / 2
|
||||||
|
|||||||
@@ -627,6 +627,18 @@ class CredentialStore(context: Context) {
|
|||||||
editor.apply()
|
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 ─────────────────────────────────────────────
|
// ── Dashboard card visibility ─────────────────────────────────────────────
|
||||||
|
|
||||||
fun getHiddenDashboardCardNumbers(): Set<String> =
|
fun getHiddenDashboardCardNumbers(): Set<String> =
|
||||||
|
|||||||
@@ -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(
|
PaymvQrData(
|
||||||
accountNumber = merchantInfo?.get("03"),
|
accountNumber = merchantInfo?.get("03"),
|
||||||
amount = root["54"],
|
amount = root["54"]?.takeIf { it != "***" },
|
||||||
purpose = additionalData?.get("08"),
|
purpose = additionalData?.get("08"),
|
||||||
merchantName = root["59"]
|
merchantName = root["59"]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ object BmlCardParser {
|
|||||||
"C8040", "C8044" -> "cards/bml/master_world.png"
|
"C8040", "C8044" -> "cards/bml/master_world.png"
|
||||||
"C8030", "C8033" -> "cards/bml/master_business_debit.png"
|
"C8030", "C8033" -> "cards/bml/master_business_debit.png"
|
||||||
"C8901", "C8991", "C8980", "C8981" -> "cards/bml/master_passport.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"
|
"C8905", "C8995" -> "cards/bml/visa_credit.png"
|
||||||
"C1001", "C1011", "C1082", "C1081", "C1101", "C1111", "C1181", "C1182" -> "cards/bml/visa_debit_generic.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"
|
"C1017" -> "cards/bml/visa_infinite.png"
|
||||||
"C1009", "C1019", "C1085", "C1086", "C1109", "C1119", "C1185", "C1186" -> "cards/bml/visa_platinum.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"
|
"C1050", "C1051", "C1087", "C1088", "C1150", "C1151", "C1187", "C1188", "C1040", "C1041", "C1047", "C1048", "C1140", "C1141", "C1147", "C1148" -> "cards/bml/visa_student_black.png"
|
||||||
|
|||||||
@@ -0,0 +1,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 (white) -->
|
||||||
|
<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"/>
|
||||||
|
|
||||||
|
<!-- 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,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">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item>
|
<item>
|
||||||
<shape android:shape="oval">
|
<shape android:shape="oval">
|
||||||
<solid android:color="#E8B547" />
|
<solid android:color="@color/ic_logo_background" />
|
||||||
</shape>
|
</shape>
|
||||||
</item>
|
</item>
|
||||||
<item android:drawable="@drawable/ic_launcher_foreground" />
|
<item android:drawable="@drawable/ic_launcher_foreground" />
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ app:menu="@menu/bottom_nav_menu" />
|
|||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:layout_gravity="start"
|
android:layout_gravity="start"
|
||||||
android:fitsSystemWindows="true"
|
android:fitsSystemWindows="true"
|
||||||
|
app:headerLayout="@layout/nav_header"
|
||||||
app:menu="@menu/drawer_menu" />
|
app:menu="@menu/drawer_menu" />
|
||||||
|
|
||||||
</androidx.drawerlayout.widget.DrawerLayout>
|
</androidx.drawerlayout.widget.DrawerLayout>
|
||||||
|
|||||||
@@ -73,22 +73,42 @@
|
|||||||
android:singleLine="true" />
|
android:singleLine="true" />
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputLayout
|
<LinearLayout
|
||||||
android:id="@+id/tilOtpSeed"
|
android:id="@+id/rowOtpSeed"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:hint="@string/otp_seed"
|
android:orientation="horizontal"
|
||||||
android:layout_marginBottom="8dp"
|
android:gravity="center_vertical"
|
||||||
app:endIconMode="password_toggle"
|
android:layout_marginBottom="8dp">
|
||||||
style="@style/Widget.Material3.TextInputLayout.OutlinedBox">
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
android:id="@+id/etOtpSeed"
|
android:id="@+id/tilOtpSeed"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:inputType="textPassword"
|
android:layout_weight="1"
|
||||||
android:imeOptions="actionDone"
|
android:hint="@string/otp_seed"
|
||||||
android:singleLine="true" />
|
app:endIconMode="password_toggle"
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
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
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
android:id="@+id/tilTotpCode"
|
android:id="@+id/tilTotpCode"
|
||||||
|
|||||||
@@ -44,6 +44,14 @@
|
|||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:text="@string/settings_nav_drawer" />
|
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
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btnNavBottom"
|
android:id="@+id/btnNavBottom"
|
||||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
|||||||
@@ -139,21 +139,11 @@
|
|||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="4dp"
|
android:layout_marginStart="4dp"
|
||||||
android:enabled="false"
|
android:enabled="false"
|
||||||
android:text="@string/paymvqr_save_image"
|
android:text="@string/paymvqr_save_image"
|
||||||
app:icon="@drawable/ic_save" />
|
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>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -7,6 +7,14 @@
|
|||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:background="?attr/colorSurface">
|
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 -->
|
<!-- Renderable receipt card -->
|
||||||
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
||||||
@@ -207,10 +215,13 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
||||||
<!-- Action buttons — outside renderable area -->
|
<!-- Action buttons — outside renderable area -->
|
||||||
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
<!-- ══════════════════════════════════════════════════════════════════════ -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/btnRow"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
|
|||||||
@@ -7,6 +7,14 @@
|
|||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:background="?attr/colorSurface">
|
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) -->
|
<!-- Renderable receipt card (header grows to fill remaining space) -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/receiptCard"
|
android:id="@+id/receiptCard"
|
||||||
@@ -236,8 +244,11 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
<!-- Action buttons — outside renderable area -->
|
<!-- Action buttons — outside renderable area -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/btnRow"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
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:layout_weight="1"
|
||||||
android:text="@string/settings_nav_drawer" />
|
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
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btnNavBottom"
|
android:id="@+id/btnNavBottom"
|
||||||
style="@style/Widget.Material3.Button.OutlinedButton"
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
@@ -70,6 +78,31 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</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 -->
|
<!-- Bottom bar shortcuts — shown only when bottom nav is active -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/sectionBottomBarShortcuts"
|
android:id="@+id/sectionBottomBarShortcuts"
|
||||||
|
|||||||
@@ -182,6 +182,55 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</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>
|
</LinearLayout>
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|||||||
@@ -26,15 +26,6 @@
|
|||||||
app:layout_constraintTop_toTopOf="parent"
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
app:layout_constraintBottom_toBottomOf="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
|
<ImageButton
|
||||||
android:id="@+id/btnEditContact"
|
android:id="@+id/btnEditContact"
|
||||||
android:layout_width="36dp"
|
android:layout_width="36dp"
|
||||||
@@ -54,6 +45,15 @@
|
|||||||
android:tint="?attr/colorError"
|
android:tint="?attr/colorError"
|
||||||
android:contentDescription="@string/contact_delete" />
|
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>
|
||||||
|
|
||||||
<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"
|
<menu xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
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
|
<item
|
||||||
android:id="@+id/action_hide_amounts"
|
android:id="@+id/action_hide_amounts"
|
||||||
android:icon="@drawable/ic_visibility"
|
android:icon="@drawable/ic_visibility"
|
||||||
|
|||||||
@@ -4,4 +4,5 @@
|
|||||||
<color name="seed_primary">#3F65AD</color>
|
<color name="seed_primary">#3F65AD</color>
|
||||||
<color name="seed_secondary">#9AD141</color>
|
<color name="seed_secondary">#9AD141</color>
|
||||||
<color name="color_unpaid">#E85D04</color>
|
<color name="color_unpaid">#E85D04</color>
|
||||||
|
<color name="ic_logo_background">#E8B547</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
<string name="password">Password</string>
|
<string name="password">Password</string>
|
||||||
<string name="otp_seed">OTP Seed (TOTP Secret)</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="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>
|
<string name="login">Login</string>
|
||||||
|
|
||||||
<!-- Lock screen -->
|
<!-- Lock screen -->
|
||||||
@@ -175,7 +176,9 @@
|
|||||||
<string name="settings_navigation">Navigation</string>
|
<string name="settings_navigation">Navigation</string>
|
||||||
<string name="settings_nav_drawer">Drawer</string>
|
<string name="settings_nav_drawer">Drawer</string>
|
||||||
<string name="settings_nav_bottom">Bottom Bar</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_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_shortcuts">Bottom Bar Shortcuts</string>
|
||||||
<string name="settings_bottom_bar_show_labels">Always show bottom bar labels</string>
|
<string name="settings_bottom_bar_show_labels">Always show bottom bar labels</string>
|
||||||
<string name="settings_bottom_bar_select">Choose button</string>
|
<string name="settings_bottom_bar_select">Choose button</string>
|
||||||
@@ -189,6 +192,16 @@
|
|||||||
<string name="settings_desc_appearance">Theme, language, and display options</string>
|
<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_privacy_security">App lock, PIN, and security preferences</string>
|
||||||
<string name="settings_desc_storage">Manage cached data and storage usage</string>
|
<string name="settings_desc_storage">Manage cached data and storage usage</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">Log out</string>
|
||||||
<string name="settings_logout_confirm_title">Log out of %s?</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>
|
<string name="settings_logout_confirm_message">All cached data will be cleared and remaining accounts will be refreshed.</string>
|
||||||
@@ -241,11 +254,13 @@
|
|||||||
<string name="transfer_scan_qr">Scan to Pay</string>
|
<string name="transfer_scan_qr">Scan to Pay</string>
|
||||||
<string name="qr_pick_image">Pick image</string>
|
<string name="qr_pick_image">Pick image</string>
|
||||||
<string name="transfer_qr_invalid">Invalid or unsupported QR code</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_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="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="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="go_to_settings">Go to Settings</string>
|
||||||
<string name="transfer_select_source_first">Select a source account first</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_enter_account_first">Enter an account number first</string>
|
||||||
<string name="transfer_account_not_found">Account not found</string>
|
<string name="transfer_account_not_found">Account not found</string>
|
||||||
<string name="transfer_session_unavailable">Session unavailable — please re-login</string>
|
<string name="transfer_session_unavailable">Session unavailable — please re-login</string>
|
||||||
@@ -330,6 +345,14 @@
|
|||||||
<string name="card_pay_qr">Scan to Pay</string>
|
<string name="card_pay_qr">Scan to Pay</string>
|
||||||
<string name="card_pay_nfc">Tap 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="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_manage">Manage Card</string>
|
||||||
<string name="card_set_as_default">Set as Default 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_hide_from_dashboard">Hide from Dashboard</string>
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -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,254 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
### 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) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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,243 @@
|
|||||||
|
# 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)
|
||||||
|
|
||||||
|
> **Note:** This step may be skipped. The app proceeds directly to Step 2b if the gateway already indicates OTP is required.
|
||||||
|
|
||||||
|
### 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -188,6 +188,8 @@ The access token expires after `expires_in` seconds (typically 3600). On a `401`
|
|||||||
| 9 | [Contacts](09-contacts.md) | Saved beneficiaries — list, save, delete |
|
| 9 | [Contacts](09-contacts.md) | Saved beneficiaries — list, save, delete |
|
||||||
| 10 | [Account Validation](10-validate.md) | Validate BML accounts, aliases, and MIB accounts |
|
| 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 |
|
| 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -32,19 +32,23 @@ curl --request GET \
|
|||||||
|
|
||||||
## Responses
|
## Responses
|
||||||
|
|
||||||
|
All responses wrap the result in a top-level `data` object.
|
||||||
|
|
||||||
### Success — Prepaid
|
### Success — Prepaid
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"custType": "PRE",
|
"data": {
|
||||||
"msisdn": "9609654321"
|
"custType": "PRE",
|
||||||
|
"msisdn": "9609654321"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `custType` | `string` | `"PRE"` = prepaid customer |
|
| `data.custType` | `string` | `"PRE"` = prepaid customer |
|
||||||
| `msisdn` | `string` | The MSISDN that was queried |
|
| `data.msisdn` | `string` | The MSISDN that was queried |
|
||||||
|
|
||||||
→ Offer **Raastas** top-up only.
|
→ Offer **Raastas** top-up only.
|
||||||
|
|
||||||
@@ -54,8 +58,10 @@ curl --request GET \
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"custType": "POST",
|
"data": {
|
||||||
"msisdn": "9609123456"
|
"custType": "POST",
|
||||||
|
"msisdn": "9609123456"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -67,8 +73,10 @@ curl --request GET \
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"custType": "HYBRID",
|
"data": {
|
||||||
"msisdn": "9609789012"
|
"custType": "HYBRID",
|
||||||
|
"msisdn": "9609789012"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -80,18 +88,20 @@ curl --request GET \
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"custType": null,
|
"data": {
|
||||||
"errorMessage": "Data Not Found",
|
"custType": null,
|
||||||
"msisdn": "9609000000"
|
"errorMessage": "Data Not Found",
|
||||||
|
"msisdn": "9609000000"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `custType` | `null` | Number is not an Ooredoo subscriber |
|
| `data.custType` | `null` | Number is not an Ooredoo subscriber |
|
||||||
| `errorMessage` | `string` | `"Data Not Found"` |
|
| `data.errorMessage` | `string` | `"Data Not Found"` |
|
||||||
|
|
||||||
Treat `custType: null` as unsupported — fall back to Dhiraagu lookup.
|
Treat `custType: null` or absent as unsupported — fall back to Dhiraagu lookup.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# App Overview
|
||||||
|
|
||||||
|
Architecture overview of the app's entry point, main container, navigation system, and global session lifecycle.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entry Point — `MainActivity`
|
||||||
|
|
||||||
|
`MainActivity` is a transparent trampoline activity. On `onCreate` it reads app state and immediately forwards to the correct destination with no visible UI of its own:
|
||||||
|
|
||||||
|
| Condition | Destination |
|
||||||
|
|---|---|
|
||||||
|
| Onboarding not done | `OnboardingActivity` |
|
||||||
|
| No saved credentials | `LoginActivity` |
|
||||||
|
| Security lock configured | `LockActivity` |
|
||||||
|
| All checks pass | `HomeActivity` |
|
||||||
|
|
||||||
|
### Intent Actions
|
||||||
|
|
||||||
|
External intents (from NFC, shortcuts, or notifications) are passed through to `HomeActivity` via the same forwarding intent:
|
||||||
|
|
||||||
|
| Action | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `OPEN_TRANSFER` | Opens transfer screen |
|
||||||
|
| `OPEN_SCAN_QR` | Opens QR scanner |
|
||||||
|
| `OPEN_PAY_WITH_CARD` | Opens BML card QR payment |
|
||||||
|
| `TAP_TO_PAY` | Opens BML tap-to-pay NFC flow |
|
||||||
|
|
||||||
|
`BmlTapToPayActivity` is a dedicated NFC entry point registered in the manifest. It immediately re-fires a `TAP_TO_PAY` intent to `MainActivity` and finishes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Main Container — `HomeActivity`
|
||||||
|
|
||||||
|
`HomeActivity` is the persistent shell containing all in-app screens. It owns:
|
||||||
|
|
||||||
|
- The `NavHostFragment` and `NavController`
|
||||||
|
- The `DrawerLayout` and `NavigationView`
|
||||||
|
- The `BottomNavigationView`
|
||||||
|
- The toolbar (lock icon + hide-amounts eye icon)
|
||||||
|
- The connectivity banner
|
||||||
|
- The autolock timer
|
||||||
|
- The MIB session keepAlive scheduler
|
||||||
|
|
||||||
|
### Toolbar
|
||||||
|
|
||||||
|
| Icon | Behavior |
|
||||||
|
|---|---|
|
||||||
|
| Lock icon | Immediately locks the app → `LockActivity` (animated with scale + alpha) |
|
||||||
|
| Eye icon | Toggles `hideAmounts` in `HomeViewModel`; all balance displays redact to `••••` |
|
||||||
|
|
||||||
|
### Auto-refresh
|
||||||
|
|
||||||
|
On launch and after unlock `HomeActivity.autoRefresh()` fires parallel login refresh calls for all banks with active sessions. Each bank runs independently — a failure in one bank does not block the others.
|
||||||
|
|
||||||
|
### Connectivity Banner
|
||||||
|
|
||||||
|
A persistent banner appears at the top of `HomeActivity` when network connectivity is lost. It disappears automatically when connectivity is restored. Per-bank connectivity errors (e.g., session expired) are surfaced via `HomeViewModel.connectivityErrors`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation Modes
|
||||||
|
|
||||||
|
The user can choose between two navigation modes in Settings → Appearance:
|
||||||
|
|
||||||
|
### Drawer (default)
|
||||||
|
|
||||||
|
A slide-out navigation drawer containing up to 10 configurable nav items. The hamburger icon in the toolbar opens it.
|
||||||
|
|
||||||
|
### Bottom Navigation
|
||||||
|
|
||||||
|
A bottom bar with 3 configurable slots plus a fixed **Dashboard** tab (always leftmost) and a **More** tab (always rightmost). Tapping **More** opens `NavMoreSheetFragment` — a bottom sheet listing all items not assigned to the 3 visible slots.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation Slots
|
||||||
|
|
||||||
|
10 possible navigation destinations can be assigned to slots. The user reorders them via drag-and-drop in Settings → Appearance.
|
||||||
|
|
||||||
|
| Destination | Default slot |
|
||||||
|
|---|---|
|
||||||
|
| Accounts | 1 |
|
||||||
|
| Transfer | 2 |
|
||||||
|
| Activities | 3 |
|
||||||
|
| Contacts | 4 |
|
||||||
|
| Financing | 5 |
|
||||||
|
| OTP | 6 |
|
||||||
|
| PayMV QR | 7 |
|
||||||
|
| BML QR Pay | 8 |
|
||||||
|
| Transfer History | 9 |
|
||||||
|
| Settings | 10 |
|
||||||
|
|
||||||
|
Two **Quick Action** slots appear as FAB-style buttons on the dashboard and are independently configurable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Autolock
|
||||||
|
|
||||||
|
Autolock fires after a configurable period of user inactivity. Any touch event resets the timer.
|
||||||
|
|
||||||
|
| Timeout option |
|
||||||
|
|---|
|
||||||
|
| 30 seconds |
|
||||||
|
| 1 minute |
|
||||||
|
| 3 minutes |
|
||||||
|
| 5 minutes |
|
||||||
|
| Never |
|
||||||
|
|
||||||
|
When the timeout expires a 10-second countdown warning dialog appears. If dismissed, the timer resets. If ignored, the app calls `LockActivity` and clears `app.isUnlocked`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Global State — `BasedBankApp`
|
||||||
|
|
||||||
|
`BasedBankApp` holds all in-memory session data. Nothing is stored to disk except encrypted credentials.
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| `isUnlocked` | Set to `true` after successful lock-screen auth; guards against process-restart bypass |
|
||||||
|
| `mibSessions` | Map of MIB profile ID → active session (cookies + DH key) |
|
||||||
|
| `bmlSessions` | Map of BML profile ID → OAuth token pair |
|
||||||
|
| `fahipaySessions` | Map of Fahipay login ID → authID + session cookie |
|
||||||
|
| `mibLoginFlows` | Active `MibLoginFlow` instances per profile |
|
||||||
|
| `bmlLoginFlows` | Active `BmlLoginFlow` instances per profile |
|
||||||
|
| `mibMutex` | Coroutine mutex — serializes all MIB API calls to prevent session corruption |
|
||||||
|
|
||||||
|
### Profile Visibility
|
||||||
|
|
||||||
|
Each stored profile has a visibility flag. Hidden profiles are excluded from the accounts list and from all API refresh cycles until re-enabled in Settings → Logins.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MIB Session KeepAlive
|
||||||
|
|
||||||
|
MIB web sessions expire after approximately 30 seconds of inactivity. `HomeActivity` schedules a coroutine that calls the MIB keepAlive endpoint every 25 seconds for each active MIB session while the app is in the foreground.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← README](README.md) **Next →** [Onboarding](01-onboarding.md)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Onboarding
|
||||||
|
|
||||||
|
Shown once on first launch. Walks the user through language selection, security setup, and appearance configuration before creating any credentials.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Activity — `OnboardingActivity`
|
||||||
|
|
||||||
|
`OnboardingActivity` hosts three sequential fragments managed by a `ViewPager2` with manual paging (swipe disabled). Progress dots are shown below the pager.
|
||||||
|
|
||||||
|
Each fragment has a **Continue** button that is only enabled after the user satisfies a completion requirement. Scrolling to the bottom of a slide is required before Continue activates on content slides.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slide 1 — Language & Welcome (`OnboardingFragment`)
|
||||||
|
|
||||||
|
- Displays a welcome illustration and app name
|
||||||
|
- Language selector chip group (English / Dhivehi)
|
||||||
|
- Selecting a language immediately updates the app locale
|
||||||
|
- Continue button becomes active once a language is selected (or immediately if system locale is already supported)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slide 2 — Security Setup (`SecuritySetupFragment`)
|
||||||
|
|
||||||
|
The user chooses a lock method to protect the app.
|
||||||
|
|
||||||
|
### Lock Methods
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
|---|---|
|
||||||
|
| PIN | 4–8 digit numeric PIN |
|
||||||
|
| Pattern | Grid pattern draw (minimum 4 nodes) |
|
||||||
|
|
||||||
|
### PIN Entry
|
||||||
|
|
||||||
|
- Two `EditText` fields: PIN + confirm PIN
|
||||||
|
- Continue activates only when both fields match and length ≥ 4
|
||||||
|
|
||||||
|
### Pattern Entry
|
||||||
|
|
||||||
|
- Custom `PatternView` widget
|
||||||
|
- Draws connecting lines between touched grid nodes in real time
|
||||||
|
- Two-phase: draw → confirm (must match first drawing)
|
||||||
|
- Continue activates after a valid matching pattern is confirmed
|
||||||
|
|
||||||
|
### Key Derivation
|
||||||
|
|
||||||
|
The chosen PIN or pattern string is hardened with **PBKDF2-HMAC-SHA256** (100 000 iterations, random 16-byte salt) before storage. The derived key is stored in encrypted `SharedPreferences` via `CredentialStore`.
|
||||||
|
|
||||||
|
### Biometric Option
|
||||||
|
|
||||||
|
After setting a PIN or pattern an optional **Enable Biometrics** toggle appears. If enabled, biometric authentication (fingerprint / face — `BIOMETRIC_WEAK`) can be used as an alternative to the PIN/pattern at the lock screen and optionally for transfer confirmation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Slide 3 — Configure (`OnboardingConfigureFragment`)
|
||||||
|
|
||||||
|
Appearance and navigation preferences, set before first login.
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
| Setting | Choices |
|
||||||
|
|---|---|
|
||||||
|
| Navigation mode | Drawer / Bottom Navigation |
|
||||||
|
| Theme | System default / Light / Dark |
|
||||||
|
| Accent colour | Chip selector (several Material colours) |
|
||||||
|
|
||||||
|
All preferences are written to `CredentialStore` / `SharedPreferences` immediately on selection so that `HomeActivity` inherits them on first launch.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Completion
|
||||||
|
|
||||||
|
When the user taps Continue on slide 3, `OnboardingActivity` sets the `onboardingDone` flag and finishes. `MainActivity` then routes to `LoginActivity` (no credentials yet) on the next launch or immediately via `startActivity`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← App Overview](00-app-overview.md) **Next →** [Lock Screen](02-lock-screen.md)
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Lock Screen
|
||||||
|
|
||||||
|
`LockActivity` is shown whenever the app is locked — on cold start (when credentials exist), after the autolock timer fires, or when the user taps the lock icon in the toolbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication Methods
|
||||||
|
|
||||||
|
The app attempts authentication in priority order:
|
||||||
|
|
||||||
|
1. **Biometrics** — if enrolled and enabled, `BiometricPrompt` is presented automatically on open
|
||||||
|
2. **PIN** — numeric keypad
|
||||||
|
3. **Pattern** — `PatternView` grid
|
||||||
|
|
||||||
|
The user can switch between biometric and PIN/pattern manually.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Biometric Authentication
|
||||||
|
|
||||||
|
Uses Android `BiometricPrompt` with `BIOMETRIC_WEAK` (fingerprint or face depending on device). A successful biometric result sets `app.isUnlocked = true` and calls `MainActivity` to route to `HomeActivity`.
|
||||||
|
|
||||||
|
On biometric failure or cancellation the screen falls back to PIN/pattern entry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PIN Entry
|
||||||
|
|
||||||
|
- A custom on-screen numeric keypad (0–9 + backspace + confirm)
|
||||||
|
- The entered digits are shown as filled/unfilled circles (no digit echo)
|
||||||
|
- Confirm fires verification immediately when the correct number of digits is entered
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern Entry
|
||||||
|
|
||||||
|
- The same `PatternView` widget used in onboarding, in verify-only mode
|
||||||
|
- The drawn pattern is hashed and compared against the stored derived key
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
The entered PIN or pattern is run through **PBKDF2-HMAC-SHA256** with the stored salt and compared to the stored hash. On match:
|
||||||
|
|
||||||
|
1. `app.isUnlocked = true`
|
||||||
|
2. `LockActivity` finishes
|
||||||
|
3. `MainActivity` routes to `HomeActivity`
|
||||||
|
|
||||||
|
On mismatch the attempt counter increments and an error shake animation plays.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Brute-Force Protection
|
||||||
|
|
||||||
|
| Threshold | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| 1–4 wrong attempts | Error label shown, counter visible |
|
||||||
|
| 5 wrong attempts | 30-second lockout; keypad/pattern disabled |
|
||||||
|
| After lockout | Counter resets; user may try again |
|
||||||
|
|
||||||
|
The attempt counter and lockout timestamp are stored in **plain** `SharedPreferences` (not encrypted) — a known limitation documented in the security audit. The app does not wipe credentials after repeated failures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `app.isUnlocked` Guard
|
||||||
|
|
||||||
|
`app.isUnlocked` is an in-memory flag that is `false` on every process start. Even if an attacker bypasses `LockActivity` via `adb`, `HomeActivity` checks this flag and re-fires `LockActivity` on resume if it is `false`. This prevents cold-start bypass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshot Protection
|
||||||
|
|
||||||
|
`FLAG_SECURE` is set on `LockActivity`'s window, preventing screenshots and screen recording. This is always on for the lock screen regardless of the user's global screenshots setting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Onboarding](01-onboarding.md) **Next →** [Login](03-login.md)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Login
|
||||||
|
|
||||||
|
`LoginActivity` handles adding bank accounts. It is shown on first launch (after onboarding) and also opened from Settings → Logins → Add Account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fragment Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
LoginActivity
|
||||||
|
└─ BankSelectionFragment ← pick a bank
|
||||||
|
└─ CredentialsFragment ← enter credentials for that bank
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bank Selection — `BankSelectionFragment`
|
||||||
|
|
||||||
|
A scrollable list of supported banks presented as selectable cards:
|
||||||
|
|
||||||
|
| Bank | Notes |
|
||||||
|
|---|---|
|
||||||
|
| MIB (Maldives Islamic Bank) | Username + password |
|
||||||
|
| BML (Bank of Maldives) | Username + password |
|
||||||
|
| Fahipay | Mobile number + password |
|
||||||
|
|
||||||
|
Tapping a card navigates to `CredentialsFragment` with the selected bank pre-set.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credentials — `CredentialsFragment`
|
||||||
|
|
||||||
|
### MIB Login
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- Username
|
||||||
|
- Password
|
||||||
|
|
||||||
|
Flow on submit:
|
||||||
|
1. `MibLoginFlow.login()` — performs Diffie-Hellman key exchange, then authenticates with Blowfish/ECB-encrypted credentials
|
||||||
|
2. On success, fetches `operatingProfiles` — the list of CIF profiles (Individual, Sole Propr, etc.)
|
||||||
|
3. Each profile is stored as a `MibAccount` with `bank = "MIB"` and `cifType` from the API
|
||||||
|
4. Sessions are stored in `BasedBankApp.mibSessions`
|
||||||
|
|
||||||
|
### BML Login
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- Username (customer ID)
|
||||||
|
- Password
|
||||||
|
|
||||||
|
Flow on submit:
|
||||||
|
1. `BmlLoginFlow.login()` — OAuth password grant → access token + refresh token
|
||||||
|
2. Fetches dashboard → list of CASA accounts + cards
|
||||||
|
3. Each account/card stored as `MibAccount` with `bank = "BML"`
|
||||||
|
4. Tokens stored in `BasedBankApp.bmlSessions`
|
||||||
|
|
||||||
|
### Fahipay Login
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- Mobile number (7-digit local, auto-prefixed with +960)
|
||||||
|
- Password
|
||||||
|
|
||||||
|
Flow on submit:
|
||||||
|
1. `FahipayLoginFlow.login()` — authenticates against Fahipay API
|
||||||
|
2. On success, stores `authID` + `__Secure-sess` cookie
|
||||||
|
3. Single wallet account stored with `bank = "FAHIPAY"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multi-Profile Support
|
||||||
|
|
||||||
|
Each MIB login can have multiple CIF profiles (e.g., an individual and a business account under the same username). Each profile appears as a separate entry in the accounts list and can be toggled independently in Settings → Logins.
|
||||||
|
|
||||||
|
BML and Fahipay each yield a single profile per login.
|
||||||
|
|
||||||
|
Adding the same bank login a second time merges its profiles into the existing login rather than creating a duplicate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credential Storage
|
||||||
|
|
||||||
|
All credentials (username, password, tokens, session cookies) are encrypted via `CredentialStore`, which uses Android `EncryptedSharedPreferences` backed by a hardware-keystore key where available.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## After Login
|
||||||
|
|
||||||
|
`CredentialsFragment` calls `app.autoRefresh()` after a successful login, then navigates back to `LoginActivity`'s result which routes to `HomeActivity` (or back to Settings if called from there).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Lock Screen](02-lock-screen.md) **Next →** [Accounts](04-accounts.md)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Accounts
|
||||||
|
|
||||||
|
The accounts screen is typically the default home destination. It shows all active bank accounts and cards grouped by bank and profile.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fragment — `AccountsFragment`
|
||||||
|
|
||||||
|
Hosts a `RecyclerView` driven by `AccountsAdapter`. Observes `HomeViewModel.accounts` (a `LiveData<List<MibAccount>>`). The list is filtered to only include accounts whose profile visibility flag is enabled.
|
||||||
|
|
||||||
|
A **pull-to-refresh** gesture triggers `HomeActivity.autoRefresh()`, which re-fetches all bank dashboards in parallel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## List Structure — `AccountsAdapter`
|
||||||
|
|
||||||
|
The adapter renders a mixed list of section headers and account rows.
|
||||||
|
|
||||||
|
### Section Headers
|
||||||
|
|
||||||
|
Accounts are grouped by bank + CIF type (for MIB) or bank name (for BML/Fahipay). Each group starts with a header row showing:
|
||||||
|
- Bank name and logo
|
||||||
|
- For MIB: `cifType` (e.g., `"Individual"`, `"Sole Propr"`) — never hardcoded, always from API
|
||||||
|
- Profile image (circular avatar, if set)
|
||||||
|
|
||||||
|
### Account / Card Rows
|
||||||
|
|
||||||
|
Each row is bound from an `AccountListDisplay` object produced by `AccountListParser.from(account)`. See [Account Parser Architecture](PARSERS.md) for mapping details.
|
||||||
|
|
||||||
|
| Field | Row element |
|
||||||
|
|---|---|
|
||||||
|
| `name` | Account or card name |
|
||||||
|
| `number` | Masked account/card number |
|
||||||
|
| `typeLabel` | Product type chip (e.g., `"Savings"`, `"Visa Platinum"`) |
|
||||||
|
| `balance` | Balance string (hidden as `••••` when hide-amounts is active) |
|
||||||
|
| `isCard` | Switches between account layout and card layout |
|
||||||
|
| `cardBrandIcon` | Visa / Mastercard / Amex logo drawable |
|
||||||
|
| `statusLabel` | Shown as an amber chip if non-null (e.g., `"Inactive"`) |
|
||||||
|
|
||||||
|
### Quick-Transfer Shortcut
|
||||||
|
|
||||||
|
Each account row has a **Send** button. Tapping it opens `TransferFragment` with the source account pre-selected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Account Tap — History
|
||||||
|
|
||||||
|
Tapping any account row navigates to `AccountHistoryFragment` for that account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hide Amounts
|
||||||
|
|
||||||
|
When the toolbar eye icon is toggled (or `HomeViewModel.hideAmounts` is `true`), all balance strings in the adapter are replaced with `"••••"` without re-fetching data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empty State
|
||||||
|
|
||||||
|
If no accounts are loaded (either no credentials or all profiles hidden), the screen shows an empty-state illustration with a prompt to add an account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Login](03-login.md) **Next →** [Account History](05-account-history.md)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Account History
|
||||||
|
|
||||||
|
Displays the transaction history for a single account. Opened by tapping an account row in the accounts list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fragment — `AccountHistoryFragment`
|
||||||
|
|
||||||
|
Receives the selected `MibAccount` via navigation arguments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Loading
|
||||||
|
|
||||||
|
On open, the fragment calls the appropriate bank API to fetch the first page of transactions:
|
||||||
|
|
||||||
|
| Bank | API |
|
||||||
|
|---|---|
|
||||||
|
| MIB | MIB transaction history endpoint (Blowfish-encrypted) |
|
||||||
|
| BML | BML transaction history endpoint (OAuth Bearer) |
|
||||||
|
| Fahipay | Fahipay wallet transaction list |
|
||||||
|
|
||||||
|
Results are mapped to a common display model and shown in a `RecyclerView`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Infinite Scroll
|
||||||
|
|
||||||
|
The list supports **infinite scroll** (pagination). When the user scrolls near the bottom of the loaded items, the next page is automatically fetched and appended. A loading spinner appears at the bottom while a page is in flight.
|
||||||
|
|
||||||
|
Page state (current page, total pages) is tracked in the fragment's `ViewModel`. If the last page has been reached the spinner is hidden and no further requests are made.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Search / Filter
|
||||||
|
|
||||||
|
A search bar at the top of the screen filters the loaded transaction list by:
|
||||||
|
- Description / narrative text
|
||||||
|
- Amount string
|
||||||
|
|
||||||
|
Filtering is performed locally on already-loaded pages — it does not trigger a new API call. Clearing the search bar restores the full list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transaction Rows
|
||||||
|
|
||||||
|
Each row shows:
|
||||||
|
- Transaction date and time
|
||||||
|
- Description / merchant name
|
||||||
|
- Debit or credit indicator
|
||||||
|
- Amount (hidden as `••••` when hide-amounts is active)
|
||||||
|
- Running balance (where available from the bank API)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Image Loading
|
||||||
|
|
||||||
|
Some MIB transaction entries include merchant logo URLs. These are loaded asynchronously into the row's image view with a generic fallback icon. Images are cached in memory for the session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empty State
|
||||||
|
|
||||||
|
If no transactions exist (new account or API returned empty list) an empty-state message is shown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Accounts](04-accounts.md) **Next →** [Transfer History](06-transfer-history.md)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Transfer History
|
||||||
|
|
||||||
|
Shows a merged, chronologically sorted list of outgoing transfers across all connected bank accounts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fragment — `TransferHistoryFragment`
|
||||||
|
|
||||||
|
Observes `HomeViewModel` for loaded account data and triggers parallel history fetches.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Loading
|
||||||
|
|
||||||
|
On open, the fragment launches parallel coroutines — one per active bank session — to fetch transfer/payment history from each bank's API. Results arrive independently and are merged into a single sorted list as each bank completes.
|
||||||
|
|
||||||
|
| Bank | Source |
|
||||||
|
|---|---|
|
||||||
|
| MIB | MIB transfer history endpoint |
|
||||||
|
| BML | BML payment history endpoint |
|
||||||
|
| Fahipay | Fahipay payment history |
|
||||||
|
|
||||||
|
A per-bank loading indicator is shown while that bank's data is in flight. If one bank fails (session expired, network error) its section shows an error row rather than crashing the whole list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## List Display
|
||||||
|
|
||||||
|
The merged list is sorted by date descending (newest first). Each row shows:
|
||||||
|
|
||||||
|
- Bank logo / icon
|
||||||
|
- Recipient name or account number
|
||||||
|
- Date and time
|
||||||
|
- Amount (hidden as `••••` when hide-amounts is active)
|
||||||
|
- Transfer status (where available)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pull-to-Refresh
|
||||||
|
|
||||||
|
A pull-to-refresh gesture re-fires all parallel fetches and rebuilds the merged list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empty State
|
||||||
|
|
||||||
|
If no transfers are found across any bank, an empty-state illustration is shown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Account History](05-account-history.md) **Next →** [Transfer](07-transfer.md)
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Transfer
|
||||||
|
|
||||||
|
The transfer screen initiates account-to-account fund transfers. It supports MIB, BML, and Fahipay as source banks and handles all bank-specific authentication and OTP steps.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fragment — `TransferFragment`
|
||||||
|
|
||||||
|
Opened via:
|
||||||
|
- Navigation menu
|
||||||
|
- Quick-transfer button on an account row (source pre-selected)
|
||||||
|
- `OPEN_TRANSFER` intent action
|
||||||
|
- QR scan result (recipient and optional amount pre-filled)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Source Account Selection
|
||||||
|
|
||||||
|
A dropdown lists all visible accounts parsed via `AccountListParser.from(acc)?.balance`. The selected source account determines which bank's transfer flow is used.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recipient Entry
|
||||||
|
|
||||||
|
The user can specify a recipient in three ways:
|
||||||
|
|
||||||
|
1. **Manual entry** — type an account number directly
|
||||||
|
2. **Contact picker** — opens `ContactPickerSheetFragment` to select a saved contact
|
||||||
|
3. **QR scan** — opens the camera scanner; a PayMV QR result pre-fills the account number, amount, and remarks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fields
|
||||||
|
|
||||||
|
| Field | Notes |
|
||||||
|
|---|---|
|
||||||
|
| Source account | Dropdown; balance shown below |
|
||||||
|
| Recipient account number | Text input or filled from contact/QR |
|
||||||
|
| Recipient name | Auto-looked up from bank API after account number entry |
|
||||||
|
| Amount | Numeric; pre-filled from QR if available |
|
||||||
|
| Remarks / purpose | Free text; pre-filled from QR if available |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recipient Lookup
|
||||||
|
|
||||||
|
After the user finishes entering a recipient account number, the app calls the source bank's name-lookup API:
|
||||||
|
|
||||||
|
- **MIB**: account name lookup via MIB API
|
||||||
|
- **BML**: beneficiary lookup via BML API
|
||||||
|
- **Fahipay**: account name resolution via Fahipay API
|
||||||
|
|
||||||
|
The resolved name is displayed below the account number field for the user to confirm.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Biometric Gate
|
||||||
|
|
||||||
|
If biometric-for-transfers is enabled in Settings → Security, `BiometricPrompt` is shown before the transfer is submitted. A failed or cancelled biometric blocks submission.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bank-Specific Flows
|
||||||
|
|
||||||
|
### MIB Transfer
|
||||||
|
|
||||||
|
1. Validates fields
|
||||||
|
2. (If biometric gate) prompts biometrics
|
||||||
|
3. Submits transfer via `MibLoginFlow` using active MIB session (serialized through `mibMutex`)
|
||||||
|
4. On success, shows `TransferReceiptFragment`
|
||||||
|
|
||||||
|
### BML Transfer
|
||||||
|
|
||||||
|
1. Validates fields
|
||||||
|
2. (If biometric gate) prompts biometrics
|
||||||
|
3. Initiates BML transfer — server responds with OTP required
|
||||||
|
4. Navigates to `OtpFragment` to collect the TOTP
|
||||||
|
5. Re-submits with OTP
|
||||||
|
6. On success, shows `TransferReceiptFragment`
|
||||||
|
|
||||||
|
### Fahipay Transfer
|
||||||
|
|
||||||
|
1. Validates fields
|
||||||
|
2. (If biometric gate) prompts biometrics
|
||||||
|
3. Submits via Fahipay API using stored `authID` + session cookie
|
||||||
|
4. On success, shows `TransferReceiptFragment`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transfer Receipt
|
||||||
|
|
||||||
|
On success the fragment navigates to `TransferReceiptFragment` passing the completed transfer details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
All bank API errors are shown as a `Snackbar` or inline error message. Session expiry triggers a re-authentication prompt rather than a crash.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Transfer History](06-transfer-history.md) **Next →** [Contacts](08-contacts.md)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Contacts
|
||||||
|
|
||||||
|
The contacts screen stores and manages frequently used transfer recipients. Contacts are local to the app and never synced externally.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fragment — `ContactsFragment`
|
||||||
|
|
||||||
|
Displays the full contact list as a `RecyclerView`. Observes `HomeViewModel.contacts` and `HomeViewModel.contactCategories`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contact List
|
||||||
|
|
||||||
|
Each contact row shows:
|
||||||
|
- Circular avatar (profile image if set, otherwise initials placeholder)
|
||||||
|
- Display name
|
||||||
|
- Account number(s)
|
||||||
|
- Category chip (if assigned)
|
||||||
|
|
||||||
|
Tapping a contact row opens `AddContactSheetFragment` in edit mode.
|
||||||
|
|
||||||
|
Tapping the **Transfer** button on a contact row opens `TransferFragment` with the recipient pre-filled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Categories
|
||||||
|
|
||||||
|
Contacts can be assigned to user-defined categories (e.g., "Family", "Business"). Categories appear as filter chips at the top of the list. Tapping a chip filters the list to that category. Tapping again clears the filter.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Add Contact — `AddContactSheetFragment`
|
||||||
|
|
||||||
|
A bottom sheet for creating or editing a contact.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Field | Notes |
|
||||||
|
|---|---|
|
||||||
|
| Name | Display name |
|
||||||
|
| Account number | Primary transfer account number |
|
||||||
|
| Bank | Optional — for display only |
|
||||||
|
| Category | Optional; selectable from existing categories or create new |
|
||||||
|
| Profile image | Optional; select from gallery or camera |
|
||||||
|
|
||||||
|
### Profile Image
|
||||||
|
|
||||||
|
The pencil icon next to the avatar opens a chooser:
|
||||||
|
- **Gallery** — pick from device gallery
|
||||||
|
- **Camera** — capture a new photo (temp file in `cacheDir`)
|
||||||
|
|
||||||
|
The image is stored locally in `filesDir/profile_images/` via `ProfileImageStore` with key `"contact_{id}"`.
|
||||||
|
|
||||||
|
### Save
|
||||||
|
|
||||||
|
On save, the contact is persisted to the local database and `HomeViewModel.contacts` is refreshed.
|
||||||
|
|
||||||
|
### Delete
|
||||||
|
|
||||||
|
A delete button (with confirmation dialog) removes the contact and its profile image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contact Picker — `ContactPickerSheetFragment`
|
||||||
|
|
||||||
|
A compact bottom sheet version of the contact list, used by `TransferFragment` when the user taps **Choose Contact**.
|
||||||
|
|
||||||
|
- Shows all contacts with avatar and name
|
||||||
|
- Search bar filters by name or account number
|
||||||
|
- Tapping a contact returns the selection to `TransferFragment` and dismisses the sheet
|
||||||
|
- Profile images use a `"local:{key}"` synthetic hash prefix to identify locally stored images
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Transfer](07-transfer.md) **Next →** [Activities](09-activities.md)
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Activities
|
||||||
|
|
||||||
|
The activities screen shows a local log of completed transfers initiated within the app, along with receipt viewing and sharing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fragment — `ActivitiesFragment`
|
||||||
|
|
||||||
|
Displays a chronological `RecyclerView` of locally stored transfer records. These records are written by the app at transfer completion time — they are not fetched from bank APIs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Activity List
|
||||||
|
|
||||||
|
Each row shows:
|
||||||
|
- Bank logo
|
||||||
|
- Recipient name and account number
|
||||||
|
- Transfer amount (hidden as `••••` when hide-amounts is active)
|
||||||
|
- Date and time
|
||||||
|
- Status badge (Completed / Failed)
|
||||||
|
|
||||||
|
Tapping a row opens `TransferReceiptFragment` for that record.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transfer Receipt — `TransferReceiptFragment`
|
||||||
|
|
||||||
|
A full-screen receipt view shown immediately after a successful transfer and accessible later from the activities list.
|
||||||
|
|
||||||
|
### Receipt Fields
|
||||||
|
|
||||||
|
| Field | Notes |
|
||||||
|
|---|---|
|
||||||
|
| Bank | Source bank logo and name |
|
||||||
|
| From account | Sender account number |
|
||||||
|
| To account | Recipient account number |
|
||||||
|
| Recipient name | As resolved at transfer time |
|
||||||
|
| Amount | Formatted with currency |
|
||||||
|
| Remarks | Transfer purpose text |
|
||||||
|
| Reference number | Bank-issued transaction reference |
|
||||||
|
| Date and time | Transfer timestamp |
|
||||||
|
| Status | Completed / Failed |
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
|
||||||
|
- **Share** — generates a text or image summary of the receipt and opens the system share sheet
|
||||||
|
- **Save to Gallery** — renders the receipt as a bitmap and saves it to the device's Pictures folder (requires `WRITE_EXTERNAL_STORAGE` on API < 29, or `MediaStore` on API 29+)
|
||||||
|
|
||||||
|
### Screenshot Note
|
||||||
|
|
||||||
|
If `FLAG_SECURE` is active (user has enabled the screenshots restriction), the Save to Gallery action uses an off-screen rendering path that bypasses the restriction for the explicit save action only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empty State
|
||||||
|
|
||||||
|
If no local transfer records exist, an empty-state illustration is shown with a prompt to make a transfer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Contacts](08-contacts.md) **Next →** [OTP Screen](10-otp-screen.md)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# OTP Screen
|
||||||
|
|
||||||
|
Displays the current TOTP (Time-based One-Time Password) code for each enrolled bank authenticator. Used when confirming transfers, QR payments, or other 2FA-protected operations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fragment — `OtpFragment`
|
||||||
|
|
||||||
|
Hosts one card per enrolled bank authenticator. Banks with no stored TOTP seed are not shown.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TOTP Display
|
||||||
|
|
||||||
|
Each card shows:
|
||||||
|
- Bank logo and name
|
||||||
|
- The current 6-digit TOTP code (large text)
|
||||||
|
- A circular countdown ring showing time remaining in the current 30-second window
|
||||||
|
- The code refreshes automatically when the window expires — no user interaction needed
|
||||||
|
|
||||||
|
### Algorithm
|
||||||
|
|
||||||
|
Standard RFC 6238 TOTP:
|
||||||
|
- Hash: SHA-1
|
||||||
|
- Window: 30 seconds
|
||||||
|
- Digits: 6
|
||||||
|
- Seed: stored per-bank in `CredentialStore` (encrypted)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported Banks
|
||||||
|
|
||||||
|
| Bank | Seed source |
|
||||||
|
|---|---|
|
||||||
|
| BML | Enrolled via BML app setup; seed stored in `CredentialStore` |
|
||||||
|
| MIB | MIB business/corporate OTP seed (if applicable) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Background Name Refresh
|
||||||
|
|
||||||
|
When the screen opens, the fragment may fire a background API call to refresh the account holder name associated with each seed. This is a best-effort call — failure does not affect OTP display.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
The OTP screen is informational — the user copies the displayed code manually and enters it wherever required (e.g., in `TransferFragment`'s OTP dialog, or in an external portal). The code is never submitted automatically from this screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
The TOTP seeds are stored encrypted in `CredentialStore`. They are never logged or included in error reports.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Activities](09-activities.md) **Next →** [PayMV QR Screen](11-paymv-qr-screen.md)
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# PayMV QR Screen
|
||||||
|
|
||||||
|
Handles both sides of PayMV/Favara QR payments: generating a receive-payment QR code and scanning a QR code to initiate a transfer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fragment — `PayMvQrFragment`
|
||||||
|
|
||||||
|
Two tabs: **Receive** (generate QR) and **Send** (scan QR).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Receive Tab — Generate QR
|
||||||
|
|
||||||
|
Generates a static PayMV QR code that others can scan to pay the user.
|
||||||
|
|
||||||
|
### Fields
|
||||||
|
|
||||||
|
| Field | Notes |
|
||||||
|
|---|---|
|
||||||
|
| Source account | Dropdown of all visible accounts; determines acquirer BIC |
|
||||||
|
| Amount | Optional — leave blank for open-amount QR |
|
||||||
|
| Purpose | Optional free-text payment purpose |
|
||||||
|
| Name | Auto-filled from the selected account's holder name |
|
||||||
|
|
||||||
|
### Generation
|
||||||
|
|
||||||
|
On tap **Generate**, the fragment builds a decimal TLV payload per the [PayMV QR Format](18-paymv-qr-format.md) spec:
|
||||||
|
|
||||||
|
1. Assembles all TLV fields (format indicator, point-of-initiation, tag 26 container, MCC, currency, amount if set, country, name, tag 62 container, tag 80 container)
|
||||||
|
2. Selects the acquirer BIC from the source account's bank (`MALBMVMV` / `MADVMVMV` / `FAHIMVMV`)
|
||||||
|
3. Appends `"6304"` and computes CRC-16/CCITT-FALSE over the full string
|
||||||
|
4. Renders the complete string as a QR code bitmap using the ZXing encoder
|
||||||
|
5. Displays the QR code full-screen with the account number and name below
|
||||||
|
|
||||||
|
### Share
|
||||||
|
|
||||||
|
A **Share** button exports the QR bitmap via the system share sheet (image/png).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Send Tab — Scan QR
|
||||||
|
|
||||||
|
Scans a QR code and pre-fills the transfer screen.
|
||||||
|
|
||||||
|
### Scanner
|
||||||
|
|
||||||
|
Opens the device camera with a QR viewfinder overlay. Supported QR formats:
|
||||||
|
|
||||||
|
| QR type | Handling |
|
||||||
|
|---|---|
|
||||||
|
| PayMV / Favara decimal TLV | Parse account number (tag 26→03), amount (tag 54), name (tag 59), purpose (tag 62→08); navigate to `TransferFragment` pre-filled |
|
||||||
|
| BML plain URL (`https://pay.bml.com.mv/app/...`) | Navigate to `BmlQrPayFragment` |
|
||||||
|
| BML embedded in combined QR (root tag 35 → sub 20 → sub-sub 01) | Extract URL; navigate to `BmlQrPayFragment` |
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
If the scanned code is not a recognized format, a `Snackbar` error is shown and the scanner remains open.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← OTP Screen](10-otp-screen.md) **Next →** [BML QR Pay](12-bml-qr-pay.md)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# BML QR Pay
|
||||||
|
|
||||||
|
Handles BML gateway QR payments — scanning a merchant QR code and completing the 3-step TOTP-authenticated payment flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fragment — `BmlQrPayFragment`
|
||||||
|
|
||||||
|
Opened via:
|
||||||
|
- Scanning a BML plain URL QR in `PayMvQrFragment`
|
||||||
|
- Scanning a combined Fahipay/PayMV QR that embeds a BML gateway URL
|
||||||
|
- The `OPEN_PAY_WITH_CARD` intent action
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 1 — Resolve QR
|
||||||
|
|
||||||
|
The fragment receives a BML gateway URL (e.g., `https://pay.bml.com.mv/app/<base64>`).
|
||||||
|
|
||||||
|
It calls the BML `payrequest` lookup API (see [QR Payment](../bmlapi/13-qr-payment.md)) to resolve the URL to merchant details:
|
||||||
|
|
||||||
|
- Merchant name (narrative1)
|
||||||
|
- Merchant address (narrative2, narrative3)
|
||||||
|
- Amount (`"0.00"` for static QRS, or preset amount for QRR)
|
||||||
|
- Currency
|
||||||
|
|
||||||
|
The resolved details are displayed on screen for the user to review before paying.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Source Account Selection
|
||||||
|
|
||||||
|
A dropdown lists all BML accounts. The selected account's internal UUID (`internalId`) is used as `debitAccount` in the payment request.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Amount Entry
|
||||||
|
|
||||||
|
- If the QR is a **static QRS** (amount `"0.00"`), the amount field is editable and required
|
||||||
|
- If the QR is a **dynamic QRR**, the amount is pre-filled and read-only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Step 2 — TOTP Payment
|
||||||
|
|
||||||
|
The payment uses the standard 3-step BML TOTP flow:
|
||||||
|
|
||||||
|
### 2a — Initiate
|
||||||
|
|
||||||
|
POST to `/walletpayments/pay` with `action: "approve"`, `debitAccount`, `requestId` (the `trxn_hash`), `amount`, `currency`. Expected response: `code: 99` (OTP required).
|
||||||
|
|
||||||
|
> This step may be skipped if the gateway already indicates OTP is required.
|
||||||
|
|
||||||
|
### 2b — Request OTP Channel
|
||||||
|
|
||||||
|
Same POST with `channel: "token"` added. Expected response: `code: 22` (OTP generated and sent to the authenticator).
|
||||||
|
|
||||||
|
### 2c — Confirm with TOTP
|
||||||
|
|
||||||
|
The fragment presents an OTP input dialog. The user opens the [OTP Screen](12-otp-screen.md) or reads the TOTP from their authenticator app, then enters the 6-digit code.
|
||||||
|
|
||||||
|
Same POST with `channel: "token"` and `otp: "<code>"`. On success: `code: 0` with merchant and amount in `payload`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Success
|
||||||
|
|
||||||
|
On successful payment a confirmation card is shown:
|
||||||
|
- Merchant name
|
||||||
|
- Amount paid
|
||||||
|
- Currency
|
||||||
|
|
||||||
|
A **Done** button dismisses the fragment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
Failed payments (`success: false`) display the `message` field from the API response. The user may retry with a fresh TOTP code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← PayMV QR Screen](11-paymv-qr-screen.md) **Next →** [Financing](13-financing.md)
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Financing
|
||||||
|
|
||||||
|
Aggregates financing products across banks — MIB promotional deals and BML loan details — in a single screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fragment — `FinancingFragment`
|
||||||
|
|
||||||
|
Observes `HomeViewModel.financing` (MIB deals) and `HomeViewModel.bmlLoanDetails`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MIB Deals Section
|
||||||
|
|
||||||
|
Displays current MIB promotional financing offers fetched from the MIB API. Each deal card shows:
|
||||||
|
- Deal name / product title
|
||||||
|
- Key terms (profit rate, tenure, minimum/maximum amount)
|
||||||
|
- A **Learn More** action that opens the deal detail in an in-app WebView or external browser
|
||||||
|
|
||||||
|
Data is loaded once on fragment creation and refreshed on pull-to-refresh.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BML Loans Section
|
||||||
|
|
||||||
|
Displays the user's active BML loan/financing accounts. Each card shows:
|
||||||
|
- Loan product name
|
||||||
|
- Outstanding balance
|
||||||
|
- Next instalment amount and due date
|
||||||
|
- Loan account number
|
||||||
|
|
||||||
|
Data comes from `HomeViewModel.bmlLoanDetails`, which is fetched from the BML loans API using the active BML session.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Card Limits Section (BML)
|
||||||
|
|
||||||
|
`HomeViewModel.bmlLimits` provides credit card limit information for BML card accounts. Displayed alongside the loan section:
|
||||||
|
- Card name
|
||||||
|
- Total limit
|
||||||
|
- Available limit
|
||||||
|
- Used amount
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pull-to-Refresh
|
||||||
|
|
||||||
|
Refreshes both MIB deals and BML loan/limit data independently. Each section shows its own loading indicator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Empty State
|
||||||
|
|
||||||
|
If no MIB session is active or no BML financing accounts exist, the respective section shows an empty-state message with a prompt to add the corresponding bank account.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← BML QR Pay](12-bml-qr-pay.md) **Next →** [Settings](14-settings.md)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Settings
|
||||||
|
|
||||||
|
The settings hub and the logins management screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Settings Hub — `SettingsFragment`
|
||||||
|
|
||||||
|
A simple preference-list screen with navigation links to sub-sections:
|
||||||
|
|
||||||
|
| Entry | Destination |
|
||||||
|
|---|---|
|
||||||
|
| Logins | `SettingsLoginsFragment` |
|
||||||
|
| Security | `SettingsSecurityFragment` |
|
||||||
|
| Appearance | `SettingsAppearanceFragment` |
|
||||||
|
| Storage | `SettingsStorageFragment` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Logins — `SettingsLoginsFragment`
|
||||||
|
|
||||||
|
Manages all connected bank accounts and profiles.
|
||||||
|
|
||||||
|
### Account List
|
||||||
|
|
||||||
|
Each connected login is shown as a card. Within each login card, individual profiles (CIF profiles for MIB, or the single account for BML/Fahipay) are listed.
|
||||||
|
|
||||||
|
Each profile entry shows:
|
||||||
|
- Profile name / CIF type
|
||||||
|
- Account number(s)
|
||||||
|
- Profile image (circular avatar)
|
||||||
|
- Visibility toggle switch
|
||||||
|
|
||||||
|
### Visibility Toggle
|
||||||
|
|
||||||
|
Toggling a profile off hides it from the accounts list and excludes it from API refresh cycles. The session is kept alive — the profile can be re-enabled without re-logging in.
|
||||||
|
|
||||||
|
### Profile Image
|
||||||
|
|
||||||
|
A pencil icon (`ic_edit`) next to each profile opens an image chooser:
|
||||||
|
- **Gallery** — pick from device photo library
|
||||||
|
- **Camera** — capture via device camera (temp file: `cacheDir/profile_photo_tmp.jpg`)
|
||||||
|
|
||||||
|
Profile images are stored locally via `ProfileImageStore`:
|
||||||
|
- BML: key `bml_{profileId}`
|
||||||
|
- Fahipay: key `fahipay_{loginId}`
|
||||||
|
- MIB: uploaded to the MIB server via profile image API (P40); retrieved via hash (P41); deleted via P42
|
||||||
|
|
||||||
|
### Add Account
|
||||||
|
|
||||||
|
A **+** (Add) button navigates to `LoginActivity` → `BankSelectionFragment` to add a new bank login.
|
||||||
|
|
||||||
|
### Logout
|
||||||
|
|
||||||
|
A **Logout** button on each login card shows a confirmation dialog. On confirm:
|
||||||
|
- Session tokens are revoked where supported
|
||||||
|
- Credentials are removed from `CredentialStore`
|
||||||
|
- All associated `MibAccount` objects are removed from `BasedBankApp`
|
||||||
|
- The accounts list is refreshed
|
||||||
|
|
||||||
|
### Business OTP Seed (MIB)
|
||||||
|
|
||||||
|
For MIB corporate/business profiles, an **OTP Seed** entry allows importing a TOTP seed string. The seed is stored encrypted in `CredentialStore` and used by the [OTP Screen](12-otp-screen.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Financing](13-financing.md) **Next →** [Settings — Security](15-settings-security.md)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Settings — Security
|
||||||
|
|
||||||
|
Controls the app lock method, biometric options, auto-lock timeout, and screenshot restriction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fragment — `SettingsSecurityFragment`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Change Lock Method
|
||||||
|
|
||||||
|
A **Change PIN** or **Change Pattern** button (label reflects current method) opens the security setup flow (same `SecuritySetupFragment` as onboarding) in change-mode. The user must first authenticate with the current PIN/pattern/biometric before the new one is accepted.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Biometrics
|
||||||
|
|
||||||
|
A toggle to enable or disable biometric authentication (fingerprint / face — `BIOMETRIC_WEAK`).
|
||||||
|
|
||||||
|
- When enabled, `BiometricPrompt` is offered at the lock screen as an alternative to PIN/pattern
|
||||||
|
- A secondary toggle controls whether biometrics are also required for transfer confirmation
|
||||||
|
|
||||||
|
Biometric availability is checked via `BiometricManager.canAuthenticate()`. The toggle is disabled with an explanatory message if the device has no enrolled biometrics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Auto-Lock Timeout
|
||||||
|
|
||||||
|
A radio group or dropdown to set the inactivity timeout:
|
||||||
|
|
||||||
|
| Option | Timeout |
|
||||||
|
|---|---|
|
||||||
|
| 30 seconds | 30 s |
|
||||||
|
| 1 minute | 60 s |
|
||||||
|
| 3 minutes | 180 s |
|
||||||
|
| 5 minutes | 300 s |
|
||||||
|
| Never | Disabled |
|
||||||
|
|
||||||
|
The selection is stored in `SharedPreferences` and read by `HomeActivity` on each timer reset.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
A toggle for `FLAG_SECURE` on `HomeActivity`'s window.
|
||||||
|
|
||||||
|
- **On (default)**: screenshots and screen recording are blocked system-wide while the app is in the foreground
|
||||||
|
- **Off**: screenshots are allowed
|
||||||
|
|
||||||
|
The lock screen always has `FLAG_SECURE` regardless of this setting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Settings](14-settings.md) **Next →** [Settings — Appearance](16-settings-appearance.md)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Settings — Appearance
|
||||||
|
|
||||||
|
Controls navigation mode, nav slot assignment, theme, accent colour, and language.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fragment — `SettingsAppearanceFragment`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation Mode
|
||||||
|
|
||||||
|
A toggle/radio group:
|
||||||
|
|
||||||
|
| Mode | Description |
|
||||||
|
|---|---|
|
||||||
|
| Drawer | Slide-out navigation drawer (default) |
|
||||||
|
| Bottom Navigation | Bottom bar with 3 visible slots + Dashboard + More |
|
||||||
|
|
||||||
|
Changing mode takes effect immediately; `HomeActivity` recreates its navigation structure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation Slot Customisation — `NavCustomization`
|
||||||
|
|
||||||
|
A drag-and-drop list of all 10 navigation destinations. The user reorders items to assign them to slots.
|
||||||
|
|
||||||
|
### Drawer Mode
|
||||||
|
|
||||||
|
All 10 items appear in the drawer in the configured order.
|
||||||
|
|
||||||
|
### Bottom Navigation Mode
|
||||||
|
|
||||||
|
- Slots 1–3 appear in the bottom bar
|
||||||
|
- Slot 4–10 appear in the **More** bottom sheet (`NavMoreSheetFragment`)
|
||||||
|
- Dashboard is always pinned as the first tab and is not part of the 10-item pool
|
||||||
|
|
||||||
|
### Quick Action Slots
|
||||||
|
|
||||||
|
Two dedicated quick-action slots are configured separately at the bottom of the customisation screen. These map to FAB-style buttons shown on the dashboard card.
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
|
||||||
|
The ordered list is serialised to `SharedPreferences` as a comma-separated string of destination IDs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Theme
|
||||||
|
|
||||||
|
A three-way selector:
|
||||||
|
|
||||||
|
| Option | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| System default | Follows the device's dark/light mode |
|
||||||
|
| Light | Forces light theme |
|
||||||
|
| Dark | Forces dark theme |
|
||||||
|
|
||||||
|
Applied via `AppCompatDelegate.setDefaultNightMode()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Accent Colour
|
||||||
|
|
||||||
|
A horizontal chip row with several Material colour options. The selected accent is applied to the app's `MaterialTheme` colour scheme (primary / secondary).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Language
|
||||||
|
|
||||||
|
A dropdown or chip selector:
|
||||||
|
|
||||||
|
| Option |
|
||||||
|
|---|
|
||||||
|
| System default |
|
||||||
|
| English |
|
||||||
|
| Dhivehi |
|
||||||
|
|
||||||
|
Applied via `AppCompatDelegate` locale override. Takes effect immediately (activity recreate).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Settings — Security](15-settings-security.md) **Next →** [Settings — Storage](17-settings-storage.md)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Settings — Storage
|
||||||
|
|
||||||
|
Manages locally cached data and profile images.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fragment — `SettingsStorageFragment`
|
||||||
|
|
||||||
|
Displays an overview of locally stored data categories with clear actions for each.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stored Data Categories
|
||||||
|
|
||||||
|
| Category | Location | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| Profile images | `filesDir/profile_images/` | BML and Fahipay profile photos stored by `ProfileImageStore` |
|
||||||
|
| Contact images | `filesDir/profile_images/` | Contact avatar photos |
|
||||||
|
| Transaction image cache | In-memory / HTTP cache | Merchant logos loaded in account history |
|
||||||
|
| Camera temp file | `cacheDir/profile_photo_tmp.jpg` | Temp file from camera capture; automatically overwritten on next camera use |
|
||||||
|
| Transfer receipts | Local database | Completed transfer records shown in Activities |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Clear Actions
|
||||||
|
|
||||||
|
### Clear Profile Images
|
||||||
|
|
||||||
|
Deletes all files in `filesDir/profile_images/` for BML and Fahipay profiles. MIB profile images are stored server-side and are not affected. After clearing, avatars fall back to the initials placeholder.
|
||||||
|
|
||||||
|
### Clear Contact Images
|
||||||
|
|
||||||
|
Deletes all contact profile images. Contact records are preserved.
|
||||||
|
|
||||||
|
### Clear All Caches
|
||||||
|
|
||||||
|
Clears:
|
||||||
|
- `cacheDir` contents (including camera temp file and HTTP response cache)
|
||||||
|
- In-memory image caches
|
||||||
|
|
||||||
|
Does **not** clear credentials, sessions, or transfer records.
|
||||||
|
|
||||||
|
### Clear Transfer History
|
||||||
|
|
||||||
|
Deletes all locally stored transfer receipt records from the database. This action is irreversible and requires a confirmation dialog.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Storage Usage
|
||||||
|
|
||||||
|
The screen may show an approximate size for each category (calculated by summing file sizes in the respective directories).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Settings — Appearance](16-settings-appearance.md)
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# PayMV QR Format
|
||||||
|
|
||||||
|
Documents the decimal TLV QR format used by the PayMV/Favara payment network in the Maldives — both generation (to receive payment) and parsing (to initiate a transfer by scanning).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Encoding
|
||||||
|
|
||||||
|
PayMV QRs use a **decimal TLV** encoding — not binary BER-TLV. Every field is represented as ASCII text:
|
||||||
|
|
||||||
|
```
|
||||||
|
<2-digit decimal tag><2-digit decimal length><value>...
|
||||||
|
```
|
||||||
|
|
||||||
|
Example — tag `59`, value `"AHMED ALI"` (9 chars):
|
||||||
|
```
|
||||||
|
5909AHMED ALI
|
||||||
|
```
|
||||||
|
|
||||||
|
Tags and lengths are always exactly 2 decimal digits. Fields are concatenated directly with no separator.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Root-Level Tags
|
||||||
|
|
||||||
|
| Tag | Field | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `00` | Format indicator | Always `"01"` |
|
||||||
|
| `01` | Point-of-initiation method | `"11"` = static QR, `"12"` = dynamic QR |
|
||||||
|
| `26` | Merchant account information | Container — see sub-tags below |
|
||||||
|
| `35` | BML/gateway merchant info | Container — present in combined EMV+BML QRs only |
|
||||||
|
| `52` | Merchant category code | `"0000"` (generic) |
|
||||||
|
| `53` | Transaction currency | `"462"` = MVR (ISO 4217 numeric) |
|
||||||
|
| `54` | Transaction amount | Decimal string (e.g. `"1.50"`); absent for open-amount QRs |
|
||||||
|
| `58` | Country code | `"MV"` |
|
||||||
|
| `59` | Merchant / recipient name | Max 25 characters |
|
||||||
|
| `62` | Additional data field | Container — see sub-tags below |
|
||||||
|
| `63` | CRC | `6304` prefix + 4-char hex checksum — always last |
|
||||||
|
| `80` | Supplementary data | Container — timestamp and domain |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Merchant Account Info — Tag `26` Sub-Tags
|
||||||
|
|
||||||
|
| Sub-Tag | Field | Example value |
|
||||||
|
|---|---|---|
|
||||||
|
| `00` | Domain | `"mv.favara.mpqr"` |
|
||||||
|
| `01` | Acquirer BIC | `"MALBMVMV"` (see table below) |
|
||||||
|
| `02` | Acquirer BIC (repeated) | Same as `01` |
|
||||||
|
| `03` | Account number | Beneficiary account number |
|
||||||
|
| `05` | Mobile number | E.164 format (e.g. `"+9607654321"`); optional |
|
||||||
|
| `10` | Network indicator | `"IPAY"` |
|
||||||
|
|
||||||
|
### Acquirer BIC Mapping
|
||||||
|
|
||||||
|
| Bank | Acquirer BIC |
|
||||||
|
|---|---|
|
||||||
|
| BML | `MALBMVMV` |
|
||||||
|
| MIB | `MADVMVMV` |
|
||||||
|
| Fahipay | `FAHIMVMV` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Additional Data — Tag `62` Sub-Tags
|
||||||
|
|
||||||
|
| Sub-Tag | Field | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `05` | Reference / bill number | 9 random uppercase alphanumeric characters |
|
||||||
|
| `08` | Payment purpose | Free-form text entered by the payee |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supplementary Data — Tag `80` Sub-Tags
|
||||||
|
|
||||||
|
| Sub-Tag | Field | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `00` | Domain | `"mv.favara.mpqr"` |
|
||||||
|
| `01` | Timestamp | ISO 8601 format: `"yyyy-MM-dd'T'HH:mm:ss.00000"` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CRC-16
|
||||||
|
|
||||||
|
The checksum uses **CRC-16/CCITT-FALSE** (polynomial `0x1021`, initial value `0xFFFF`). The CRC is computed over the entire payload string up to and including `"6304"`, then the 4-digit uppercase hex result is appended.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def crc16(data: str) -> str:
|
||||||
|
crc = 0xFFFF
|
||||||
|
for c in data:
|
||||||
|
crc ^= (ord(c) & 0xFF) << 8
|
||||||
|
for _ in range(8):
|
||||||
|
if crc & 0x8000:
|
||||||
|
crc = ((crc << 1) & 0xFFFF) ^ 0x1021
|
||||||
|
else:
|
||||||
|
crc = (crc << 1) & 0xFFFF
|
||||||
|
return format(crc, '04X')
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Generating a Receive-Payment QR
|
||||||
|
|
||||||
|
To create a QR that others can scan to pay you:
|
||||||
|
|
||||||
|
```
|
||||||
|
00 02 01 ← Format indicator
|
||||||
|
01 02 11 ← Static QR
|
||||||
|
26 <len> ← Merchant account info
|
||||||
|
00 15 mv.favara.mpqr
|
||||||
|
01 08 MALBMVMV ← Acquirer BIC (BML example)
|
||||||
|
02 08 MALBMVMV ← Repeated
|
||||||
|
03 <len> <accountNumber>
|
||||||
|
05 <len> <+960XXXXXXX> ← Optional phone
|
||||||
|
10 04 IPAY
|
||||||
|
52 04 0000 ← MCC
|
||||||
|
53 03 462 ← MVR
|
||||||
|
54 <len> <amount> ← Omit tag entirely if open-amount
|
||||||
|
58 02 MV
|
||||||
|
59 <len> <name up to 25 chars>
|
||||||
|
62 <len>
|
||||||
|
05 09 <9 random alphanum chars> ← Reference
|
||||||
|
08 <len> <purpose text>
|
||||||
|
80 <len>
|
||||||
|
00 15 mv.favara.mpqr
|
||||||
|
01 <len> <yyyy-MM-dd'T'HH:mm:ss.00000> ← Timestamp
|
||||||
|
6304<CRC>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parsing a PayMV QR (Incoming Scan)
|
||||||
|
|
||||||
|
When scanning a QR code, extract the relevant fields:
|
||||||
|
|
||||||
|
| Field | TLV path | Used for |
|
||||||
|
|---|---|---|
|
||||||
|
| Account number | root→`26`→`03` | Transfer destination |
|
||||||
|
| Amount | root→`54` | Pre-fill transfer amount (may be absent) |
|
||||||
|
| Merchant name | root→`59` | Display recipient name |
|
||||||
|
| Purpose | root→`62`→`08` | Pre-fill transfer remarks |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extracting a BML Gateway URL from a Combined QR
|
||||||
|
|
||||||
|
Combined QRs (e.g. Fahipay card QRs that embed a BML gateway payment URL) encode the BML URL at a fixed TLV path:
|
||||||
|
|
||||||
|
```
|
||||||
|
root tag 35 → sub-tag 20 → sub-sub-tag 01
|
||||||
|
```
|
||||||
|
|
||||||
|
The value at sub-sub-tag `01` is a full `https://pay.bml.com.mv/app/...` URL. Extract it and hand off to the [BML QR Payment flow](../bmlapi/13-qr-payment.md).
|
||||||
|
|
||||||
|
Plain BML QR codes (not combined) start with `https://pay.bml.com.mv/app/` directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example Payload
|
||||||
|
|
||||||
|
Static QR for account `7700000000123`, holder `"AHMED ALI"`, open amount, purpose `"Rent"`:
|
||||||
|
|
||||||
|
```
|
||||||
|
000201010211268...520400005303462
|
||||||
|
5802MV5909AHMED ALI6225050912345ABCDEF0804Rent
|
||||||
|
80...63044A2B
|
||||||
|
```
|
||||||
|
|
||||||
|
(values abbreviated for clarity — actual tags are concatenated with no whitespace)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← README](README.md) **Next →** [Account Parser Architecture](19-parsers.md)
|
||||||
@@ -83,3 +83,9 @@ Handles both CASA accounts and prepaid/credit cards.
|
|||||||
`AccountsAdapter` calls `AccountListParser.from(account)` once per item (skipping `null` results) and binds the resulting `AccountListDisplay` directly. The adapter has zero bank-specific logic.
|
`AccountsAdapter` calls `AccountListParser.from(account)` once per item (skipping `null` results) and binds the resulting `AccountListDisplay` directly. The adapter has zero bank-specific logic.
|
||||||
|
|
||||||
The transfer screen dropdown (`TransferFragment`) also uses `AccountListParser.from(acc)?.balance` for the source account balance display.
|
The transfer screen dropdown (`TransferFragment`) also uses `AccountListParser.from(acc)?.balance` for the source account balance display.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← PayMV QR Format](18-paymv-qr-format.md) **Next →** [Transfer Flows](20-transfer-flows.md)
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
# Transfer Flows
|
||||||
|
|
||||||
|
The transfer screen (`TransferFragment`) handles all outgoing payments across MIB, BML, and Fahipay. This document covers how the UI routes transfers, how recipients are looked up, which combinations are allowed, and which are rejected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Entry Points
|
||||||
|
|
||||||
|
`TransferFragment` can be launched in several modes depending on context:
|
||||||
|
|
||||||
|
| Factory method | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `newInstance(account, name, ...)` | Pre-fills the "To" card from a contact or recents pick |
|
||||||
|
| `newInstanceFrom(account)` | Pre-selects the given account in the "From" dropdown |
|
||||||
|
| `newInstanceFromQr(account, name, amount, remarks)` | Pre-fills recipient + optional amount/remarks from a PayMV QR scan |
|
||||||
|
| `newInstanceFromBmlQr(qrUrl, fromAccountNumber?)` | BML card/gateway QR merchant payment mode — locks recipient, may pre-fill amount |
|
||||||
|
| `newInstanceWithAutoScan()` | Opens the QR scanner immediately on load |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Account Input Detection
|
||||||
|
|
||||||
|
The raw "To" field input is normalised first (spaces stripped, `+960`/`960` country prefix removed if the result is 7 digits), then classified:
|
||||||
|
|
||||||
|
| Pattern | Type |
|
||||||
|
|---|---|
|
||||||
|
| Starts with `9`, exactly 17 digits | `MIB_ACCOUNT` |
|
||||||
|
| Starts with `7`, exactly 13 digits | `BML_ACCOUNT` |
|
||||||
|
| Starts with `7` or `9`, exactly 7 digits | `PHONE` |
|
||||||
|
| Starts with `A` followed by 6 digits | `NATIONAL_ID` |
|
||||||
|
| Contains `@` | `EMAIL` |
|
||||||
|
| Anything else | `UNKNOWN` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recipient Lookup
|
||||||
|
|
||||||
|
Lookup behaviour depends on the **source account's bank**.
|
||||||
|
|
||||||
|
### Fahipay source
|
||||||
|
|
||||||
|
Only `PHONE` input is accepted. Any other type is rejected immediately with an error on the "To" field.
|
||||||
|
|
||||||
|
Phone lookup hits both Dhiraagu and Ooredoo in parallel (order depends on the first digit):
|
||||||
|
|
||||||
|
- Numbers starting with `7`: Dhiraagu first, Ooredoo fallback
|
||||||
|
- Numbers starting with `9`: Ooredoo first, Dhiraagu fallback
|
||||||
|
|
||||||
|
The result maps to one or more Fahipay services:
|
||||||
|
|
||||||
|
| Carrier result | Service shown |
|
||||||
|
|---|---|
|
||||||
|
| Dhiraagu `RELOAD` | Dhiraagu Reload |
|
||||||
|
| Dhiraagu `BILL_PAY` | Dhiraagu Bill Pay |
|
||||||
|
| Ooredoo `PRE` or `HYBRID` | Raastas (prepaid top-up) |
|
||||||
|
| Ooredoo `POST` or `HYBRID` | Ooredoo Bill Pay |
|
||||||
|
|
||||||
|
If exactly one service matches, it is auto-selected. If multiple match (Ooredoo `HYBRID` gives two), a chip group is shown for the user to choose.
|
||||||
|
|
||||||
|
### BML source
|
||||||
|
|
||||||
|
1. If the input type is `MIB_ACCOUNT`, calls `BmlValidateClient.verifyMibAccount()`.
|
||||||
|
2. Otherwise calls `BmlValidateClient.validateAccount()`.
|
||||||
|
3. If either BML call fails and a MIB session is available, falls back to `MibTransferClient.lookup()`.
|
||||||
|
4. If both fail, shows the error from the MIB lookup (or a generic "account not found").
|
||||||
|
|
||||||
|
There is also a short-circuit: if the input matches a saved contact whose `transferCyDesc` is not `MVR`, the contact is used directly without a network lookup.
|
||||||
|
|
||||||
|
### MIB source
|
||||||
|
|
||||||
|
Calls `MibTransferClient.lookup()` directly. Errors from `MibLookupException` are shown verbatim to the user.
|
||||||
|
|
||||||
|
### BML-only session (no MIB session)
|
||||||
|
|
||||||
|
Falls back to `BmlValidateClient.validateAccount()` only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transfer Type Routing
|
||||||
|
|
||||||
|
Once the source and destination are resolved, the transfer type is determined as follows. This applies for both BML personal (`doBmlTransfer`) and BML business (`startBmlBusinessOtpFlow`) — the routing logic is identical.
|
||||||
|
|
||||||
|
```
|
||||||
|
Source: BML
|
||||||
|
|
||||||
|
├── isSrcCard (BML_PREPAID / BML_CREDIT / BML_DEBIT)
|
||||||
|
│ └── type = CAD creditAccount = dest BML CASA internalId (or dest account number)
|
||||||
|
│
|
||||||
|
├── isDestMyCard (destination is user's own BML card)
|
||||||
|
│ └── type = CPA creditAccount = card internalId
|
||||||
|
│
|
||||||
|
├── isDestMib && currency == MVR
|
||||||
|
│ └── type = DOT creditAccount = MIB account number bank = "MIB"
|
||||||
|
│
|
||||||
|
├── isDestMib && currency == USD
|
||||||
|
│ └── Requires a saved BML contact for that MIB account (see Rejections)
|
||||||
|
│ type = DOT creditAccount = contact.benefNo (numeric) bank = null
|
||||||
|
│
|
||||||
|
└── everything else (BML → BML CASA, BML → other local bank)
|
||||||
|
└── type = IAT creditAccount = dest account number
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Source: MIB
|
||||||
|
|
||||||
|
├── isDestMib (17-digit 9… account)
|
||||||
|
│ └── bankNo = 2 endpoint = transferInternal
|
||||||
|
│
|
||||||
|
└── everything else (BML or other local bank)
|
||||||
|
└── bankNo = 3 endpoint = transferLocal
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
Source: Fahipay
|
||||||
|
|
||||||
|
└── Routed to the selected service:
|
||||||
|
FAHIPAY_TRANSFER, RAASTAS, OOREDOO_BILL, DHIRAAGU_RELOAD, DHIRAAGU_BILL
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rejected Combinations
|
||||||
|
|
||||||
|
These combinations are blocked before a transfer is attempted.
|
||||||
|
|
||||||
|
### BML USD → MIB (no saved contact)
|
||||||
|
|
||||||
|
**Condition:** source is BML, currency is USD, destination is a MIB account, and no BML contact exists for that account number.
|
||||||
|
|
||||||
|
**Result:** dialog shown — "Contact required". The user must first add the MIB account as a BML contact before a USD cross-bank transfer can proceed.
|
||||||
|
|
||||||
|
> This is enforced in `initiateTransfer()` before reaching `doBmlTransfer`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BML QR payment — non-card source
|
||||||
|
|
||||||
|
**Condition:** in BML QR merchant payment mode and the user selects a non-card account (i.e. not `BML_PREPAID`, `BML_CREDIT`, or `BML_DEBIT`) from the "From" dropdown.
|
||||||
|
|
||||||
|
**Result:** selection is rejected with a toast: "Unsupported for BML QR — select a card". The dropdown resets.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fahipay — non-phone destination
|
||||||
|
|
||||||
|
**Condition:** source is Fahipay and the input type is anything other than `PHONE`.
|
||||||
|
|
||||||
|
**Result:** inline error on the "To" field: "Only phone numbers are supported for Fahipay transfers."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### No source account selected
|
||||||
|
|
||||||
|
**Condition:** user taps the lookup button or the transfer button without selecting a "From" account.
|
||||||
|
|
||||||
|
**Result:** toast: "Please select a source account first."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Inactive BML card as source
|
||||||
|
|
||||||
|
**Condition:** a BML card (`BML_PREPAID`, `BML_CREDIT`, `BML_DEBIT`) with `statusDesc != "Active"` appears in the dropdown but is not selectable — `getAccount()` returns `null` for it and `isEnabled()` returns `false`.
|
||||||
|
|
||||||
|
**Result:** the row is shown at 40% opacity and cannot be tapped.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Missing internalId
|
||||||
|
|
||||||
|
**Condition:** a BML source account has a blank `internalId` (needed as the `debitAccount` in BML API calls).
|
||||||
|
|
||||||
|
**Result:** transfer is aborted with a toast: "Missing internal account ID — please refresh your accounts."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Warnings (allowed but flagged)
|
||||||
|
|
||||||
|
These combinations proceed after user confirmation but show a prominent red warning in the confirm dialog.
|
||||||
|
|
||||||
|
### USD source → MVR destination
|
||||||
|
|
||||||
|
> "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!"
|
||||||
|
|
||||||
|
**Condition:** `src.currencyName == "USD"` and the resolved destination account's currency is `MVR`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BML credit card as source
|
||||||
|
|
||||||
|
> "Transferring from a credit card is treated as a cash advance. Cash advance fees will be charged on the 10th of the month."
|
||||||
|
|
||||||
|
**Condition:** `src.profileType == "BML_CREDIT"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BML Business Profile OTP Flow
|
||||||
|
|
||||||
|
Business profiles use a manual OTP delivered via email or SMS rather than a TOTP seed. The flow replaces the standard single-step confirm:
|
||||||
|
|
||||||
|
1. **Initiate** — `startBmlBusinessOtpFlow()` calls `BmlAccountClient.fetchTransferChannels()` to list available channels (email, SMS).
|
||||||
|
2. **Channel selection** — a channel picker is shown inline. Transfer fields are locked (dimmed, disabled).
|
||||||
|
3. **Initiate with channel** — `BmlTransferClient.initiateTransfer()` is called with the chosen channel, which triggers the OTP dispatch.
|
||||||
|
4. **OTP entry** — an OTP input field appears. The transfer button label changes to "Verify Payment".
|
||||||
|
5. **Confirm** — `BmlTransferClient.confirmTransfer()` is called with the entered OTP (not a generated TOTP).
|
||||||
|
|
||||||
|
If channel fetch fails or returns empty, the flow is aborted and the form is re-enabled.
|
||||||
|
|
||||||
|
**Profile detection:** `isBusinessProfile()` checks `bmlProfilesMap[loginId]` for a profile entry matching `src.profileId` with `profileType == "business"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## BML QR Merchant Payment Flow
|
||||||
|
|
||||||
|
Triggered when the transfer screen is opened via `newInstanceFromBmlQr()` or when a BML ebanking/pay.bml URL is scanned from the QR scanner.
|
||||||
|
|
||||||
|
Two sub-modes:
|
||||||
|
|
||||||
|
| Mode | Trigger | Extra step |
|
||||||
|
|---|---|---|
|
||||||
|
| Static card QR | URL starts with `https://ebanking.bankofmaldives.com.mv/qrpay/` | None |
|
||||||
|
| Gateway QR | URL starts with `https://pay.bml.com.mv/app/` | `BmlQrPayClient.preInitiatePayment()` required before initiate |
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. `lookupBmlQrMerchant()` — fetches merchant info via `BmlQrPayClient.lookupPayRequest()`. Locks the "To" row.
|
||||||
|
2. For dynamic QRs (`info.amount > 0`), pre-fills the amount and locks the amount field.
|
||||||
|
3. Remarks field is locked (not applicable for merchant payments).
|
||||||
|
4. On confirm: TOTP is generated, then `initiatePayment()` → (for gateway QR: `preInitiatePayment()` first) → `confirmPayment()` with a fresh TOTP.
|
||||||
|
5. On success: a success dialog is shown (no receipt saved). Back-press returns to previous screen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transfer Button Enable Conditions
|
||||||
|
|
||||||
|
The transfer button is only enabled when all of the following are true:
|
||||||
|
|
||||||
|
- A source account is selected
|
||||||
|
- A recipient is resolved (`resolvedAccountNumber` not blank, or `bmlQrInfo` is set)
|
||||||
|
- Amount is greater than `0`
|
||||||
|
- No connectivity error for `NO_INTERNET` or for the source bank
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Account Parser Architecture](19-parsers.md)
|
||||||
@@ -373,3 +373,9 @@ _None._
|
|||||||
- **MIB Blowfish/ECB** — inherited upstream protocol weakness, not actionable without server-side changes.
|
- **MIB Blowfish/ECB** — inherited upstream protocol weakness, not actionable without server-side changes.
|
||||||
- **DhiraaguClient JSON string interpolation** — low real-world risk given numeric-only input validation upstream.
|
- **DhiraaguClient JSON string interpolation** — low real-world risk given numeric-only input validation upstream.
|
||||||
- **`android:allowBackup="true"`** — flagged by automated scanners but effectively mitigated by the exclusion rules.
|
- **`android:allowBackup="true"`** — flagged by automated scanners but effectively mitigated by the exclusion rules.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[← Account Parser Architecture](19-parsers.md)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# App Internals
|
||||||
|
|
||||||
|
Documentation for app-specific logic — UI flows, routing decisions, and business rules implemented in the Android client.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI Flows
|
||||||
|
|
||||||
|
| Document | Description |
|
||||||
|
|---|---|
|
||||||
|
| [00 — App Overview](00-app-overview.md) | MainActivity routing, HomeActivity, navigation modes, autolock, global session state |
|
||||||
|
| [01 — Onboarding](01-onboarding.md) | Language selection, security setup (PIN/pattern), appearance configuration |
|
||||||
|
| [02 — Lock Screen](02-lock-screen.md) | LockActivity, PIN/pattern/biometric unlock, brute-force protection |
|
||||||
|
| [03 — Login](03-login.md) | Bank selection, credential entry, MIB/BML/Fahipay login flows, multi-profile support |
|
||||||
|
| [04 — Accounts](04-accounts.md) | Account list grouped display, AccountsAdapter, profile images, quick-transfer shortcut |
|
||||||
|
| [05 — Account History](05-account-history.md) | Paginated transaction history, search, infinite scroll |
|
||||||
|
| [06 — Transfer History](06-transfer-history.md) | Multi-bank merged transfer history, parallel loading |
|
||||||
|
| [07 — Transfer](07-transfer.md) | Recipient lookup, MIB/BML/Fahipay transfer flows, QR, biometric gate, BML OTP |
|
||||||
|
| [08 — Contacts](08-contacts.md) | Contact list, add/edit/delete, categories, contact picker sheet |
|
||||||
|
| [09 — Activities](09-activities.md) | Local transfer log, TransferReceiptFragment, share/save receipt |
|
||||||
|
| [10 — OTP Screen](10-otp-screen.md) | TOTP display, real-time countdown, enrolled bank authenticators |
|
||||||
|
| [11 — PayMV QR Screen](11-paymv-qr-screen.md) | Generate receive-payment QR, scan QR to initiate transfer |
|
||||||
|
| [12 — BML QR Pay](12-bml-qr-pay.md) | Scan BML merchant QR, merchant lookup, 3-step TOTP payment |
|
||||||
|
| [13 — Financing](13-financing.md) | MIB promotional deals, BML loans and card limits |
|
||||||
|
| [14 — Settings](14-settings.md) | Settings hub, logins management, profile images, add/logout accounts |
|
||||||
|
| [15 — Settings: Security](15-settings-security.md) | Change lock method, biometrics, auto-lock timeout, screenshots |
|
||||||
|
| [16 — Settings: Appearance](16-settings-appearance.md) | Navigation mode, slot drag-reorder, theme, accent colour, language |
|
||||||
|
| [17 — Settings: Storage](17-settings-storage.md) | Clear caches, profile images, transfer history |
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
| Document | Description |
|
||||||
|
|---|---|
|
||||||
|
| [18 — PayMV QR Format](18-paymv-qr-format.md) | Decimal TLV encoding, all tags, CRC-16, QR generation recipe, parsing reference |
|
||||||
|
| [19 — Parsers](19-parsers.md) | Account display parser architecture — how raw bank API data is normalised into a unified `AccountListDisplay` model |
|
||||||
|
| [20 — Transfer Flows](20-transfer-flows.md) | TransferFragment entry points, recipient lookup, transfer type routing, rejected combinations, BML business OTP flow, BML QR merchant payments |
|
||||||
|
| [AI Security Audit](AI_SECURITY_CHECK.md) | Full source security audit — credential storage, network layer, manifest, data privacy |
|
||||||
Reference in New Issue
Block a user