forked from shihaam/thijooree
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0efe833e40
|
||
|
|
f5f52829c7
|
||
|
|
3db077cf9a
|
||
|
|
ee5ecdaa18
|
||
|
|
2df162c09e
|
||
|
|
0f77216d2d
|
||
|
|
71e893faf8
|
||
|
|
1cd254c134
|
||
|
|
87536a339b
|
||
|
|
32d23a43b3
|
||
|
|
846ce22245
|
||
|
|
ed5b456e3b
|
||
|
|
9b284cc8d4
|
||
|
|
c0b58061c2
|
||
|
|
978da26ff1
|
||
|
|
7fe2ba5788
|
||
|
|
26a0c7b81d
|
||
|
|
83fc340e2b
|
||
|
|
bfbb649b33
|
||
|
|
b780091bb8
|
||
|
|
e4468c4a8f
|
||
|
|
b4e1f57347
|
||
|
|
907757c893
|
||
|
|
1ea0355ce6
|
||
|
|
c9b8973b65
|
||
|
|
7a0e32f4d6
|
||
|
|
d68b8aaf0a
|
||
|
|
396f778ad4
|
@@ -87,3 +87,24 @@ jobs:
|
|||||||
--data-binary "@${ASSET_PATH}"
|
--data-binary "@${ASSET_PATH}"
|
||||||
|
|
||||||
echo "Uploaded asset: $ASSET_NAME"
|
echo "Uploaded asset: $ASSET_NAME"
|
||||||
|
|
||||||
|
- name: Send APK to Telegram
|
||||||
|
env:
|
||||||
|
TG_BOT_TOKEN: ${{ secrets.TG_BOT_TOKEN }}
|
||||||
|
TG_CHAT_ID: ${{ vars.TG_CHAT_ID }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$TG_BOT_TOKEN" ] || [ -z "$TG_CHAT_ID" ]; then
|
||||||
|
echo "TG_BOT_TOKEN or TG_CHAT_ID not set, skipping Telegram upload."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_NAME="${{ gitea.repository }}"
|
||||||
|
APP_NAME="${APP_NAME##*/}"
|
||||||
|
TAG="${{ gitea.ref_name }}"
|
||||||
|
ASSET_PATH=".build/release/release/${APP_NAME}-${TAG}.apk"
|
||||||
|
CAPTION="${APP_NAME} ${TAG}"
|
||||||
|
|
||||||
|
curl -s -X POST "https://api.telegram.org/bot${TG_BOT_TOKEN}/sendDocument" \
|
||||||
|
-F "chat_id=${TG_CHAT_ID}" \
|
||||||
|
-F "document=@${ASSET_PATH}" \
|
||||||
|
-F "caption=${CAPTION}"
|
||||||
|
|||||||
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-22T00:11:32.873305232Z">
|
<DropdownSelection timestamp="2026-05-28T18:41:19.777722821Z">
|
||||||
<Target type="DEFAULT_BOOT">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="PhysicalDevice" identifier="serial=683a9830" />
|
<DeviceId pluginId="PhysicalDevice" identifier="serial=4254e2f" />
|
||||||
</handle>
|
</handle>
|
||||||
</Target>
|
</Target>
|
||||||
</DropdownSelection>
|
</DropdownSelection>
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ A native Android client for Maldivian banking services. It is a pure client: req
|
|||||||
- Existing accounts with MIB, BML, or Fahipay
|
- Existing accounts with MIB, BML, or Fahipay
|
||||||
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
|
- Your TOTP seed (base32 secret from your authenticator app setup) for each bank
|
||||||
|
|
||||||
## Download
|
## Download APK
|
||||||
[Download latest APK](https://git.shihaam.dev/shihaam/ISODroid/releases/latest)
|
[Gitea Releases](https://git.shihaam.dev/shihaam/thijooree/releases)
|
||||||
|
[Telegram Channel](https://t.me/s/thijooreeapks)
|
||||||
|
|
||||||
## Privacy
|
## Privacy
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ android {
|
|||||||
applicationId = "sh.sar.basedbank"
|
applicationId = "sh.sar.basedbank"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 8
|
versionCode = 11
|
||||||
versionName = "1.0.9"
|
versionName = "1.0.12"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -91,6 +91,9 @@ dependencies {
|
|||||||
// Biometric authentication
|
// Biometric authentication
|
||||||
implementation("androidx.biometric:biometric:1.1.0")
|
implementation("androidx.biometric:biometric:1.1.0")
|
||||||
|
|
||||||
|
// Encrypted SharedPreferences (HCE token store)
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shortcuts xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<shortcut
|
||||||
|
android:shortcutId="transfer"
|
||||||
|
android:enabled="true"
|
||||||
|
android:icon="@drawable/ic_shortcut_transfer"
|
||||||
|
android:shortcutShortLabel="@string/transfer"
|
||||||
|
android:shortcutLongLabel="@string/transfer">
|
||||||
|
<intent
|
||||||
|
android:action="sh.sar.basedbank.OPEN_TRANSFER"
|
||||||
|
android:targetPackage="sh.sar.basedbank.debug"
|
||||||
|
android:targetClass="sh.sar.basedbank.MainActivity" />
|
||||||
|
<categories android:name="android.shortcut.conversation" />
|
||||||
|
</shortcut>
|
||||||
|
|
||||||
|
<shortcut
|
||||||
|
android:shortcutId="scan_qr"
|
||||||
|
android:enabled="true"
|
||||||
|
android:icon="@drawable/ic_shortcut_scan_qr"
|
||||||
|
android:shortcutShortLabel="@string/transfer_scan_qr"
|
||||||
|
android:shortcutLongLabel="@string/transfer_scan_qr">
|
||||||
|
<intent
|
||||||
|
android:action="sh.sar.basedbank.OPEN_SCAN_QR"
|
||||||
|
android:targetPackage="sh.sar.basedbank.debug"
|
||||||
|
android:targetClass="sh.sar.basedbank.MainActivity" />
|
||||||
|
<categories android:name="android.shortcut.conversation" />
|
||||||
|
</shortcut>
|
||||||
|
|
||||||
|
<shortcut
|
||||||
|
android:shortcutId="tap_to_pay"
|
||||||
|
android:enabled="true"
|
||||||
|
android:icon="@drawable/ic_shortcut_pay_card"
|
||||||
|
android:shortcutShortLabel="@string/card_pay_nfc"
|
||||||
|
android:shortcutLongLabel="@string/card_pay_nfc">
|
||||||
|
<intent
|
||||||
|
android:action="sh.sar.basedbank.TAP_TO_PAY"
|
||||||
|
android:targetPackage="sh.sar.basedbank.debug"
|
||||||
|
android:targetClass="sh.sar.basedbank.MainActivity" />
|
||||||
|
<categories android:name="android.shortcut.conversation" />
|
||||||
|
</shortcut>
|
||||||
|
|
||||||
|
</shortcuts>
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<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-feature android:name="android.hardware.nfc.hce" android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".BasedBankApp"
|
android:name=".BasedBankApp"
|
||||||
@@ -59,6 +62,25 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:screenOrientation="portrait" />
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".nfc.BmlTapToPayActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:theme="@style/Theme.BasedBank" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".nfc.BmlHostCardEmulatorService"
|
||||||
|
android:exported="true"
|
||||||
|
android:permission="android.permission.BIND_NFC_SERVICE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.nfc.cardemulation.host_apdu_service"
|
||||||
|
android:resource="@xml/bml_aid_list" />
|
||||||
|
</service>
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
@@ -278,9 +278,11 @@ 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)
|
||||||
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)
|
||||||
})
|
})
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ class MainActivity : AppCompatActivity() {
|
|||||||
"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
|
||||||
"sh.sar.basedbank.OPEN_PAY_WITH_CARD" -> R.id.nav_pay_with_card
|
"sh.sar.basedbank.OPEN_PAY_WITH_CARD" -> R.id.nav_pay_with_card
|
||||||
|
"sh.sar.basedbank.TAP_TO_PAY" -> R.id.nav_pay_with_card
|
||||||
else -> -1
|
else -> -1
|
||||||
}
|
}
|
||||||
val autoScan = intent?.action == "sh.sar.basedbank.OPEN_SCAN_QR"
|
val autoScan = intent?.action == "sh.sar.basedbank.OPEN_SCAN_QR"
|
||||||
|
val autoTapMode = intent?.action == "sh.sar.basedbank.TAP_TO_PAY"
|
||||||
|
|
||||||
val target = when {
|
val target = when {
|
||||||
!onboardingDone -> OnboardingActivity::class.java
|
!onboardingDone -> OnboardingActivity::class.java
|
||||||
@@ -43,6 +45,7 @@ class MainActivity : AppCompatActivity() {
|
|||||||
startActivity(Intent(this, target).apply {
|
startActivity(Intent(this, target).apply {
|
||||||
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)
|
||||||
})
|
})
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,15 @@ data class BmlQrPayResult(
|
|||||||
val errorMessage: String = ""
|
val errorMessage: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class BmlWalletToken(
|
||||||
|
val token: String,
|
||||||
|
val expiry: String,
|
||||||
|
val appCode: String, // AID hex, e.g. "A0000000031010"
|
||||||
|
val serviceCode: String,
|
||||||
|
val data: String,
|
||||||
|
val validUntil: String // "YYYY-MM-DD HH:mm:ss.SSS"
|
||||||
|
)
|
||||||
|
|
||||||
data class BmlForeignLimit(
|
data class BmlForeignLimit(
|
||||||
val type: String,
|
val type: String,
|
||||||
val used: Double,
|
val used: Double,
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package sh.sar.basedbank.api.bml
|
||||||
|
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class BmlTapToPayClient {
|
||||||
|
|
||||||
|
private val client = newBmlApiClient()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches up to [quantity] single-use payment tokens for [cardId].
|
||||||
|
* [otp] is a TOTP code generated from the stored BML OTP seed.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. POST → code 99 (OTP required) or 0 (direct, unlikely)
|
||||||
|
* 2. POST with channel=token → code 22 (OTP generated on BML side, but we use TOTP)
|
||||||
|
* 3. POST with otp=TOTP → code 0, payload = token list
|
||||||
|
*/
|
||||||
|
fun fetchTokens(
|
||||||
|
session: BmlSession,
|
||||||
|
cardId: String,
|
||||||
|
otp: String,
|
||||||
|
quantity: Int = 3
|
||||||
|
): List<BmlWalletToken> {
|
||||||
|
val url = "$BML_BASE_URL/api/mobile/walletpayments/gettoken"
|
||||||
|
|
||||||
|
// Step 1: initiate
|
||||||
|
val base = JSONObject().apply {
|
||||||
|
put("type", "track2")
|
||||||
|
put("cardid", cardId)
|
||||||
|
put("quantity", quantity)
|
||||||
|
}
|
||||||
|
val step1 = post(session, url, base)
|
||||||
|
if (step1.optInt("code") == 0) return parseTokens(step1.optJSONArray("payload"))
|
||||||
|
if (step1.optInt("code") != 99) throw Exception(step1.optString("message", "Token request failed"))
|
||||||
|
|
||||||
|
// Step 2: request OTP channel (triggers BML to validate we can use TOTP)
|
||||||
|
val body2 = JSONObject(base.toString()).apply { put("channel", "token") }
|
||||||
|
val step2 = post(session, url, body2)
|
||||||
|
if (step2.optInt("code") != 22) throw Exception(step2.optString("message", "OTP channel request failed"))
|
||||||
|
|
||||||
|
// Step 3: submit TOTP
|
||||||
|
val body3 = JSONObject(body2.toString()).apply { put("otp", otp) }
|
||||||
|
val step3 = post(session, url, body3)
|
||||||
|
if (step3.optInt("code") != 0) throw Exception(step3.optString("message", "Token fetch failed"))
|
||||||
|
|
||||||
|
return parseTokens(step3.optJSONArray("payload"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun post(session: BmlSession, url: String, body: JSONObject): JSONObject {
|
||||||
|
val req = okhttp3.Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.post(body.toString().toRequestBody("application/json".toMediaType()))
|
||||||
|
.header("Authorization", "Bearer ${session.accessToken}")
|
||||||
|
.header("User-Agent", BML_USER_AGENT)
|
||||||
|
.header("x-app-version", BML_APP_VERSION)
|
||||||
|
.build()
|
||||||
|
return client.newCall(req).execute().use { resp ->
|
||||||
|
JSONObject(resp.body?.string() ?: throw Exception("Empty response"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseTokens(arr: JSONArray?): List<BmlWalletToken> {
|
||||||
|
arr ?: return emptyList()
|
||||||
|
return (0 until arr.length()).map { i ->
|
||||||
|
val o = arr.getJSONObject(i)
|
||||||
|
BmlWalletToken(
|
||||||
|
token = o.getString("token"),
|
||||||
|
expiry = o.getString("expiry"),
|
||||||
|
appCode = o.getString("app_code"),
|
||||||
|
serviceCode = o.getString("service_code"),
|
||||||
|
data = o.optString("data", ""),
|
||||||
|
validUntil = o.optString("valid_until", "")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package sh.sar.basedbank.nfc
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.nfc.cardemulation.HostApduService
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Log
|
||||||
|
import sh.sar.basedbank.api.bml.BmlWalletToken
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HCE service that emulates a BML contactless payment card.
|
||||||
|
*
|
||||||
|
* Implements the minimal EMV mag-stripe contactless flow:
|
||||||
|
* SELECT PPSE → SELECT AID → GET PROCESSING OPTIONS → READ RECORD
|
||||||
|
*
|
||||||
|
* Each BmlWalletToken is single-use and is set via [setToken] before tapping.
|
||||||
|
* After READ RECORD is sent the [onTransactionComplete] callback fires.
|
||||||
|
*/
|
||||||
|
class BmlHostCardEmulatorService : HostApduService() {
|
||||||
|
|
||||||
|
private var gpoSent = false
|
||||||
|
|
||||||
|
override fun processCommandApdu(commandApdu: ByteArray?, extras: Bundle?): ByteArray {
|
||||||
|
if (commandApdu == null) return SW_UNKNOWN_ERROR
|
||||||
|
val apdu = Apdu(commandApdu)
|
||||||
|
if (apdu.isError) return apdu.errorResponse()
|
||||||
|
|
||||||
|
return when (apdu.ins) {
|
||||||
|
INS_SELECT -> handleSelect(apdu)
|
||||||
|
INS_GPO -> handleGpo()
|
||||||
|
INS_READ -> handleReadRecord()
|
||||||
|
else -> SW_UNKNOWN_ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDeactivated(reason: Int) {
|
||||||
|
if (!gpoSent) onTransactionComplete?.invoke(false)
|
||||||
|
gpoSent = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── APDU handlers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun handleSelect(apdu: Apdu): ByteArray {
|
||||||
|
val data = apdu.data ?: return SW_UNKNOWN_ERROR
|
||||||
|
|
||||||
|
if (data.contentEquals(PPSE_BYTES)) {
|
||||||
|
val token = activeToken ?: run { launchPromptActivity(); return SW_UNKNOWN_ERROR }
|
||||||
|
return hexToBytes(buildSelectPpseResponse(token.appCode, applicationLabel(token.appCode), "01"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val token = activeToken ?: return SW_UNKNOWN_ERROR
|
||||||
|
return if (data.contentEquals(hexToBytes(token.appCode))) {
|
||||||
|
hexToBytes(buildSelectAidResponse(token.appCode, applicationLabel(token.appCode)))
|
||||||
|
} else {
|
||||||
|
SW_UNKNOWN_ERROR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchPromptActivity() {
|
||||||
|
val intent = Intent("sh.sar.basedbank.TAP_TO_PAY").apply {
|
||||||
|
setPackage(applicationContext.packageName)
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
}
|
||||||
|
startActivity(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleGpo(): ByteArray {
|
||||||
|
gpoSent = true
|
||||||
|
// AIP=0080 (mag-stripe mode), AFL=08010100 (SFI=1, record 1-1, offline 0)
|
||||||
|
val miscData = "008008010100"
|
||||||
|
val body = tlv("80", miscData)
|
||||||
|
return hexToBytes(body + SW_OK_HEX)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleReadRecord(): ByteArray {
|
||||||
|
val token = activeToken ?: return SW_UNKNOWN_ERROR
|
||||||
|
val track2 = buildTrack2(token)
|
||||||
|
val body = tlv("70", tlv("57", track2))
|
||||||
|
val response = hexToBytes(body + SW_OK_HEX)
|
||||||
|
onTransactionComplete?.invoke(true)
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TLV / APDU response builders ───────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun buildSelectPpseResponse(aid: String, label: String, priority: String): String {
|
||||||
|
val priorityTlv = tlv("87", priority) // tag 87
|
||||||
|
val aidTlv = tlv("4F", aid) // tag 4F (ADF Name)
|
||||||
|
val appEntry = tlv("61", aidTlv + priorityTlv) // tag 61
|
||||||
|
val ppseTlv = tlv("84", PPSE_HEX) // tag 84 (DF Name)
|
||||||
|
val inner = tlv("BF0C", appEntry) // tag BF0C
|
||||||
|
val propTemplate = tlv("A5", inner) // tag A5
|
||||||
|
val fci = tlv("6F", ppseTlv + propTemplate) // tag 6F
|
||||||
|
return fci + SW_OK_HEX
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildSelectAidResponse(aid: String, label: String): String {
|
||||||
|
val aidTlv = tlv("84", aid) // tag 84
|
||||||
|
val labelTlv = tlv("50", asciiToHex(label)) // tag 50
|
||||||
|
val pdolTlv = tlv("9F38", "9F6602") // PDOL: TTQ 2 bytes
|
||||||
|
val propTemplate = tlv("A5", labelTlv + pdolTlv) // tag A5
|
||||||
|
val fci = tlv("6F", aidTlv + propTemplate) // tag 6F
|
||||||
|
return fci + SW_OK_HEX
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildTrack2(token: BmlWalletToken): String {
|
||||||
|
var t2 = "${token.token}D${token.expiry}${token.serviceCode}${token.data}"
|
||||||
|
if (t2.length % 2 != 0) t2 += "F"
|
||||||
|
return t2
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Build BER-TLV: tag (hex string, 1 or 2 bytes) + DER length + data (hex string). */
|
||||||
|
private fun tlv(tagHex: String, dataHex: String): String {
|
||||||
|
val lenBytes = dataHex.length / 2
|
||||||
|
val lenHex = when {
|
||||||
|
lenBytes <= 0x7F -> lenBytes.toHexByte()
|
||||||
|
lenBytes <= 0xFF -> "81" + lenBytes.toHexByte()
|
||||||
|
else -> "82" + (lenBytes shr 8).toHexByte() + (lenBytes and 0xFF).toHexByte()
|
||||||
|
}
|
||||||
|
return tagHex + lenHex + dataHex
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Int.toHexByte(): String = toString(16).padStart(2, '0').uppercase()
|
||||||
|
|
||||||
|
private fun asciiToHex(s: String): String =
|
||||||
|
s.toByteArray(Charsets.US_ASCII).joinToString("") { "%02X".format(it) }
|
||||||
|
|
||||||
|
private fun hexToBytes(hex: String): ByteArray {
|
||||||
|
val s = hex.uppercase()
|
||||||
|
return ByteArray(s.length / 2) { i ->
|
||||||
|
s.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── APDU parser ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private inner class Apdu(raw: ByteArray) {
|
||||||
|
val isError: Boolean
|
||||||
|
val ins: Int
|
||||||
|
val data: ByteArray?
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (raw.size < 4) {
|
||||||
|
isError = true; ins = -1; data = null
|
||||||
|
} else {
|
||||||
|
isError = false
|
||||||
|
ins = raw[1].toInt() and 0xFF
|
||||||
|
val lc = if (raw.size > 4) raw[4].toInt() and 0xFF else 0
|
||||||
|
data = if (lc > 0 && raw.size >= 5 + lc) raw.copyOfRange(5, 5 + lc) else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun errorResponse() = SW_UNKNOWN_ERROR
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BmlHCE"
|
||||||
|
|
||||||
|
private const val INS_SELECT = 0xA4
|
||||||
|
private const val INS_GPO = 0xA8
|
||||||
|
private const val INS_READ = 0xB2
|
||||||
|
|
||||||
|
private val PPSE_HEX = "325041592E5359532E4444463031" // "2PAY.SYS.DDF01"
|
||||||
|
private val PPSE_BYTES = byteArrayOf(
|
||||||
|
0x32,0x50,0x41,0x59,0x2E,0x53,0x59,0x53,0x2E,0x44,0x44,0x46,0x30,0x31
|
||||||
|
)
|
||||||
|
|
||||||
|
private const val SW_OK_HEX = "9000"
|
||||||
|
private val SW_UNKNOWN_ERROR = byteArrayOf(0x6F.toByte(), 0x00.toByte())
|
||||||
|
|
||||||
|
@Volatile var activeToken: BmlWalletToken? = null
|
||||||
|
@Volatile var onTransactionComplete: ((success: Boolean) -> Unit)? = null
|
||||||
|
|
||||||
|
fun setToken(token: BmlWalletToken) { activeToken = token }
|
||||||
|
fun clearToken() { activeToken = null }
|
||||||
|
|
||||||
|
fun applicationLabel(aidHex: String): String = when {
|
||||||
|
aidHex.startsWith("A0000000031010", ignoreCase = true) -> "VISA"
|
||||||
|
aidHex.startsWith("A0000000041010", ignoreCase = true) -> "MASTERCARD"
|
||||||
|
aidHex.startsWith("A000000025", ignoreCase = true) -> "AMEX"
|
||||||
|
else -> "BML"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package sh.sar.basedbank.nfc
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
|
import sh.sar.basedbank.MainActivity
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback entry point — redirects to MainActivity which routes to the in-app tap-to-pay screen.
|
||||||
|
*/
|
||||||
|
class BmlTapToPayActivity : AppCompatActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
startActivity(Intent(this, MainActivity::class.java).apply {
|
||||||
|
action = "sh.sar.basedbank.TAP_TO_PAY"
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||||
|
})
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ 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 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
|
||||||
@@ -40,10 +42,10 @@ class DashboardFragment : Fragment() {
|
|||||||
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
|
||||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
|
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
||||||
raw.startsWith("https://pay.bml.com.mv/app/")) {
|
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(raw, pendingQrAccountNumber)
|
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, pendingQrAccountNumber)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||||
@@ -96,13 +98,22 @@ class DashboardFragment : Fragment() {
|
|||||||
LinearSnapHelper().attachToRecyclerView(binding.rvCards)
|
LinearSnapHelper().attachToRecyclerView(binding.rvCards)
|
||||||
|
|
||||||
val updateCardList = {
|
val updateCardList = {
|
||||||
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
|
val credStore = CredentialStore(requireContext())
|
||||||
|
val hidden = credStore.getHiddenDashboardCardNumbers()
|
||||||
|
val mibItems = (viewModel.mibCards.value ?: emptyList())
|
||||||
|
.filter { !hidden.contains(it.maskedCardNumber) }
|
||||||
|
.map { CardItem.Mib(it) }
|
||||||
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
||||||
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) }
|
.filter { (it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") && it.statusDesc.equals("Active", ignoreCase = true) && !hidden.contains(it.accountNumber) }
|
||||||
.map { CardItem.Bml(it) }
|
.map { CardItem.Bml(it) }
|
||||||
val all = mibItems + bmlItems
|
val all = mibItems + bmlItems
|
||||||
cardAdapter.update(all)
|
val defaultNum = credStore.getDefaultCardAccountNumber()
|
||||||
binding.sectionCards.visibility = if (all.isNotEmpty()) View.VISIBLE else View.GONE
|
val ordered = if (defaultNum != null) {
|
||||||
|
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||||
|
if (def != null) listOf(def) + all.filter { it !== def } else all
|
||||||
|
} else all
|
||||||
|
cardAdapter.update(ordered)
|
||||||
|
binding.sectionCards.visibility = if (ordered.isNotEmpty()) View.VISIBLE else View.GONE
|
||||||
}
|
}
|
||||||
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
|
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
|
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
|
||||||
@@ -278,26 +289,23 @@ class DashboardFragment : Fragment() {
|
|||||||
.groupBy({ it.first }, { it.second })
|
.groupBy({ it.first }, { it.second })
|
||||||
.mapValues { (_, vs) -> vs.sum() }
|
.mapValues { (_, vs) -> vs.sum() }
|
||||||
|
|
||||||
|
val blockedMvr = blockedByCurrency["MVR"] ?: 0.0
|
||||||
|
val blockedUsd = blockedByCurrency["USD"] ?: 0.0
|
||||||
val blockedTotal = blockedByCurrency.values.sum()
|
val blockedTotal = blockedByCurrency.values.sum()
|
||||||
if (blockedTotal > 0.0) {
|
|
||||||
// Primary line: prefer MVR if present, otherwise the first currency.
|
|
||||||
val primaryCcy = if ("MVR" in blockedByCurrency) "MVR" else blockedByCurrency.keys.first()
|
|
||||||
val primaryAmt = blockedByCurrency.getValue(primaryCcy)
|
|
||||||
binding.tvBlockedTotal.text = if (hide) "$primaryCcy ••••••" else "$primaryCcy %,.2f".format(primaryAmt)
|
|
||||||
|
|
||||||
val secondary = blockedByCurrency.filterKeys { it != primaryCcy }
|
if (blockedMvr > 0.0) {
|
||||||
if (secondary.isNotEmpty()) {
|
binding.tvBlockedMvr.text = if (hide) "MVR ••••••" else "MVR %,.2f".format(blockedMvr)
|
||||||
binding.tvBlockedSecondary.text = secondary.entries.joinToString(" · ") { (ccy, amt) ->
|
binding.cardBlockedMvr.visibility = View.VISIBLE
|
||||||
if (hide) "$ccy ••••••" else "$ccy %,.2f".format(amt)
|
|
||||||
}
|
|
||||||
binding.tvBlockedSecondary.visibility = View.VISIBLE
|
|
||||||
} else {
|
|
||||||
binding.tvBlockedSecondary.visibility = View.GONE
|
|
||||||
}
|
|
||||||
binding.cardBlocked.visibility = View.VISIBLE
|
|
||||||
} else {
|
} else {
|
||||||
binding.cardBlocked.visibility = View.GONE
|
binding.cardBlockedMvr.visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
if (blockedUsd > 0.0) {
|
||||||
|
binding.tvBlockedUsd.text = if (hide) "USD ••••••" else "USD %,.2f".format(blockedUsd)
|
||||||
|
binding.cardBlockedUsd.visibility = View.VISIBLE
|
||||||
|
} else {
|
||||||
|
binding.cardBlockedUsd.visibility = View.GONE
|
||||||
|
}
|
||||||
|
binding.rowBlocked.visibility = if (blockedTotal > 0.0) View.VISIBLE else View.GONE
|
||||||
|
|
||||||
// Overdue: MIB finance deals + BML loan details (assumed MVR — matches existing Pending Finances).
|
// Overdue: MIB finance deals + BML loan details (assumed MVR — matches existing Pending Finances).
|
||||||
val mibOverdue = (viewModel.financing.value ?: emptyList()).sumOf { it.overdueAmount }
|
val mibOverdue = (viewModel.financing.value ?: emptyList()).sumOf { it.overdueAmount }
|
||||||
@@ -310,8 +318,7 @@ class DashboardFragment : Fragment() {
|
|||||||
binding.cardOverdue.visibility = View.GONE
|
binding.cardOverdue.visibility = View.GONE
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.rowAttention.visibility =
|
binding.rowAttention.visibility = if (overdueTotal > 0.0) View.VISIBLE else View.GONE
|
||||||
if (blockedTotal > 0.0 || overdueTotal > 0.0) View.VISIBLE else View.GONE
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePendingFinances() {
|
private fun updatePendingFinances() {
|
||||||
@@ -383,8 +390,15 @@ class DashboardFragment : Fragment() {
|
|||||||
val nfcSupported = nfcAdapter != null
|
val nfcSupported = nfcAdapter != null
|
||||||
btnPayNfc.isEnabled = nfcSupported
|
btnPayNfc.isEnabled = nfcSupported
|
||||||
btnPayNfc.setOnClickListener {
|
btnPayNfc.setOnClickListener {
|
||||||
val msg = if (isMib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
|
if (isMib) {
|
||||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
val accountNumber = (item as CardItem.Bml).account.accountNumber
|
||||||
|
(requireActivity() as HomeActivity).navigateTo(
|
||||||
|
R.id.nav_pay_with_card,
|
||||||
|
CardsFragment.newInstanceWithAutoTapMode(accountNumber)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,10 +237,13 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
if (savedInstanceState == null) {
|
if (savedInstanceState == null) {
|
||||||
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)
|
||||||
if (navDest != -1) {
|
if (navDest != -1) {
|
||||||
val fragment = if (autoScan && navDest == R.id.nav_transfer)
|
val fragment = when {
|
||||||
TransferFragment.newInstanceWithAutoScan()
|
autoScan && navDest == R.id.nav_transfer -> TransferFragment.newInstanceWithAutoScan()
|
||||||
else null
|
autoTapMode && navDest == R.id.nav_pay_with_card -> CardsFragment.newInstanceWithAutoTapMode()
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
navigateTo(navDest, fragment)
|
navigateTo(navDest, fragment)
|
||||||
} else {
|
} else {
|
||||||
show(DashboardFragment())
|
show(DashboardFragment())
|
||||||
@@ -255,6 +258,9 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
binding.drawerLayout.closeDrawers()
|
binding.drawerLayout.closeDrawers()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Let CardsFragment handle back if in manage mode
|
||||||
|
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
|
||||||
|
if (currentFrag is CardsFragment && currentFrag.onBackPressed()) 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()
|
||||||
@@ -263,7 +269,6 @@ class HomeActivity : AppCompatActivity() {
|
|||||||
// 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)
|
val isBottomNav = getSharedPreferences("prefs", MODE_PRIVATE).getBoolean("bottom_nav", false)
|
||||||
if (isBottomNav && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
|
if (isBottomNav && binding.bottomNavigation.selectedItemId != R.id.nav_dashboard) {
|
||||||
val currentFrag = supportFragmentManager.findFragmentById(R.id.contentFrame)
|
|
||||||
// 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())
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
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.Toast
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
@@ -42,6 +47,16 @@ class OtpFragment : Fragment() {
|
|||||||
override fun onBindViewHolder(holder: VH, position: Int) {
|
override fun onBindViewHolder(holder: VH, position: Int) {
|
||||||
holder.b.tvOtpLabel.text = entries[position].label
|
holder.b.tvOtpLabel.text = entries[position].label
|
||||||
update(holder.b, entries[position].seed)
|
update(holder.b, entries[position].seed)
|
||||||
|
holder.b.root.setOnClickListener {
|
||||||
|
val code = holder.b.tvOtpCode.text.toString().replace(" ", "")
|
||||||
|
if (code.isNotEmpty()) {
|
||||||
|
val clipboard = it.context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
Toast.makeText(it.context, "OTP copied", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun tick() {
|
fun tick() {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ 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.databinding.FragmentPayMvQrBinding
|
import sh.sar.basedbank.databinding.FragmentPayMvQrBinding
|
||||||
|
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.PaymvQrParser
|
||||||
@@ -60,9 +61,9 @@ class PayMvQrFragment : Fragment() {
|
|||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
|
|
||||||
// BML card/gateway QR — hand off to dedicated payment screen
|
// BML card/gateway QR — hand off to dedicated payment screen
|
||||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
|
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
||||||
raw.startsWith("https://pay.bml.com.mv/app/")) {
|
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw))
|
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw))
|
||||||
return@registerForActivityResult
|
return@registerForActivityResult
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +99,8 @@ class PayMvQrFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
setupDropdown()
|
setupDropdown()
|
||||||
binding.etAmount.addTextChangedListener { scheduleGenerate() }
|
binding.etAmount.addTextChangedListener { scheduleGenerate() }
|
||||||
|
binding.etReference.addTextChangedListener { scheduleGenerate() }
|
||||||
|
binding.switchIncludePhone.setOnCheckedChangeListener { _, _ -> scheduleGenerate() }
|
||||||
binding.btnShare.isEnabled = false
|
binding.btnShare.isEnabled = false
|
||||||
binding.btnSave.isEnabled = false
|
binding.btnSave.isEnabled = false
|
||||||
binding.btnShare.setOnClickListener { shareQr() }
|
binding.btnShare.setOnClickListener { shareQr() }
|
||||||
@@ -110,7 +113,9 @@ class PayMvQrFragment : Fragment() {
|
|||||||
private fun setupDropdown() {
|
private fun setupDropdown() {
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
viewModel.accounts.observe(viewLifecycleOwner) { accounts ->
|
||||||
val eligible = accounts.filter {
|
val eligible = accounts.filter {
|
||||||
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN"
|
it.profileType != "BML_PREPAID" && it.profileType != "BML_CREDIT" && it.profileType != "BML_DEBIT" && it.profileType != "BML_LOAN" &&
|
||||||
|
it.bank != "MIB" && // TODO: MIB does not support PayMV QR
|
||||||
|
!(it.bank == "BML" && it.currencyName.contains("USD", ignoreCase = true)) // TODO: BML USD not supported by MMA
|
||||||
}
|
}
|
||||||
val adapter = QrAccountAdapter(requireContext(), eligible)
|
val adapter = QrAccountAdapter(requireContext(), eligible)
|
||||||
binding.actvAccount.setAdapter(adapter)
|
binding.actvAccount.setAdapter(adapter)
|
||||||
@@ -145,8 +150,28 @@ class PayMvQrFragment : Fragment() {
|
|||||||
?.let { "%.2f".format(it) }
|
?.let { "%.2f".format(it) }
|
||||||
|
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
|
val includePhone = binding.switchIncludePhone.isChecked
|
||||||
|
val loginId = sh.sar.basedbank.util.ProfileImageStore.loginIdFromTag(account.loginTag)
|
||||||
|
val store = CredentialStore(ctx)
|
||||||
|
val mobile = if (includePhone) {
|
||||||
|
when (account.bank) {
|
||||||
|
"BML" -> store.loadBmlUserProfile(loginId)?.mobile
|
||||||
|
"FAHIPAY" -> store.loadFahipayUserProfile(loginId)?.mobile
|
||||||
|
else -> null
|
||||||
|
}?.let { m ->
|
||||||
|
when {
|
||||||
|
m.startsWith("+") -> m
|
||||||
|
m.length == 7 -> "+960$m"
|
||||||
|
else -> m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else null
|
||||||
|
|
||||||
|
val purpose = binding.etReference.text?.toString()?.trim()
|
||||||
|
?.takeIf { it.isNotBlank() } ?: getString(R.string.paymvqr_reference_default)
|
||||||
|
|
||||||
val bmp = withContext(Dispatchers.Default) {
|
val bmp = withContext(Dispatchers.Default) {
|
||||||
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted)
|
val payload = buildQrPayload(account.accountNumber, account.accountBriefName, acquirer, amountFormatted, mobile, purpose)
|
||||||
renderQrCard(ctx, account, payload, amountFormatted)
|
renderQrCard(ctx, account, payload, amountFormatted)
|
||||||
}
|
}
|
||||||
if (_binding == null) return
|
if (_binding == null) return
|
||||||
@@ -164,7 +189,9 @@ class PayMvQrFragment : Fragment() {
|
|||||||
accountNumber: String,
|
accountNumber: String,
|
||||||
accountName: String,
|
accountName: String,
|
||||||
acquirer: String,
|
acquirer: String,
|
||||||
amountStr: String?
|
amountStr: String?,
|
||||||
|
mobile: String?,
|
||||||
|
purpose: String
|
||||||
): String {
|
): String {
|
||||||
fun tlv(tag: String, value: String): String {
|
fun tlv(tag: String, value: String): String {
|
||||||
val len = value.length
|
val len = value.length
|
||||||
@@ -174,17 +201,30 @@ class PayMvQrFragment : Fragment() {
|
|||||||
val poi = tlv("01", "11")
|
val poi = tlv("01", "11")
|
||||||
val sub00 = tlv("00", "mv.favara.mpqr")
|
val sub00 = tlv("00", "mv.favara.mpqr")
|
||||||
val sub01 = tlv("01", acquirer)
|
val sub01 = tlv("01", acquirer)
|
||||||
|
val sub02 = tlv("02", acquirer) // repeated acquirer, as per official PayMV app
|
||||||
val sub03 = tlv("03", accountNumber)
|
val sub03 = tlv("03", accountNumber)
|
||||||
|
val sub05 = if (!mobile.isNullOrBlank()) tlv("05", mobile) else ""
|
||||||
val sub10 = tlv("10", "IPAY")
|
val sub10 = tlv("10", "IPAY")
|
||||||
val merchantAcct = tlv("26", sub00 + sub01 + sub03 + sub10)
|
val merchantAcct = tlv("26", sub00 + sub01 + sub02 + sub03 + sub05 + sub10)
|
||||||
|
val mcc = tlv("52", "0000")
|
||||||
val currency = tlv("53", "462")
|
val currency = tlv("53", "462")
|
||||||
val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else ""
|
val amountTLV = if (!amountStr.isNullOrBlank()) tlv("54", amountStr) else ""
|
||||||
val country = tlv("58", "MV")
|
val country = tlv("58", "MV")
|
||||||
val name = tlv("59", accountName.take(25))
|
val name = tlv("59", accountName.take(25))
|
||||||
val prefix = format + poi + merchantAcct + currency + amountTLV + country + name + "6304"
|
val ref = generateReference()
|
||||||
|
val addlData = tlv("62", tlv("05", ref) + tlv("08", purpose))
|
||||||
|
val timestamp = java.time.LocalDateTime.now()
|
||||||
|
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.00000"))
|
||||||
|
val tag80 = tlv("80", tlv("00", "mv.favara.mpqr") + tlv("01", timestamp))
|
||||||
|
val prefix = format + poi + merchantAcct + mcc + currency + amountTLV + country + name + addlData + tag80 + "6304"
|
||||||
return prefix + crc16(prefix)
|
return prefix + crc16(prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun generateReference(): String {
|
||||||
|
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
return (1..9).map { chars.random() }.joinToString("")
|
||||||
|
}
|
||||||
|
|
||||||
private fun crc16(data: String): String {
|
private fun crc16(data: String): String {
|
||||||
var crc = 0xFFFF
|
var crc = 0xFFFF
|
||||||
for (c in data) {
|
for (c in data) {
|
||||||
@@ -427,7 +467,7 @@ class PayMvQrFragment : Fragment() {
|
|||||||
} else {
|
} else {
|
||||||
b.tvDropdownAccountType.visibility = View.GONE
|
b.tvDropdownAccountType.visibility = View.GONE
|
||||||
}
|
}
|
||||||
b.tvDropdownBalance.text = displayData?.balance ?: ""
|
b.tvDropdownBalance.visibility = View.GONE
|
||||||
b.root.alpha = 1f
|
b.root.alpha = 1f
|
||||||
|
|
||||||
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
|
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
|
||||||
|
|||||||
@@ -14,19 +14,41 @@ 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 androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import android.view.animation.AccelerateInterpolator
|
||||||
|
import android.view.animation.DecelerateInterpolator
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.doOnNextLayout
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import androidx.recyclerview.widget.PagerSnapHelper
|
import androidx.recyclerview.widget.PagerSnapHelper
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import android.animation.ValueAnimator
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.RectF
|
||||||
|
import android.view.Gravity
|
||||||
|
import androidx.biometric.BiometricManager
|
||||||
|
import androidx.biometric.BiometricPrompt
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import com.google.android.material.button.MaterialButton
|
||||||
import com.google.android.material.color.MaterialColors
|
import com.google.android.material.color.MaterialColors
|
||||||
|
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.R
|
||||||
|
import sh.sar.basedbank.api.bml.BmlTapToPayClient
|
||||||
|
import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
|
||||||
import sh.sar.basedbank.api.mib.MibCard
|
import sh.sar.basedbank.api.mib.MibCard
|
||||||
import sh.sar.basedbank.databinding.FragmentCardsBinding
|
import sh.sar.basedbank.databinding.FragmentCardsBinding
|
||||||
import sh.sar.basedbank.util.CardsCache
|
import sh.sar.basedbank.util.CardsCache
|
||||||
|
import sh.sar.basedbank.util.CredentialStore
|
||||||
|
import sh.sar.basedbank.util.Totp
|
||||||
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
import sh.sar.basedbank.util.bmlapi.BmlCardParser
|
||||||
|
import sh.sar.basedbank.util.PaymvQrParser
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
class CardsFragment : Fragment() {
|
class CardsFragment : Fragment() {
|
||||||
@@ -39,14 +61,30 @@ class CardsFragment : Fragment() {
|
|||||||
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 pendingQrAccountNumber: String? = null
|
||||||
|
private var isManageMode: Boolean = false
|
||||||
|
private var isTapMode: Boolean = false
|
||||||
|
private var tapAnimView: NfcTapAnimationView? = null
|
||||||
|
private var autoTapModeTriggered = false
|
||||||
|
|
||||||
|
// Carousel snapshot captured on enter, used to reverse the exit animation
|
||||||
|
private var carouselCardLayoutTop = 0f // card layout top relative to contentLayout
|
||||||
|
private var carouselCardCenterX = 0f // card center X relative to contentLayout
|
||||||
|
private var carouselTextLayoutTop = 0f // tvSelectedCardType layout top relative to contentLayout
|
||||||
|
|
||||||
|
// Swipe-to-dismiss tracking
|
||||||
|
private var swipeDragStartRawY = 0f
|
||||||
|
private var swipeIsDragging = false
|
||||||
|
|
||||||
|
private lateinit var stackAdapter: CardStackAdapter
|
||||||
|
private val store by lazy { CredentialStore(requireContext()) }
|
||||||
|
|
||||||
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
|
||||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
|
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
||||||
raw.startsWith("https://pay.bml.com.mv/app/")) {
|
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(raw, pendingQrAccountNumber)
|
R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, pendingQrAccountNumber)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.transfer_qr_invalid, Toast.LENGTH_SHORT).show()
|
||||||
@@ -64,7 +102,7 @@ class CardsFragment : Fragment() {
|
|||||||
val peekPx = screenW / 8
|
val peekPx = screenW / 8
|
||||||
cardWidth = screenW - 2 * peekPx
|
cardWidth = screenW - 2 * peekPx
|
||||||
|
|
||||||
val stackAdapter = CardStackAdapter(cardWidth)
|
stackAdapter = CardStackAdapter(cardWidth)
|
||||||
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
binding.rvCards.layoutManager = LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false)
|
||||||
binding.rvCards.adapter = stackAdapter
|
binding.rvCards.adapter = stackAdapter
|
||||||
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
|
binding.rvCards.setPadding(peekPx, 0, peekPx, 0)
|
||||||
@@ -101,24 +139,8 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
insets
|
insets
|
||||||
}
|
}
|
||||||
|
|
||||||
val updateCardList = {
|
viewModel.mibCards.observe(viewLifecycleOwner) { rebuildCards() }
|
||||||
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
|
viewModel.accounts.observe(viewLifecycleOwner) { rebuildCards() }
|
||||||
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
|
||||||
.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
|
|
||||||
.map { CardItem.Bml(it) }
|
|
||||||
cards = mibItems + bmlItems
|
|
||||||
stackAdapter.update(cards)
|
|
||||||
binding.loadingView.visibility = View.GONE
|
|
||||||
val empty = cards.isEmpty()
|
|
||||||
binding.emptyView.visibility = if (empty) View.VISIBLE else View.GONE
|
|
||||||
binding.contentLayout.visibility = if (empty) View.GONE else View.VISIBLE
|
|
||||||
if (!empty) {
|
|
||||||
buildDots(cards.size, currentCardPosition)
|
|
||||||
updateCardInfo(currentCardPosition)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
viewModel.mibCards.observe(viewLifecycleOwner) { updateCardList() }
|
|
||||||
viewModel.accounts.observe(viewLifecycleOwner) { updateCardList() }
|
|
||||||
|
|
||||||
val cached = CardsCache.load(requireContext())
|
val cached = CardsCache.load(requireContext())
|
||||||
if (cached.isNotEmpty()) {
|
if (cached.isNotEmpty()) {
|
||||||
@@ -128,6 +150,54 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
}
|
}
|
||||||
(activity as? HomeActivity)?.triggerRefreshCards()
|
(activity as? HomeActivity)?.triggerRefreshCards()
|
||||||
|
|
||||||
|
binding.btnManageCard.setOnClickListener {
|
||||||
|
setManageMode(!isManageMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swipe-down on the manage card to dismiss manage mode
|
||||||
|
binding.manageCardView.root.setOnTouchListener { _, event ->
|
||||||
|
if (!isManageMode && !isTapMode) return@setOnTouchListener false
|
||||||
|
val mgr = binding.manageCardView.root
|
||||||
|
when (event.action) {
|
||||||
|
android.view.MotionEvent.ACTION_DOWN -> {
|
||||||
|
mgr.animate().cancel()
|
||||||
|
binding.tvSelectedCardType.animate().cancel()
|
||||||
|
swipeDragStartRawY = event.rawY
|
||||||
|
swipeIsDragging = false
|
||||||
|
true
|
||||||
|
}
|
||||||
|
android.view.MotionEvent.ACTION_MOVE -> {
|
||||||
|
val dy = (event.rawY - swipeDragStartRawY).coerceAtLeast(0f)
|
||||||
|
if (dy > 12f || swipeIsDragging) {
|
||||||
|
swipeIsDragging = true
|
||||||
|
mgr.translationY = dy
|
||||||
|
binding.tvSelectedCardType.translationY = dy * 0.6f
|
||||||
|
val scale = 1f - (dy / (binding.contentLayout.height * 2.5f)).coerceIn(0f, 0.12f)
|
||||||
|
mgr.scaleX = scale
|
||||||
|
mgr.scaleY = scale
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
}
|
||||||
|
android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> {
|
||||||
|
if (swipeIsDragging) {
|
||||||
|
val dy = (event.rawY - swipeDragStartRawY).coerceAtLeast(0f)
|
||||||
|
swipeIsDragging = false
|
||||||
|
if (dy > 130f) {
|
||||||
|
if (isTapMode) setTapMode(false) else setManageMode(false)
|
||||||
|
} else {
|
||||||
|
// Snap back
|
||||||
|
mgr.animate().translationY(0f).scaleX(1f).scaleY(1f)
|
||||||
|
.setDuration(280).setInterpolator(DecelerateInterpolator()).start()
|
||||||
|
binding.tvSelectedCardType.animate().translationY(0f)
|
||||||
|
.setDuration(280).setInterpolator(DecelerateInterpolator()).start()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else false
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
binding.btnScanToPay.setOnClickListener {
|
binding.btnScanToPay.setOnClickListener {
|
||||||
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
|
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
|
||||||
if (item is CardItem.Mib) {
|
if (item is CardItem.Mib) {
|
||||||
@@ -142,8 +212,17 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
binding.btnTapToPay.isEnabled = nfcAvailable
|
binding.btnTapToPay.isEnabled = nfcAvailable
|
||||||
binding.btnTapToPay.setOnClickListener {
|
binding.btnTapToPay.setOnClickListener {
|
||||||
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
|
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
|
||||||
val msg = if (item is CardItem.Mib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
|
if (item is CardItem.Mib) {
|
||||||
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
|
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
val bmlItem = item as CardItem.Bml
|
||||||
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
|
||||||
|
showBiometricPromptForTap(bmlItem)
|
||||||
|
} else {
|
||||||
|
setTapMode(true, bmlItem)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val wip = View.OnClickListener {
|
val wip = View.OnClickListener {
|
||||||
@@ -154,6 +233,482 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
binding.btnBlock.setOnClickListener(wip)
|
binding.btnBlock.setOnClickListener(wip)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun setManageMode(enabled: Boolean) {
|
||||||
|
isManageMode = enabled
|
||||||
|
requireActivity().title = getString(if (enabled) R.string.card_manage else R.string.nav_pay_with_card)
|
||||||
|
if (enabled) enterManageMode() else exitManageMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enterManageMode() {
|
||||||
|
val item = cards.getOrNull(currentCardPosition) ?: return
|
||||||
|
|
||||||
|
// Bind card data
|
||||||
|
val cv = binding.manageCardView
|
||||||
|
when (item) {
|
||||||
|
is CardItem.Mib -> {
|
||||||
|
cv.tvCardOwner.text = item.card.cardHolderName
|
||||||
|
cv.tvCardNumber.text = formatMasked(item.card.maskedCardNumber)
|
||||||
|
val assetPath = cardImageAsset(item.card)
|
||||||
|
if (assetPath != null) loadCardImage(cv.ivCardImage, assetPath)
|
||||||
|
else cv.ivCardImage.setImageDrawable(null)
|
||||||
|
bindCardStatus(cv.tvCardStatus, mibCardStatusLabel(item.card.cardStatus))
|
||||||
|
cv.root.alpha = 1f
|
||||||
|
}
|
||||||
|
is CardItem.Bml -> {
|
||||||
|
cv.tvCardOwner.text = item.account.accountBriefName
|
||||||
|
cv.tvCardNumber.text = formatMasked(item.account.accountNumber)
|
||||||
|
loadCardImage(cv.ivCardImage, BmlCardParser.cardImageAsset(item.account))
|
||||||
|
val isActive = item.account.statusDesc.equals("Active", ignoreCase = true)
|
||||||
|
bindCardStatus(cv.tvCardStatus, item.account.statusDesc.takeUnless { isActive })
|
||||||
|
cv.root.alpha = if (isActive) 1f else 0.45f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture positions BEFORE layout changes (for enter animation + exit animation later)
|
||||||
|
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
|
||||||
|
val lm = binding.rvCards.layoutManager as? LinearLayoutManager
|
||||||
|
val srcView = lm?.findViewByPosition(currentCardPosition)
|
||||||
|
val srcLoc = IntArray(2).also { srcView?.getLocationOnScreen(it) ?: run { it[0] = contentLoc[0]; it[1] = contentLoc[1] } }
|
||||||
|
val srcScreenTop = (srcLoc[1] - contentLoc[1]).toFloat()
|
||||||
|
val srcCenterX = (srcLoc[0] - contentLoc[0]).toFloat() + cardWidth / 2f
|
||||||
|
|
||||||
|
val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
|
||||||
|
val textSrcScreenTop = (textLoc[1] - contentLoc[1]).toFloat()
|
||||||
|
|
||||||
|
// Apply layout changes
|
||||||
|
binding.btnManageCard.visibility = View.GONE
|
||||||
|
binding.topSpacer.visibility = View.GONE
|
||||||
|
binding.rvCards.visibility = View.GONE
|
||||||
|
binding.pageIndicator.visibility = View.GONE
|
||||||
|
binding.llPayButtons.visibility = View.GONE
|
||||||
|
binding.llManageButtons.visibility = View.VISIBLE
|
||||||
|
binding.llDefaultCardRow.visibility = View.VISIBLE
|
||||||
|
binding.llHideDashboardRow.visibility = View.VISIBLE
|
||||||
|
binding.manageCardView.root.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// Set switch state (clear listener first to avoid triggering on programmatic set)
|
||||||
|
val isBml = item is CardItem.Bml
|
||||||
|
binding.switchDefaultCard.setOnCheckedChangeListener(null)
|
||||||
|
binding.switchDefaultCard.isChecked = isBml && store.getDefaultCardAccountNumber() == (item as? CardItem.Bml)?.account?.accountNumber
|
||||||
|
binding.switchDefaultCard.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
if (item is CardItem.Mib) {
|
||||||
|
// MIB doesn't support NFC/QR pay — same toast as scan/tap to pay
|
||||||
|
binding.switchDefaultCard.setOnCheckedChangeListener(null)
|
||||||
|
binding.switchDefaultCard.isChecked = false
|
||||||
|
binding.switchDefaultCard.setOnCheckedChangeListener { _, c ->
|
||||||
|
handleDefaultCardToggle(c)
|
||||||
|
}
|
||||||
|
Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
|
||||||
|
} else {
|
||||||
|
handleDefaultCardToggle(isChecked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val accountNumber = (item as? CardItem.Bml)?.account?.accountNumber
|
||||||
|
?: (item as? CardItem.Mib)?.card?.maskedCardNumber
|
||||||
|
binding.switchHideFromDashboard.setOnCheckedChangeListener(null)
|
||||||
|
binding.switchHideFromDashboard.isChecked = accountNumber != null &&
|
||||||
|
store.getHiddenDashboardCardNumbers().contains(accountNumber)
|
||||||
|
binding.switchHideFromDashboard.setOnCheckedChangeListener { _, isChecked ->
|
||||||
|
if (accountNumber != null) {
|
||||||
|
store.setCardHiddenFromDashboard(accountNumber, isChecked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After layout pass, compute offsets, save carousel snapshot, and animate
|
||||||
|
binding.contentLayout.doOnNextLayout {
|
||||||
|
val mgr = binding.manageCardView.root
|
||||||
|
val dstLoc = IntArray(2).also { mgr.getLocationOnScreen(it) }
|
||||||
|
val dstTop = (dstLoc[1] - contentLoc[1]).toFloat()
|
||||||
|
val dstCenterX = (dstLoc[0] - contentLoc[0]).toFloat() + mgr.width / 2f
|
||||||
|
|
||||||
|
val scaleStart = if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f
|
||||||
|
val transXStart = srcCenterX - dstCenterX
|
||||||
|
val transYStart = srcScreenTop - dstTop
|
||||||
|
|
||||||
|
// Save the carousel card's position (relative to contentLayout) for the exit animation
|
||||||
|
carouselCardLayoutTop = srcScreenTop
|
||||||
|
carouselCardCenterX = srcCenterX
|
||||||
|
carouselTextLayoutTop = textSrcScreenTop
|
||||||
|
|
||||||
|
val textDstLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
|
||||||
|
val textDstTop = (textDstLoc[1] - contentLoc[1]).toFloat()
|
||||||
|
|
||||||
|
mgr.pivotX = mgr.width / 2f
|
||||||
|
mgr.pivotY = 0f
|
||||||
|
mgr.scaleX = scaleStart
|
||||||
|
mgr.scaleY = scaleStart
|
||||||
|
mgr.translationX = transXStart
|
||||||
|
mgr.translationY = transYStart
|
||||||
|
|
||||||
|
mgr.animate()
|
||||||
|
.scaleX(1f).scaleY(1f)
|
||||||
|
.translationX(0f).translationY(0f)
|
||||||
|
.setDuration(380)
|
||||||
|
.setInterpolator(DecelerateInterpolator())
|
||||||
|
.start()
|
||||||
|
|
||||||
|
binding.tvSelectedCardType.translationY = textSrcScreenTop - textDstTop
|
||||||
|
binding.tvSelectedCardType.animate()
|
||||||
|
.translationY(0f)
|
||||||
|
.setDuration(380)
|
||||||
|
.setInterpolator(DecelerateInterpolator())
|
||||||
|
.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleDefaultCardToggle(isChecked: Boolean) {
|
||||||
|
val item = cards.getOrNull(currentCardPosition) as? CardItem.Bml ?: return
|
||||||
|
store.setDefaultCardAccountNumber(if (isChecked) item.account.accountNumber else null)
|
||||||
|
rebuildCards()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exitManageMode() {
|
||||||
|
binding.manageCardView.root.animate().cancel()
|
||||||
|
binding.tvSelectedCardType.animate().cancel()
|
||||||
|
|
||||||
|
val mgr = binding.manageCardView.root
|
||||||
|
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
|
||||||
|
|
||||||
|
// Compute layout top of manage card (strip current translationY which may be from a swipe drag)
|
||||||
|
val mgrLoc = IntArray(2).also { mgr.getLocationOnScreen(it) }
|
||||||
|
val mgrLayoutTop = (mgrLoc[1] - contentLoc[1]).toFloat() - mgr.translationY
|
||||||
|
|
||||||
|
val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
|
||||||
|
val textLayoutTop = (textLoc[1] - contentLoc[1]).toFloat() - binding.tvSelectedCardType.translationY
|
||||||
|
|
||||||
|
// Target: animate card back to carousel position
|
||||||
|
val scaleEnd = if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f
|
||||||
|
val mgrLayoutCenterX = (mgrLoc[0] - contentLoc[0]).toFloat() - mgr.translationX + mgr.width / 2f
|
||||||
|
val targetTransX = carouselCardCenterX - mgrLayoutCenterX
|
||||||
|
val targetTransY = carouselCardLayoutTop - mgrLayoutTop
|
||||||
|
|
||||||
|
val targetTextTransY = carouselTextLayoutTop - textLayoutTop
|
||||||
|
|
||||||
|
mgr.pivotX = mgr.width / 2f
|
||||||
|
mgr.pivotY = 0f
|
||||||
|
|
||||||
|
mgr.animate()
|
||||||
|
.scaleX(scaleEnd).scaleY(scaleEnd)
|
||||||
|
.translationX(targetTransX)
|
||||||
|
.translationY(targetTransY)
|
||||||
|
.setDuration(320)
|
||||||
|
.setInterpolator(AccelerateInterpolator())
|
||||||
|
.withEndAction {
|
||||||
|
mgr.scaleX = 1f; mgr.scaleY = 1f
|
||||||
|
mgr.translationX = 0f; mgr.translationY = 0f
|
||||||
|
mgr.visibility = View.GONE
|
||||||
|
binding.tvSelectedCardType.translationY = 0f
|
||||||
|
|
||||||
|
binding.btnManageCard.visibility = View.VISIBLE
|
||||||
|
binding.topSpacer.visibility = View.VISIBLE
|
||||||
|
binding.rvCards.visibility = View.VISIBLE
|
||||||
|
binding.llPayButtons.visibility = View.VISIBLE
|
||||||
|
binding.llManageButtons.visibility = View.GONE
|
||||||
|
binding.llDefaultCardRow.visibility = View.GONE
|
||||||
|
binding.llHideDashboardRow.visibility = View.GONE
|
||||||
|
binding.switchDefaultCard.setOnCheckedChangeListener(null)
|
||||||
|
binding.switchHideFromDashboard.setOnCheckedChangeListener(null)
|
||||||
|
buildDots(cards.size, currentCardPosition)
|
||||||
|
}
|
||||||
|
.start()
|
||||||
|
|
||||||
|
binding.tvSelectedCardType.animate()
|
||||||
|
.translationY(targetTextTransY)
|
||||||
|
.setDuration(320)
|
||||||
|
.setInterpolator(AccelerateInterpolator())
|
||||||
|
.withEndAction { binding.tvSelectedCardType.translationY = 0f }
|
||||||
|
.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tap-to-pay mode ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun setTapMode(enabled: Boolean, item: CardItem.Bml? = null) {
|
||||||
|
isTapMode = enabled
|
||||||
|
requireActivity().title = getString(if (enabled) R.string.card_pay_nfc else R.string.nav_pay_with_card)
|
||||||
|
if (enabled) enterTapMode(item!!) else exitTapMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showBiometricPromptForTap(item: CardItem.Bml) {
|
||||||
|
val bmgr = BiometricManager.from(requireContext())
|
||||||
|
if (bmgr.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) != BiometricManager.BIOMETRIC_SUCCESS) {
|
||||||
|
setTapMode(true, item)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()),
|
||||||
|
object : BiometricPrompt.AuthenticationCallback() {
|
||||||
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||||
|
setTapMode(true, item)
|
||||||
|
}
|
||||||
|
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { }
|
||||||
|
})
|
||||||
|
prompt.authenticate(
|
||||||
|
BiometricPrompt.PromptInfo.Builder()
|
||||||
|
.setTitle(getString(R.string.card_pay_nfc))
|
||||||
|
.setSubtitle(item.account.accountBriefName)
|
||||||
|
.setNegativeButtonText(getString(R.string.cancel))
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enterTapMode(item: CardItem.Bml) {
|
||||||
|
// Bind card data to the shared manage card view
|
||||||
|
val cv = binding.manageCardView
|
||||||
|
cv.tvCardOwner.text = item.account.accountBriefName
|
||||||
|
cv.tvCardNumber.text = formatMasked(item.account.accountNumber)
|
||||||
|
loadCardImage(cv.ivCardImage, BmlCardParser.cardImageAsset(item.account))
|
||||||
|
val isActive = item.account.statusDesc.equals("Active", ignoreCase = true)
|
||||||
|
bindCardStatus(cv.tvCardStatus, item.account.statusDesc.takeUnless { isActive })
|
||||||
|
cv.root.alpha = if (isActive) 1f else 0.45f
|
||||||
|
|
||||||
|
// Snapshot carousel card position before layout changes (for animation)
|
||||||
|
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
|
||||||
|
val lm = binding.rvCards.layoutManager as? LinearLayoutManager
|
||||||
|
val srcView = lm?.findViewByPosition(currentCardPosition)
|
||||||
|
val srcLoc = IntArray(2).also {
|
||||||
|
srcView?.getLocationOnScreen(it) ?: run { it[0] = contentLoc[0]; it[1] = contentLoc[1] }
|
||||||
|
}
|
||||||
|
val srcScreenTop = (srcLoc[1] - contentLoc[1]).toFloat()
|
||||||
|
val srcCenterX = (srcLoc[0] - contentLoc[0]).toFloat() + cardWidth / 2f
|
||||||
|
val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
|
||||||
|
val textSrcScreenTop = (textLoc[1] - contentLoc[1]).toFloat()
|
||||||
|
|
||||||
|
carouselCardLayoutTop = srcScreenTop
|
||||||
|
carouselCardCenterX = srcCenterX
|
||||||
|
carouselTextLayoutTop = textSrcScreenTop
|
||||||
|
|
||||||
|
// Apply layout changes
|
||||||
|
binding.btnManageCard.visibility = View.GONE
|
||||||
|
binding.topSpacer.visibility = View.GONE
|
||||||
|
binding.rvCards.visibility = View.GONE
|
||||||
|
binding.pageIndicator.visibility = View.GONE
|
||||||
|
binding.divider.visibility = View.GONE
|
||||||
|
binding.llPayButtons.visibility = View.GONE
|
||||||
|
binding.llManageButtons.visibility = View.GONE
|
||||||
|
binding.llDefaultCardRow.visibility = View.GONE
|
||||||
|
binding.manageCardView.root.visibility = View.VISIBLE
|
||||||
|
binding.flTapMode.visibility = View.VISIBLE
|
||||||
|
|
||||||
|
// Build tap mode content: animation view + cancel button
|
||||||
|
binding.flTapMode.removeAllViews()
|
||||||
|
val animView = NfcTapAnimationView(requireContext())
|
||||||
|
tapAnimView = animView
|
||||||
|
|
||||||
|
val dp = resources.displayMetrics.density
|
||||||
|
val cancelBtn = MaterialButton(requireContext(), null,
|
||||||
|
com.google.android.material.R.attr.materialButtonOutlinedStyle
|
||||||
|
).apply { setText(R.string.cancel); setOnClickListener { setTapMode(false) } }
|
||||||
|
|
||||||
|
val cancelWrapper = LinearLayout(requireContext()).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
gravity = Gravity.CENTER_HORIZONTAL
|
||||||
|
setPadding(0, 0, 0, (24 * dp).toInt())
|
||||||
|
addView(cancelBtn)
|
||||||
|
}
|
||||||
|
val container = LinearLayout(requireContext()).apply {
|
||||||
|
orientation = LinearLayout.VERTICAL
|
||||||
|
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||||
|
addView(View(requireContext()).apply { // spacer pushes content below card
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)
|
||||||
|
})
|
||||||
|
addView(animView.apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 3f)
|
||||||
|
})
|
||||||
|
addView(cancelWrapper.apply {
|
||||||
|
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
binding.flTapMode.addView(container)
|
||||||
|
|
||||||
|
// Animate card up from carousel position (same as manage mode)
|
||||||
|
binding.contentLayout.doOnNextLayout {
|
||||||
|
val mgr = binding.manageCardView.root
|
||||||
|
val dstLoc = IntArray(2).also { mgr.getLocationOnScreen(it) }
|
||||||
|
val dstTop = (dstLoc[1] - contentLoc[1]).toFloat()
|
||||||
|
val dstCenterX = (dstLoc[0] - contentLoc[0]).toFloat() + mgr.width / 2f
|
||||||
|
|
||||||
|
mgr.pivotX = mgr.width / 2f
|
||||||
|
mgr.pivotY = 0f
|
||||||
|
mgr.scaleX = if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f
|
||||||
|
mgr.scaleY = mgr.scaleX
|
||||||
|
mgr.translationX = srcCenterX - dstCenterX
|
||||||
|
mgr.translationY = srcScreenTop - dstTop
|
||||||
|
|
||||||
|
mgr.animate()
|
||||||
|
.scaleX(1f).scaleY(1f)
|
||||||
|
.translationX(0f).translationY(0f)
|
||||||
|
.setDuration(380).setInterpolator(DecelerateInterpolator()).start()
|
||||||
|
|
||||||
|
val textDstLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
|
||||||
|
binding.tvSelectedCardType.translationY = textSrcScreenTop - (textDstLoc[1] - contentLoc[1]).toFloat()
|
||||||
|
binding.tvSelectedCardType.animate()
|
||||||
|
.translationY(0f)
|
||||||
|
.setDuration(380).setInterpolator(DecelerateInterpolator()).start()
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchAndArmToken(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exitTapMode() {
|
||||||
|
tapAnimView?.stopAnimation()
|
||||||
|
tapAnimView = null
|
||||||
|
BmlHostCardEmulatorService.clearToken()
|
||||||
|
BmlHostCardEmulatorService.onTransactionComplete = null
|
||||||
|
|
||||||
|
binding.manageCardView.root.animate().cancel()
|
||||||
|
binding.tvSelectedCardType.animate().cancel()
|
||||||
|
|
||||||
|
val mgr = binding.manageCardView.root
|
||||||
|
val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
|
||||||
|
val mgrLoc = IntArray(2).also { mgr.getLocationOnScreen(it) }
|
||||||
|
val mgrLayoutTop = (mgrLoc[1] - contentLoc[1]).toFloat() - mgr.translationY
|
||||||
|
val mgrLayoutCenterX = (mgrLoc[0] - contentLoc[0]).toFloat() - mgr.translationX + mgr.width / 2f
|
||||||
|
|
||||||
|
val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
|
||||||
|
val textLayoutTop = (textLoc[1] - contentLoc[1]).toFloat() - binding.tvSelectedCardType.translationY
|
||||||
|
|
||||||
|
mgr.pivotX = mgr.width / 2f
|
||||||
|
mgr.pivotY = 0f
|
||||||
|
|
||||||
|
mgr.animate()
|
||||||
|
.scaleX(if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f)
|
||||||
|
.scaleY(if (mgr.width > 0) cardWidth.toFloat() / mgr.width.toFloat() else 1f)
|
||||||
|
.translationX(carouselCardCenterX - mgrLayoutCenterX)
|
||||||
|
.translationY(carouselCardLayoutTop - mgrLayoutTop)
|
||||||
|
.setDuration(320)
|
||||||
|
.setInterpolator(AccelerateInterpolator())
|
||||||
|
.withEndAction {
|
||||||
|
mgr.scaleX = 1f; mgr.scaleY = 1f
|
||||||
|
mgr.translationX = 0f; mgr.translationY = 0f
|
||||||
|
mgr.visibility = View.GONE
|
||||||
|
binding.tvSelectedCardType.translationY = 0f
|
||||||
|
binding.flTapMode.visibility = View.GONE
|
||||||
|
binding.flTapMode.removeAllViews()
|
||||||
|
binding.btnManageCard.visibility = View.VISIBLE
|
||||||
|
binding.topSpacer.visibility = View.VISIBLE
|
||||||
|
binding.rvCards.visibility = View.VISIBLE
|
||||||
|
binding.divider.visibility = View.VISIBLE
|
||||||
|
binding.llPayButtons.visibility = View.VISIBLE
|
||||||
|
buildDots(cards.size, currentCardPosition)
|
||||||
|
}
|
||||||
|
.start()
|
||||||
|
|
||||||
|
binding.tvSelectedCardType.animate()
|
||||||
|
.translationY(carouselTextLayoutTop - textLayoutTop)
|
||||||
|
.setDuration(320)
|
||||||
|
.setInterpolator(AccelerateInterpolator())
|
||||||
|
.withEndAction { binding.tvSelectedCardType.translationY = 0f }
|
||||||
|
.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchAndArmToken(item: CardItem.Bml) {
|
||||||
|
val app = requireActivity().application as BasedBankApp
|
||||||
|
|
||||||
|
viewLifecycleOwner.lifecycleScope.launch {
|
||||||
|
val loginId = item.account.loginTag.removePrefix("bml_")
|
||||||
|
val session = app.bmlSessionFor(item.account)
|
||||||
|
val otpSeed = CredentialStore(requireContext()).loadBmlCredentials(loginId)?.otpSeed
|
||||||
|
|
||||||
|
if (session == null || otpSeed == null) {
|
||||||
|
if (isTapMode) {
|
||||||
|
Toast.makeText(requireContext(),
|
||||||
|
if (session == null) getString(R.string.transfer_session_unavailable)
|
||||||
|
else "OTP unavailable",
|
||||||
|
Toast.LENGTH_SHORT).show()
|
||||||
|
setTapMode(false)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val otp = Totp.generate(otpSeed)
|
||||||
|
val result = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { BmlTapToPayClient().fetchTokens(session, item.account.internalId, otp) }
|
||||||
|
}
|
||||||
|
val token = result.getOrNull()?.firstOrNull()
|
||||||
|
|
||||||
|
if (!isTapMode) return@launch // user cancelled while we were fetching
|
||||||
|
|
||||||
|
if (token == null) {
|
||||||
|
if (isTapMode) {
|
||||||
|
Toast.makeText(requireContext(),
|
||||||
|
result.exceptionOrNull()?.message ?: "Failed to get payment token",
|
||||||
|
Toast.LENGTH_SHORT).show()
|
||||||
|
setTapMode(false)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
BmlHostCardEmulatorService.setToken(token)
|
||||||
|
BmlHostCardEmulatorService.onTransactionComplete = { success ->
|
||||||
|
view?.post {
|
||||||
|
if (!isTapMode) return@post
|
||||||
|
setTapMode(false)
|
||||||
|
if (success) Toast.makeText(requireContext(), "Payment complete", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun rebuildCards() {
|
||||||
|
// Remember which card is currently selected by identity so we can restore position after reorder
|
||||||
|
val currentCard = cards.getOrNull(currentCardPosition)
|
||||||
|
|
||||||
|
val defaultNum = store.getDefaultCardAccountNumber()
|
||||||
|
val mibItems = (viewModel.mibCards.value ?: emptyList()).map { CardItem.Mib(it) }
|
||||||
|
val bmlItems = (viewModel.accounts.value ?: emptyList())
|
||||||
|
.filter { it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT" }
|
||||||
|
.map { CardItem.Bml(it) }
|
||||||
|
val all: List<CardItem> = mibItems + bmlItems
|
||||||
|
// Move default BML card to front
|
||||||
|
cards = if (defaultNum != null) {
|
||||||
|
val def = all.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == defaultNum }
|
||||||
|
if (def != null) listOf(def) + all.filter { it !== def } else all
|
||||||
|
} else all
|
||||||
|
|
||||||
|
// Restore position to follow the same card after reorder
|
||||||
|
if (currentCard != null) {
|
||||||
|
val newPos = cards.indexOf(currentCard)
|
||||||
|
if (newPos >= 0 && newPos != currentCardPosition) {
|
||||||
|
currentCardPosition = newPos
|
||||||
|
binding.rvCards.scrollToPosition(newPos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stackAdapter.update(cards)
|
||||||
|
binding.loadingView.visibility = View.GONE
|
||||||
|
val empty = cards.isEmpty()
|
||||||
|
binding.emptyView.visibility = if (empty) View.VISIBLE else View.GONE
|
||||||
|
binding.contentLayout.visibility = if (empty) View.GONE else View.VISIBLE
|
||||||
|
if (!empty) {
|
||||||
|
buildDots(cards.size, currentCardPosition)
|
||||||
|
updateCardInfo(currentCardPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-enter tap mode when launched from shortcut, NFC prompt, or dashboard
|
||||||
|
if (!autoTapModeTriggered && arguments?.getBoolean(ARG_AUTO_TAP_MODE) == true) {
|
||||||
|
val targetAccount = arguments?.getString(ARG_AUTO_TAP_ACCOUNT)
|
||||||
|
val targetCard = if (targetAccount != null)
|
||||||
|
cards.filterIsInstance<CardItem.Bml>().firstOrNull { it.account.accountNumber == targetAccount }
|
||||||
|
else
|
||||||
|
cards.filterIsInstance<CardItem.Bml>().firstOrNull()
|
||||||
|
if (targetCard != null) {
|
||||||
|
autoTapModeTriggered = true
|
||||||
|
// Scroll to the target card first
|
||||||
|
val pos = cards.indexOf(targetCard)
|
||||||
|
if (pos >= 0) {
|
||||||
|
currentCardPosition = pos
|
||||||
|
binding.rvCards.scrollToPosition(pos)
|
||||||
|
}
|
||||||
|
val prefs = requireContext().getSharedPreferences("prefs", Context.MODE_PRIVATE)
|
||||||
|
if (prefs.getBoolean("biometrics_transfer_confirm", false)) {
|
||||||
|
showBiometricPromptForTap(targetCard)
|
||||||
|
} else {
|
||||||
|
setTapMode(true, targetCard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun applyCardScales() {
|
private fun applyCardScales() {
|
||||||
val rv = binding.rvCards
|
val rv = binding.rvCards
|
||||||
val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f
|
val rvCenter = rv.paddingStart + (rv.width - rv.paddingStart - rv.paddingEnd) / 2f
|
||||||
@@ -173,6 +728,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun buildDots(count: Int, selected: Int) {
|
private fun buildDots(count: Int, selected: Int) {
|
||||||
|
if (isManageMode || isTapMode) return
|
||||||
binding.pageIndicator.removeAllViews()
|
binding.pageIndicator.removeAllViews()
|
||||||
if (count <= 1) {
|
if (count <= 1) {
|
||||||
binding.pageIndicator.visibility = View.GONE
|
binding.pageIndicator.visibility = View.GONE
|
||||||
@@ -207,12 +763,36 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun onBackPressed(): Boolean {
|
||||||
|
if (isTapMode) {
|
||||||
|
setTapMode(false)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (isManageMode) {
|
||||||
|
setManageMode(false)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
super.onPause()
|
||||||
|
if (isTapMode) {
|
||||||
|
BmlHostCardEmulatorService.clearToken()
|
||||||
|
BmlHostCardEmulatorService.onTransactionComplete = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
requireActivity().title = getString(R.string.nav_pay_with_card)
|
requireActivity().title = getString(R.string.nav_pay_with_card)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
|
tapAnimView?.stopAnimation()
|
||||||
|
tapAnimView = null
|
||||||
|
BmlHostCardEmulatorService.clearToken()
|
||||||
|
BmlHostCardEmulatorService.onTransactionComplete = null
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
_binding = null
|
_binding = null
|
||||||
}
|
}
|
||||||
@@ -274,7 +854,114 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── NFC animation view ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private inner class NfcTapAnimationView(context: Context) : View(context) {
|
||||||
|
|
||||||
|
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val animator = ValueAnimator.ofFloat(0f, 1f).apply {
|
||||||
|
duration = 1600
|
||||||
|
repeatCount = ValueAnimator.INFINITE
|
||||||
|
repeatMode = ValueAnimator.RESTART
|
||||||
|
addUpdateListener { invalidate() }
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopAnimation() = animator.cancel()
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
val w = width.toFloat(); val h = height.toFloat()
|
||||||
|
if (w <= 0f || h <= 0f) return
|
||||||
|
|
||||||
|
val dp = resources.displayMetrics.density
|
||||||
|
val progress = animator.animatedFraction
|
||||||
|
val cx = w / 2f; val cy = h / 2f - 20 * dp
|
||||||
|
|
||||||
|
val colorOnSurface = MaterialColors.getColor(this,
|
||||||
|
com.google.android.material.R.attr.colorOnSurface, android.graphics.Color.BLACK)
|
||||||
|
val colorPrimary = MaterialColors.getColor(this,
|
||||||
|
com.google.android.material.R.attr.colorPrimary, android.graphics.Color.BLUE)
|
||||||
|
val colorSurfaceVariant = MaterialColors.getColor(this,
|
||||||
|
com.google.android.material.R.attr.colorSurfaceVariant, android.graphics.Color.LTGRAY)
|
||||||
|
|
||||||
|
// Phone (left of center)
|
||||||
|
val phoneW = 36 * dp; val phoneH = 62 * dp
|
||||||
|
val phoneX = cx - 72 * dp - phoneW; val phoneY = cy - phoneH / 2f
|
||||||
|
|
||||||
|
// POS terminal (right of center)
|
||||||
|
val posW = 30 * dp; val posH = 50 * dp
|
||||||
|
val posX = cx + 72 * dp; val posY = cy - posH / 2f
|
||||||
|
|
||||||
|
// Phone body
|
||||||
|
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
|
||||||
|
canvas.drawRoundRect(phoneX, phoneY, phoneX + phoneW, phoneY + phoneH, 6 * dp, 6 * dp, paint)
|
||||||
|
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2 * dp; paint.color = colorOnSurface
|
||||||
|
canvas.drawRoundRect(phoneX, phoneY, phoneX + phoneW, phoneY + phoneH, 6 * dp, 6 * dp, paint)
|
||||||
|
|
||||||
|
// Phone screen
|
||||||
|
paint.style = Paint.Style.FILL; paint.color = colorPrimary; paint.alpha = 70
|
||||||
|
canvas.drawRoundRect(phoneX + 3 * dp, phoneY + 8 * dp,
|
||||||
|
phoneX + phoneW - 3 * dp, phoneY + phoneH - 12 * dp, 3 * dp, 3 * dp, paint)
|
||||||
|
paint.alpha = 255
|
||||||
|
|
||||||
|
// Static NFC arcs on the right side of phone
|
||||||
|
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2 * dp; paint.color = colorPrimary
|
||||||
|
val arcOriginX = phoneX + phoneW
|
||||||
|
for (i in 1..3) {
|
||||||
|
val r = i * 10 * dp
|
||||||
|
paint.alpha = 220 - i * 50
|
||||||
|
canvas.drawArc(RectF(arcOriginX - r, cy - r, arcOriginX + r, cy + r),
|
||||||
|
-70f, 140f, false, paint)
|
||||||
|
}
|
||||||
|
paint.alpha = 255
|
||||||
|
|
||||||
|
// POS terminal body
|
||||||
|
paint.style = Paint.Style.FILL; paint.color = colorSurfaceVariant
|
||||||
|
canvas.drawRoundRect(posX, posY, posX + posW, posY + posH, 5 * dp, 5 * dp, paint)
|
||||||
|
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2 * dp; paint.color = colorOnSurface
|
||||||
|
canvas.drawRoundRect(posX, posY, posX + posW, posY + posH, 5 * dp, 5 * dp, paint)
|
||||||
|
|
||||||
|
// POS screen
|
||||||
|
paint.style = Paint.Style.FILL; paint.color = colorPrimary; paint.alpha = 70
|
||||||
|
canvas.drawRoundRect(posX + 3 * dp, posY + 4 * dp,
|
||||||
|
posX + posW - 3 * dp, posY + posH * 0.45f, 3 * dp, 3 * dp, paint)
|
||||||
|
paint.alpha = 255
|
||||||
|
|
||||||
|
// POS card slot
|
||||||
|
paint.style = Paint.Style.STROKE; paint.strokeWidth = 1.5f * dp; paint.color = colorOnSurface
|
||||||
|
canvas.drawLine(posX + 4 * dp, posY + posH * 0.72f, posX + posW - 4 * dp, posY + posH * 0.72f, paint)
|
||||||
|
|
||||||
|
// Animated NFC rings travelling from phone toward POS
|
||||||
|
val gapStart = arcOriginX + 28 * dp
|
||||||
|
val gapEnd = posX - 4 * dp
|
||||||
|
val midX = (gapStart + gapEnd) / 2f
|
||||||
|
paint.style = Paint.Style.STROKE; paint.strokeWidth = 2.5f * dp
|
||||||
|
for (i in 0..2) {
|
||||||
|
val p = ((progress + i / 3f) % 1f)
|
||||||
|
val r = p * (gapEnd - gapStart) / 2f + 6 * dp
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
paint.alpha = 255
|
||||||
|
|
||||||
|
// Label
|
||||||
|
paint.style = Paint.Style.FILL; paint.color = colorOnSurface; paint.alpha = 160
|
||||||
|
paint.textSize = 14 * dp; paint.textAlign = Paint.Align.CENTER
|
||||||
|
canvas.drawText(context.getString(R.string.card_pay_nfc), cx, cy + 60 * dp, paint)
|
||||||
|
paint.alpha = 255; paint.textAlign = Paint.Align.LEFT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val ARG_AUTO_TAP_MODE = "auto_tap_mode"
|
||||||
|
private const val ARG_AUTO_TAP_ACCOUNT = "auto_tap_account"
|
||||||
|
fun newInstanceWithAutoTapMode(accountNumber: String? = null) = CardsFragment().apply {
|
||||||
|
arguments = Bundle().apply {
|
||||||
|
putBoolean(ARG_AUTO_TAP_MODE, true)
|
||||||
|
if (accountNumber != null) putString(ARG_AUTO_TAP_ACCOUNT, accountNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun cardImageAsset(card: MibCard): String? = when (card.cardType) {
|
fun cardImageAsset(card: MibCard): String? = when (card.cardType) {
|
||||||
"51" -> "cards/mib/faisa_card.png"
|
"51" -> "cards/mib/faisa_card.png"
|
||||||
"53" -> "cards/mib/visa_black_platinum.png"
|
"53" -> "cards/mib/visa_black_platinum.png"
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package sh.sar.basedbank.ui.home
|
package sh.sar.basedbank.ui.home
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.provider.Settings as AndroidSettings
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -15,6 +18,7 @@ import android.widget.ImageView
|
|||||||
import android.widget.LinearLayout
|
import android.widget.LinearLayout
|
||||||
import android.widget.TextView
|
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.FileProvider
|
import androidx.core.content.FileProvider
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.fragment.app.activityViewModels
|
import androidx.fragment.app.activityViewModels
|
||||||
@@ -77,6 +81,29 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
handlePickedImage(bitmap)
|
handlePickedImage(bitmap)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val cameraPermissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) { granted ->
|
||||||
|
if (granted) cameraLauncher.launch(cameraPhotoUri ?: return@registerForActivityResult)
|
||||||
|
else showCameraPermissionRationale()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showCameraPermissionRationale() {
|
||||||
|
val ctx = requireContext()
|
||||||
|
MaterialAlertDialogBuilder(ctx)
|
||||||
|
.setTitle(R.string.qr_camera_permission_title)
|
||||||
|
.setMessage(R.string.camera_permission_profile_message)
|
||||||
|
.setNegativeButton(R.string.cancel) { dialog, _ -> dialog.dismiss() }
|
||||||
|
.setPositiveButton(R.string.go_to_settings) { _, _ ->
|
||||||
|
startActivity(
|
||||||
|
Intent(AndroidSettings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
|
||||||
|
data = Uri.fromParts("package", ctx.packageName, null)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.show()
|
||||||
|
}
|
||||||
|
|
||||||
private fun loadAndScaleBitmap(uri: Uri): Bitmap? {
|
private fun loadAndScaleBitmap(uri: Uri): Bitmap? {
|
||||||
return try {
|
return try {
|
||||||
val ctx = requireContext()
|
val ctx = requireContext()
|
||||||
@@ -159,7 +186,11 @@ class SettingsLoginsFragment : Fragment() {
|
|||||||
val photoFile = File(ctx.cacheDir, "profile_photo_tmp.jpg")
|
val photoFile = File(ctx.cacheDir, "profile_photo_tmp.jpg")
|
||||||
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", photoFile)
|
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", photoFile)
|
||||||
cameraPhotoUri = uri
|
cameraPhotoUri = uri
|
||||||
cameraLauncher.launch(uri)
|
if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
cameraLauncher.launch(uri)
|
||||||
|
} else {
|
||||||
|
cameraPermissionLauncher.launch(Manifest.permission.CAMERA)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (target is PendingImageTarget.Mib || currentBitmap != null || hasSavedImage(ctx, target)) {
|
if (target is PendingImageTarget.Mib || currentBitmap != null || hasSavedImage(ctx, target)) {
|
||||||
items += Triple(R.drawable.ic_delete, getString(R.string.profile_image_remove)) {
|
items += Triple(R.drawable.ic_delete, getString(R.string.profile_image_remove)) {
|
||||||
|
|||||||
@@ -125,9 +125,12 @@ class TransferFragment : Fragment() {
|
|||||||
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
val raw = result.data?.getStringExtra(QrScannerActivity.EXTRA_QR_CONTENT) ?: return@registerForActivityResult
|
||||||
|
|
||||||
// BML card/gateway QR — hand off to dedicated payment screen
|
// BML card/gateway QR — hand off to dedicated payment screen
|
||||||
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") ||
|
val bmlUrl = PaymvQrParser.extractBmlGatewayUrl(raw)
|
||||||
raw.startsWith("https://pay.bml.com.mv/app/")) {
|
if (raw.startsWith("https://ebanking.bankofmaldives.com.mv/qrpay/") || bmlUrl != null) {
|
||||||
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(raw))
|
val fromCard = selectedAccount?.takeIf {
|
||||||
|
it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT"
|
||||||
|
}
|
||||||
|
(requireActivity() as HomeActivity).navigateTo(R.id.nav_transfer, TransferFragment.newInstanceFromBmlQr(bmlUrl ?: raw, fromCard?.accountNumber))
|
||||||
return@registerForActivityResult
|
return@registerForActivityResult
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +293,25 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
bmlQrInfo = info
|
bmlQrInfo = info
|
||||||
|
|
||||||
|
// Auto-select the user's default BML card if no card was pre-selected
|
||||||
|
if (selectedAccount == null) {
|
||||||
|
val defaultNum = CredentialStore(requireContext()).getDefaultCardAccountNumber()
|
||||||
|
if (defaultNum != null) {
|
||||||
|
val allAccounts = viewModel.accounts.value ?: emptyList()
|
||||||
|
val defaultCard = allAccounts.firstOrNull {
|
||||||
|
it.accountNumber == defaultNum &&
|
||||||
|
(it.profileType == "BML_PREPAID" || it.profileType == "BML_CREDIT" || it.profileType == "BML_DEBIT") &&
|
||||||
|
it.statusDesc.equals("Active", ignoreCase = true)
|
||||||
|
}
|
||||||
|
if (defaultCard != null) {
|
||||||
|
selectedAccount = defaultCard
|
||||||
|
updateAmountPrefix(defaultCard)
|
||||||
|
showFromCard(defaultCard)
|
||||||
|
updateTransferButton()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show merchant in the "To" card — clear button hidden (can't change recipient for QR)
|
// Show merchant in the "To" card — clear button hidden (can't change recipient for QR)
|
||||||
binding.tvToAccountName.text = info.merchantName
|
binding.tvToAccountName.text = info.merchantName
|
||||||
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
|
binding.tvToBankBic.text = info.merchantAddress.ifBlank { "BML Merchant" }
|
||||||
@@ -297,7 +319,6 @@ class TransferFragment : Fragment() {
|
|||||||
binding.tvToBalance.visibility = View.GONE
|
binding.tvToBalance.visibility = View.GONE
|
||||||
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
binding.ivToPhoto.scaleType = android.widget.ImageView.ScaleType.CENTER_CROP
|
||||||
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(info.merchantName, "#0066A1"))
|
binding.ivToPhoto.setImageBitmap(makeInitialsBitmap(info.merchantName, "#0066A1"))
|
||||||
binding.btnClearToInfo.visibility = View.GONE
|
|
||||||
binding.cardToInfo.visibility = View.VISIBLE
|
binding.cardToInfo.visibility = View.VISIBLE
|
||||||
|
|
||||||
// Pre-fill amount if dynamic QR
|
// Pre-fill amount if dynamic QR
|
||||||
@@ -353,6 +374,14 @@ class TransferFragment : Fragment() {
|
|||||||
|
|
||||||
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
|
binding.actvFrom.setOnItemClickListener { _, _, position, _ ->
|
||||||
val picked = accountDropdownAdapter?.getAccount(position) ?: return@setOnItemClickListener
|
val picked = accountDropdownAdapter?.getAccount(position) ?: return@setOnItemClickListener
|
||||||
|
if (bmlQrInfo != null) {
|
||||||
|
val isCard = picked.profileType == "BML_PREPAID" || picked.profileType == "BML_CREDIT" || picked.profileType == "BML_DEBIT"
|
||||||
|
if (!isCard) {
|
||||||
|
Toast.makeText(requireContext(), "Unsupported for BML QR — select a card", Toast.LENGTH_SHORT).show()
|
||||||
|
binding.actvFrom.setText("", false)
|
||||||
|
return@setOnItemClickListener
|
||||||
|
}
|
||||||
|
}
|
||||||
selectedAccount = picked
|
selectedAccount = picked
|
||||||
updateAmountPrefix(picked)
|
updateAmountPrefix(picked)
|
||||||
showFromCard(picked)
|
showFromCard(picked)
|
||||||
@@ -506,6 +535,14 @@ class TransferFragment : Fragment() {
|
|||||||
binding.tilTo.setEndIconOnClickListener { lookupAccount() }
|
binding.tilTo.setEndIconOnClickListener { lookupAccount() }
|
||||||
|
|
||||||
binding.btnClearToInfo.setOnClickListener {
|
binding.btnClearToInfo.setOnClickListener {
|
||||||
|
if (bmlQrInfo != null) {
|
||||||
|
bmlQrInfo = null
|
||||||
|
bmlGatewayQr = false
|
||||||
|
binding.tilAmount.isEnabled = true
|
||||||
|
binding.tilRemarks.isEnabled = true
|
||||||
|
binding.tilRemarks.alpha = 1f
|
||||||
|
binding.etAmount.setText("")
|
||||||
|
}
|
||||||
resolvedAccountNumber = ""
|
resolvedAccountNumber = ""
|
||||||
resolvedRecipientName = ""
|
resolvedRecipientName = ""
|
||||||
resolvedToOwnAccount = null
|
resolvedToOwnAccount = null
|
||||||
@@ -1737,6 +1774,7 @@ class TransferFragment : Fragment() {
|
|||||||
.also { it.root.tag = it }
|
.also { it.root.tag = it }
|
||||||
}
|
}
|
||||||
val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT" || acc.profileType == "BML_DEBIT") && !acc.statusDesc.equals("Active", ignoreCase = true)
|
val inactive = (acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT" || acc.profileType == "BML_DEBIT") && !acc.statusDesc.equals("Active", ignoreCase = true)
|
||||||
|
val isCard = acc.profileType == "BML_PREPAID" || acc.profileType == "BML_CREDIT" || acc.profileType == "BML_DEBIT"
|
||||||
val isBmlAccount = acc.bank == "BML"
|
val isBmlAccount = acc.bank == "BML"
|
||||||
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
val ownerPrefix = if (isBmlAccount && acc.profileName.isNotBlank()) "${acc.profileName} · " else ""
|
||||||
val hide = viewModel.hideAmounts.value ?: false
|
val hide = viewModel.hideAmounts.value ?: false
|
||||||
@@ -1754,7 +1792,11 @@ class TransferFragment : Fragment() {
|
|||||||
}
|
}
|
||||||
val balance = displayData?.balance ?: ""
|
val balance = displayData?.balance ?: ""
|
||||||
b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance
|
b.tvDropdownBalance.text = if (hide && balance.isNotBlank()) maskAmount(balance) else balance
|
||||||
b.root.alpha = if (inactive) 0.4f else 1f
|
b.root.alpha = when {
|
||||||
|
inactive -> 0.4f
|
||||||
|
bmlQrInfo != null && !isCard -> 0.35f
|
||||||
|
else -> 1f
|
||||||
|
}
|
||||||
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
|
val networkIcon = BmlCardParser.cardNetworkIcon(acc)
|
||||||
when {
|
when {
|
||||||
networkIcon != null -> {
|
networkIcon != null -> {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package sh.sar.basedbank.ui.login
|
package sh.sar.basedbank.ui.login
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
@@ -81,6 +84,17 @@ class CredentialsFragment : Fragment() {
|
|||||||
binding.btnLogin.isEnabled = false
|
binding.btnLogin.isEnabled = false
|
||||||
binding.btnLogin.setOnClickListener { attemptLogin() }
|
binding.btnLogin.setOnClickListener { attemptLogin() }
|
||||||
|
|
||||||
|
binding.cardOtp.setOnClickListener {
|
||||||
|
val code = binding.tvOtpCode.text.toString().replace(" ", "")
|
||||||
|
if (code.isNotEmpty()) {
|
||||||
|
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
clipboard.setPrimaryClip(ClipData.newPlainText("OTP", code))
|
||||||
|
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
Toast.makeText(requireContext(), "OTP copied", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val loginFieldWatcher = object : TextWatcher {
|
val loginFieldWatcher = object : TextWatcher {
|
||||||
override fun afterTextChanged(s: Editable?) { updateLoginButtonState() }
|
override fun afterTextChanged(s: Editable?) { updateLoginButtonState() }
|
||||||
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
|
||||||
|
|||||||
@@ -615,6 +615,29 @@ class CredentialStore(context: Context) {
|
|||||||
} catch (_: Exception) { null }
|
} catch (_: Exception) { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Default payment card ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** BML card account number the user has pinned as their default payment card, or null. */
|
||||||
|
fun getDefaultCardAccountNumber(): String? = prefs.getString("default_card_account_number", null)
|
||||||
|
|
||||||
|
fun setDefaultCardAccountNumber(accountNumber: String?) {
|
||||||
|
val editor = prefs.edit()
|
||||||
|
if (accountNumber == null) editor.remove("default_card_account_number")
|
||||||
|
else editor.putString("default_card_account_number", accountNumber)
|
||||||
|
editor.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Dashboard card visibility ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
fun getHiddenDashboardCardNumbers(): Set<String> =
|
||||||
|
prefs.getStringSet("hidden_dashboard_cards", emptySet()) ?: emptySet()
|
||||||
|
|
||||||
|
fun setCardHiddenFromDashboard(accountNumber: String, hidden: Boolean) {
|
||||||
|
val current = getHiddenDashboardCardNumbers().toMutableSet()
|
||||||
|
if (hidden) current.add(accountNumber) else current.remove(accountNumber)
|
||||||
|
prefs.edit().putStringSet("hidden_dashboard_cards", current).apply()
|
||||||
|
}
|
||||||
|
|
||||||
// ── MIB profile visibility (per loginId) ─────────────────────────────────
|
// ── MIB profile visibility (per loginId) ─────────────────────────────────
|
||||||
|
|
||||||
/** Returns the set of MIB profile IDs the user has chosen to hide (for a given loginId). */
|
/** Returns the set of MIB profile IDs the user has chosen to hide (for a given loginId). */
|
||||||
|
|||||||
@@ -9,6 +9,24 @@ data class PaymvQrData(
|
|||||||
|
|
||||||
object PaymvQrParser {
|
object PaymvQrParser {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the BML gateway URL if [raw] is or contains one, otherwise null.
|
||||||
|
* Handles both plain URL QRs and combined EMV QRs (e.g. Fahipay+BML card QR).
|
||||||
|
* For combined EMV QRs the URL is parsed from TLV (root tag 35 → sub-tag 20 → sub-sub-tag 01)
|
||||||
|
* rather than via regex, to avoid greedily consuming subsequent EMV tag bytes.
|
||||||
|
*/
|
||||||
|
fun extractBmlGatewayUrl(raw: String): String? {
|
||||||
|
if (raw.startsWith("https://pay.bml.com.mv/app/")) return raw
|
||||||
|
return try {
|
||||||
|
val root = parseTlv(raw)
|
||||||
|
val bmlMerchantInfo = root["35"]?.let { parseTlv(it) } ?: return null
|
||||||
|
val inner = bmlMerchantInfo["20"]?.let { parseTlv(it) } ?: return null
|
||||||
|
inner["01"]?.takeIf { it.startsWith("https://pay.bml.com.mv/app/") }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun parse(raw: String): PaymvQrData? {
|
fun parse(raw: String): PaymvQrData? {
|
||||||
return try {
|
return try {
|
||||||
val root = parseTlv(raw)
|
val root = parseTlv(raw)
|
||||||
|
|||||||
@@ -5,19 +5,20 @@
|
|||||||
android:viewportWidth="60"
|
android:viewportWidth="60"
|
||||||
android:viewportHeight="60">
|
android:viewportHeight="60">
|
||||||
|
|
||||||
<!-- White background -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:pathData="M0 59.776h59.785V0H0z"/>
|
|
||||||
|
|
||||||
<!-- Red background -->
|
<!-- Red background -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#E21B23"
|
android:fillColor="#E21B23"
|
||||||
android:pathData="M3.297 56.421h53.191V3.356H3.298z"/>
|
android:pathData="M0 60h60V0H0z"/>
|
||||||
|
|
||||||
<!-- White logo mark -->
|
<!-- White logo mark -->
|
||||||
<path
|
<group
|
||||||
android:fillColor="#FFFFFF"
|
android:scaleX="0.85"
|
||||||
android:fillType="evenOdd"
|
android:scaleY="0.85"
|
||||||
android:pathData="M37.421 6.708v34.059h-3.7V20.853L22.763 40.767H18.65l18.77-34.06zM18.517 51.073l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.03c-.613.313-1.228.626-1.88.626-.65 0-1.265-.313-1.879-.626-.553-.283-1.108-.564-1.624-.564-.514 0-1.069.28-1.62.564-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.068.28-1.62.564c-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.069.28-1.622.564c-.614.313-1.228.626-1.879.626-.6 0-1.167-.265-1.73-.55v-1.446zm0-2.816l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.028c-.613.315-1.228.627-1.88.627-.65 0-1.265-.312-1.879-.627-.553-.281-1.108-.562-1.624-.562-.514 0-1.069.28-1.62.562-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.068.28-1.62.562c-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.069.28-1.622.562c-.614.315-1.228.627-1.879.627-.6 0-1.167-.264-1.73-.55v-1.445zm12.428-6.042h12.41v3.969h-12.41v-.001H18.531l-2.1-3.968h14.514z"/>
|
android:pivotX="30"
|
||||||
|
android:pivotY="30">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:fillType="evenOdd"
|
||||||
|
android:pathData="M37.421 6.708v34.059h-3.7V20.853L22.763 40.767H18.65l18.77-34.06zM18.517 51.073l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.03c-.613.313-1.228.626-1.88.626-.65 0-1.265-.313-1.879-.626-.553-.283-1.108-.564-1.624-.564-.514 0-1.069.28-1.62.564-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.068.28-1.62.564c-.616.313-1.23.626-1.88.626-.653 0-1.268-.313-1.88-.626-.554-.283-1.108-.564-1.624-.564s-1.069.28-1.622.564c-.614.313-1.228.626-1.879.626-.6 0-1.167-.265-1.73-.55v-1.446zm0-2.816l.108.055c.552.283 1.106.564 1.623.564.515 0 1.068-.281 1.621-.564.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.068-.281 1.621-.564c.615-.313 1.23-.627 1.88-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564c.613-.313 1.228-.627 1.878-.627.652 0 1.267.314 1.88.627.554.283 1.107.564 1.623.564s1.07-.281 1.623-.564l.314-.159v1.444l-.057.028c-.613.315-1.228.627-1.88.627-.65 0-1.265-.312-1.879-.627-.553-.281-1.108-.562-1.624-.562-.514 0-1.069.28-1.62.562-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.068.28-1.62.562c-.616.315-1.23.627-1.88.627-.653 0-1.268-.312-1.88-.627-.554-.281-1.108-.562-1.624-.562s-1.069.28-1.622.562c-.614.315-1.228.627-1.879.627-.6 0-1.167-.264-1.73-.55v-1.445zm12.428-6.042h12.41v3.969h-12.41v-.001H18.531l-2.1-3.968h14.514z"/>
|
||||||
|
</group>
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?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">
|
||||||
|
<!-- Card icon -->
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:pathData="M20,4H4C2.89,4 2.01,4.89 2.01,6L2,18c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2V6C22,4.89 21.11,4 20,4zM20,18H4v-6h16V18zM20,8H4V6h16V8z" />
|
||||||
|
<!-- Background circle separating gear from card -->
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/colorSurface"
|
||||||
|
android:pathData="M12.5,18 A5.5,5.5 0 1 0 23.5,18 A5.5,5.5 0 1 0 12.5,18 Z" />
|
||||||
|
<!--
|
||||||
|
Simple 6-tooth gear, scaled 0.5x and placed at (18,18) in the icon.
|
||||||
|
The gear path is drawn in a 24×24 space (center 12,12); the group
|
||||||
|
transform maps it: point(x,y) → (x·0.5+12, y·0.5+12).
|
||||||
|
Outer radius=10, root radius=7, hub radius=4.
|
||||||
|
Teeth are rectangular (straight-line) so they stay crisp at small scale.
|
||||||
|
Outer ring is clockwise; hub hole is counterclockwise (nonZero cutout).
|
||||||
|
-->
|
||||||
|
<group
|
||||||
|
android:scaleX="0.5"
|
||||||
|
android:scaleY="0.5"
|
||||||
|
android:translateX="12"
|
||||||
|
android:translateY="12">
|
||||||
|
<path
|
||||||
|
android:fillColor="?attr/colorOnSurfaceVariant"
|
||||||
|
android:pathData="
|
||||||
|
M18.58,9.61 L21.85,10.26 L21.85,13.74 L18.58,14.39
|
||||||
|
L17.36,16.50 L18.43,19.66 L15.42,21.40 L13.22,18.89
|
||||||
|
L10.78,18.89 L8.58,21.40 L5.57,19.66 L6.64,16.50
|
||||||
|
L5.42,14.39 L2.15,13.74 L2.15,10.26 L5.42,9.61
|
||||||
|
L6.64,7.50 L5.57,4.34 L8.58,2.60 L10.78,5.11
|
||||||
|
L13.22,5.11 L15.42,2.60 L18.43,4.34 L17.36,7.50 Z
|
||||||
|
M12,8 A4,4 0 1 0 12,16 A4,4 0 1 0 12,8 Z" />
|
||||||
|
</group>
|
||||||
|
</vector>
|
||||||
@@ -2,9 +2,41 @@
|
|||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="24dp"
|
android:width="24dp"
|
||||||
android:height="24dp"
|
android:height="24dp"
|
||||||
android:viewportWidth="24"
|
android:viewportWidth="48"
|
||||||
android:viewportHeight="24">
|
android:viewportHeight="48">
|
||||||
|
|
||||||
|
<!-- Phone outline -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="?attr/colorOnSurface"
|
android:fillColor="#00000000"
|
||||||
android:pathData="M20,2L4,2C2.9,2 2,2.9 2,4l0,16c0,1.1 0.9,2 2,2l16,0c1.1,0 2,-0.9 2,-2L22,4C22,2.9 21.1,2 20,2zM13,18l-2,0 0,-1c-2.76,0 -5,-2.24 -5,-5s2.24,-5 5,-5 5,2.24 5,5l-2,0c0,-1.65 -1.35,-3 -3,-3s-3,1.35 -3,3 1.35,3 3,3l0,-2 2,3zM19,12l-2,0c0,-2.76 -2.24,-5 -5,-5l0,-2C15.87,5 19,8.13 19,12z"/>
|
android:strokeColor="?attr/colorOnSurface"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M6.08,8.6 L20.45,8.6 A1.58,1.58,0,0,1,22.03,10.18 L22.03,37.81 A1.58,1.58,0,0,1,20.45,39.39 L6.08,39.39 A1.58,1.58,0,0,1,4.5,37.81 L4.5,10.18 A1.58,1.58,0,0,1,6.08,8.6 Z"/>
|
||||||
|
|
||||||
|
<!-- Top notch line -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="?attr/colorOnSurface"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:pathData="M4.5,12.55 L22.03,12.55"/>
|
||||||
|
|
||||||
|
<!-- Bottom notch line -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="?attr/colorOnSurface"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:pathData="M4.5,35.45 L22.03,35.45"/>
|
||||||
|
|
||||||
|
<!-- NFC waves (outer, mid, inner) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="?attr/colorOnSurface"
|
||||||
|
android:strokeWidth="2"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M36.07,9.29 a18.28,18.28,0,0,1,0,29.42 m-4,-24.16 a11.84,11.84,0,0,1,5,9.45 11.84,11.84,0,0,1,-5,9.45 m-3.68,-14 a5.67,5.67,0,0,1,0,9.1"/>
|
||||||
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -4,13 +4,47 @@
|
|||||||
android:height="108dp"
|
android:height="108dp"
|
||||||
android:viewportWidth="108"
|
android:viewportWidth="108"
|
||||||
android:viewportHeight="108">
|
android:viewportHeight="108">
|
||||||
|
|
||||||
<group
|
<group
|
||||||
android:translateX="30"
|
android:translateX="30"
|
||||||
android:translateY="30"
|
android:translateY="30"
|
||||||
android:scaleX="2"
|
android:scaleX="1.0"
|
||||||
android:scaleY="2">
|
android:scaleY="1.0">
|
||||||
|
|
||||||
|
<!-- Phone outline -->
|
||||||
<path
|
<path
|
||||||
android:fillColor="#FFFFFF"
|
android:fillColor="#00000000"
|
||||||
android:pathData="M20,4H4C2.89,4 2.01,4.89 2.01,6L2,18c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2V6C22,4.89 21.11,4 20,4zM20,18H4v-6h16V18zM20,8H4V6h16V8z" />
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="2.5"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M6.08,8.6 L20.45,8.6 A1.58,1.58,0,0,1,22.03,10.18 L22.03,37.81 A1.58,1.58,0,0,1,20.45,39.39 L6.08,39.39 A1.58,1.58,0,0,1,4.5,37.81 L4.5,10.18 A1.58,1.58,0,0,1,6.08,8.6 Z"/>
|
||||||
|
|
||||||
|
<!-- Top notch line -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="2.5"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:pathData="M4.5,12.55 L22.03,12.55"/>
|
||||||
|
|
||||||
|
<!-- Bottom notch line -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="2.5"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:pathData="M4.5,35.45 L22.03,35.45"/>
|
||||||
|
|
||||||
|
<!-- NFC waves -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#00000000"
|
||||||
|
android:strokeColor="#FFFFFF"
|
||||||
|
android:strokeWidth="2.5"
|
||||||
|
android:strokeLineCap="round"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:pathData="M36.07,9.29 a18.28,18.28,0,0,1,0,29.42 m-4,-24.16 a11.84,11.84,0,0,1,5,9.45 11.84,11.84,0,0,1,-5,9.45 m-3.68,-14 a5.67,5.67,0,0,1,0,9.1"/>
|
||||||
|
|
||||||
</group>
|
</group>
|
||||||
|
|
||||||
</vector>
|
</vector>
|
||||||
|
|||||||
@@ -14,12 +14,47 @@
|
|||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:visibility="gone">
|
android:visibility="gone">
|
||||||
|
|
||||||
<!-- Top spacer: pushes card to vertical center -->
|
<!-- Manage Card button row -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="end"
|
||||||
|
android:paddingHorizontal="12dp"
|
||||||
|
android:paddingTop="4dp">
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnManageCard"
|
||||||
|
style="@style/Widget.Material3.Button.OutlinedButton"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:minWidth="0dp"
|
||||||
|
android:minHeight="0dp"
|
||||||
|
android:text="@string/card_manage"
|
||||||
|
android:textSize="12sp"
|
||||||
|
app:icon="@drawable/ic_manage_card"
|
||||||
|
app:iconSize="20dp"
|
||||||
|
app:iconPadding="4dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Top spacer: pushes card to vertical center (hidden in manage mode) -->
|
||||||
<View
|
<View
|
||||||
|
android:id="@+id/topSpacer"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
|
<!-- Manage mode: selected card with overlays -->
|
||||||
|
<include
|
||||||
|
android:id="@+id/manageCardView"
|
||||||
|
layout="@layout/item_card_stack"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginHorizontal="16dp"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
<!-- Horizontal card stack. Width/padding set programmatically for centering + peek. -->
|
<!-- Horizontal card stack. Width/padding set programmatically for centering + peek. -->
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/rvCards"
|
android:id="@+id/rvCards"
|
||||||
@@ -58,20 +93,22 @@
|
|||||||
|
|
||||||
<!-- Divider -->
|
<!-- Divider -->
|
||||||
<View
|
<View
|
||||||
|
android:id="@+id/divider"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="1dp"
|
android:layout_height="1dp"
|
||||||
android:layout_marginHorizontal="24dp"
|
android:layout_marginHorizontal="24dp"
|
||||||
android:layout_marginBottom="4dp"
|
android:layout_marginBottom="4dp"
|
||||||
android:background="?attr/colorOutlineVariant" />
|
android:background="?attr/colorOutlineVariant" />
|
||||||
|
|
||||||
<!-- Primary pay actions -->
|
<!-- Primary pay actions (normal mode) -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/llPayButtons"
|
||||||
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"
|
||||||
android:paddingHorizontal="16dp"
|
android:paddingHorizontal="16dp"
|
||||||
android:paddingTop="8dp"
|
android:paddingTop="8dp"
|
||||||
android:paddingBottom="4dp">
|
android:paddingBottom="12dp">
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btnScanToPay"
|
android:id="@+id/btnScanToPay"
|
||||||
@@ -111,61 +148,134 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Secondary card management actions -->
|
<!-- Default card toggle (manage mode only) -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
android:id="@+id/llDefaultCardRow"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingHorizontal="20dp"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingBottom="4dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/card_set_as_default"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium" />
|
||||||
|
|
||||||
|
<com.google.android.material.materialswitch.MaterialSwitch
|
||||||
|
android:id="@+id/switchDefaultCard"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Hide from dashboard toggle (manage mode only) -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/llHideDashboardRow"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingHorizontal="20dp"
|
||||||
|
android:paddingTop="4dp"
|
||||||
|
android:paddingBottom="4dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/card_hide_from_dashboard"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium" />
|
||||||
|
|
||||||
|
<com.google.android.material.materialswitch.MaterialSwitch
|
||||||
|
android:id="@+id/switchHideFromDashboard"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Card management actions (manage mode only) -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/llManageButtons"
|
||||||
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"
|
||||||
android:paddingHorizontal="8dp"
|
android:paddingHorizontal="8dp"
|
||||||
android:paddingTop="4dp"
|
android:paddingTop="8dp"
|
||||||
android:paddingBottom="8dp">
|
android:paddingBottom="12dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btnChangePin"
|
android:id="@+id/btnChangePin"
|
||||||
style="@style/Widget.Material3.Button.TextButton"
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
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:minWidth="0dp"
|
android:minWidth="0dp"
|
||||||
android:minHeight="0dp"
|
android:minHeight="0dp"
|
||||||
|
android:paddingTop="14dp"
|
||||||
|
android:paddingBottom="14dp"
|
||||||
android:text="@string/card_action_change_pin"
|
android:text="@string/card_action_change_pin"
|
||||||
android:textSize="11sp"
|
android:textSize="12sp"
|
||||||
app:icon="@drawable/ic_edit"
|
app:icon="@drawable/ic_edit"
|
||||||
app:iconSize="16dp"
|
app:iconSize="22dp"
|
||||||
app:iconPadding="3dp" />
|
app:iconGravity="top"
|
||||||
|
app:iconPadding="6dp" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btnFreeze"
|
android:id="@+id/btnFreeze"
|
||||||
style="@style/Widget.Material3.Button.TextButton"
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
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:minWidth="0dp"
|
android:minWidth="0dp"
|
||||||
android:minHeight="0dp"
|
android:minHeight="0dp"
|
||||||
|
android:paddingTop="14dp"
|
||||||
|
android:paddingBottom="14dp"
|
||||||
android:text="@string/card_action_freeze"
|
android:text="@string/card_action_freeze"
|
||||||
android:textSize="11sp"
|
android:textSize="12sp"
|
||||||
app:icon="@drawable/ic_freeze"
|
app:icon="@drawable/ic_freeze"
|
||||||
app:iconSize="16dp"
|
app:iconSize="22dp"
|
||||||
app:iconPadding="3dp" />
|
app:iconGravity="top"
|
||||||
|
app:iconPadding="6dp" />
|
||||||
|
|
||||||
<com.google.android.material.button.MaterialButton
|
<com.google.android.material.button.MaterialButton
|
||||||
android:id="@+id/btnBlock"
|
android:id="@+id/btnBlock"
|
||||||
style="@style/Widget.Material3.Button.TextButton"
|
style="@style/Widget.Material3.Button.TonalButton"
|
||||||
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:minWidth="0dp"
|
android:minWidth="0dp"
|
||||||
android:minHeight="0dp"
|
android:minHeight="0dp"
|
||||||
|
android:paddingTop="14dp"
|
||||||
|
android:paddingBottom="14dp"
|
||||||
android:text="@string/card_action_block"
|
android:text="@string/card_action_block"
|
||||||
android:textSize="11sp"
|
android:textSize="12sp"
|
||||||
app:icon="@drawable/ic_block"
|
app:icon="@drawable/ic_block"
|
||||||
app:iconSize="16dp"
|
app:iconSize="22dp"
|
||||||
app:iconPadding="3dp" />
|
app:iconGravity="top"
|
||||||
|
app:iconPadding="6dp" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Tap-to-pay overlay: shown in tap mode, sits above contentLayout -->
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/flTapMode"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/loadingView"
|
android:id="@+id/loadingView"
|
||||||
|
|||||||
@@ -116,6 +116,8 @@
|
|||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:layout_marginBottom="8dp"
|
android:layout_marginBottom="8dp"
|
||||||
android:visibility="invisible"
|
android:visibility="invisible"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
app:cardBackgroundColor="?attr/colorSecondaryContainer"
|
app:cardBackgroundColor="?attr/colorSecondaryContainer"
|
||||||
app:cardCornerRadius="12dp"
|
app:cardCornerRadius="12dp"
|
||||||
app:cardElevation="0dp">
|
app:cardElevation="0dp">
|
||||||
|
|||||||
@@ -176,9 +176,9 @@
|
|||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Attention row: Blocked + Overdue (hidden when nothing applies) -->
|
<!-- Blocked funds row: MVR + USD separate cards (hidden when no blocked amounts) -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/rowAttention"
|
android:id="@+id/rowBlocked"
|
||||||
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"
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
android:visibility="gone">
|
android:visibility="gone">
|
||||||
|
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
android:id="@+id/cardBlocked"
|
android:id="@+id/cardBlockedMvr"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
@@ -205,12 +205,12 @@
|
|||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/dashboard_blocked"
|
android:text="@string/dashboard_blocked_mvr"
|
||||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||||
android:textColor="?attr/colorOnErrorContainer" />
|
android:textColor="?attr/colorOnErrorContainer" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/tvBlockedTotal"
|
android:id="@+id/tvBlockedMvr"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="4dp"
|
android:layout_marginTop="4dp"
|
||||||
@@ -218,22 +218,12 @@
|
|||||||
android:textAppearance="?attr/textAppearanceTitleMedium"
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
android:textColor="?attr/colorOnErrorContainer" />
|
android:textColor="?attr/colorOnErrorContainer" />
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/tvBlockedSecondary"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_marginTop="2dp"
|
|
||||||
android:textAppearance="?attr/textAppearanceLabelSmall"
|
|
||||||
android:textColor="?attr/colorOnErrorContainer"
|
|
||||||
android:alpha="0.75"
|
|
||||||
android:visibility="gone" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</com.google.android.material.card.MaterialCardView>
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
android:id="@+id/cardOverdue"
|
android:id="@+id/cardBlockedUsd"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
@@ -241,6 +231,52 @@
|
|||||||
app:cardElevation="1dp"
|
app:cardElevation="1dp"
|
||||||
app:cardCornerRadius="12dp"
|
app:cardCornerRadius="12dp"
|
||||||
app:cardBackgroundColor="?attr/colorErrorContainer"
|
app:cardBackgroundColor="?attr/colorErrorContainer"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/dashboard_blocked_usd"
|
||||||
|
android:textAppearance="?attr/textAppearanceLabelSmall"
|
||||||
|
android:textColor="?attr/colorOnErrorContainer" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvBlockedUsd"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="USD —"
|
||||||
|
android:textAppearance="?attr/textAppearanceTitleMedium"
|
||||||
|
android:textColor="?attr/colorOnErrorContainer" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Overdue row (hidden when no overdue financing) -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/rowAttention"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardOverdue"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
app:cardElevation="1dp"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardBackgroundColor="?attr/colorErrorContainer"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
android:focusable="true"
|
android:focusable="true"
|
||||||
android:visibility="gone">
|
android:visibility="gone">
|
||||||
|
|||||||
@@ -64,7 +64,6 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginBottom="12dp"
|
android:layout_marginBottom="12dp"
|
||||||
android:hint="@string/paymvqr_amount_hint"
|
android:hint="@string/paymvqr_amount_hint"
|
||||||
app:helperText="@string/paymvqr_amount_helper"
|
|
||||||
app:prefixText="MVR ">
|
app:prefixText="MVR ">
|
||||||
|
|
||||||
<com.google.android.material.textfield.TextInputEditText
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
@@ -76,6 +75,47 @@
|
|||||||
|
|
||||||
</com.google.android.material.textfield.TextInputLayout>
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<!-- Reference / purpose (optional) -->
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:id="@+id/tilReference"
|
||||||
|
style="@style/Widget.Material3.TextInputLayout.OutlinedBox"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="10dp"
|
||||||
|
android:hint="@string/paymvqr_reference_hint">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etReference"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:inputType="text"
|
||||||
|
android:maxLines="1" />
|
||||||
|
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
<!-- Include phone number toggle -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:layout_marginBottom="12dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="@string/paymvqr_include_phone"
|
||||||
|
android:textAppearance="?attr/textAppearanceBodyMedium" />
|
||||||
|
|
||||||
|
<com.google.android.material.materialswitch.MaterialSwitch
|
||||||
|
android:id="@+id/switchIncludePhone"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:checked="true" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Action buttons — always visible; share/save disabled until QR is rendered -->
|
<!-- Action buttons — always visible; share/save disabled until QR is rendered -->
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:id="@+id/layoutActions"
|
android:id="@+id/layoutActions"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<com.google.android.material.card.MaterialCardView
|
<com.google.android.material.card.MaterialCardView
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
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"
|
||||||
android:layout_width="280dp"
|
android:layout_width="320dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginEnd="12dp"
|
android:layout_marginEnd="12dp"
|
||||||
app:cardCornerRadius="16dp"
|
app:cardCornerRadius="16dp"
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginHorizontal="16dp"
|
android:layout_marginHorizontal="16dp"
|
||||||
android:layout_marginBottom="12dp"
|
android:layout_marginBottom="12dp"
|
||||||
|
android:clickable="true"
|
||||||
|
android:focusable="true"
|
||||||
app:cardCornerRadius="16dp"
|
app:cardCornerRadius="16dp"
|
||||||
app:cardElevation="2dp">
|
app:cardElevation="2dp">
|
||||||
|
|
||||||
|
|||||||
@@ -115,11 +115,13 @@
|
|||||||
<!-- PayMV QR Generator -->
|
<!-- PayMV QR Generator -->
|
||||||
<string name="paymvqr_select_account">Select account</string>
|
<string name="paymvqr_select_account">Select account</string>
|
||||||
<string name="paymvqr_amount_hint">Amount (optional)</string>
|
<string name="paymvqr_amount_hint">Amount (optional)</string>
|
||||||
<string name="paymvqr_amount_helper">Leave empty to allow payer to enter any amount</string>
|
|
||||||
<string name="paymvqr_share">Share</string>
|
<string name="paymvqr_share">Share</string>
|
||||||
<string name="paymvqr_save_image">Save Image</string>
|
<string name="paymvqr_save_image">Save Image</string>
|
||||||
<string name="paymvqr_saved">QR saved to gallery</string>
|
<string name="paymvqr_saved">QR saved to gallery</string>
|
||||||
<string name="paymvqr_save_failed">Failed to save image</string>
|
<string name="paymvqr_save_failed">Failed to save image</string>
|
||||||
|
<string name="paymvqr_include_phone">Include phone number</string>
|
||||||
|
<string name="paymvqr_reference_hint">Reference (optional)</string>
|
||||||
|
<string name="paymvqr_reference_default">PayMV QR Transfer</string>
|
||||||
|
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<string name="action_lock">Lock app</string>
|
<string name="action_lock">Lock app</string>
|
||||||
@@ -218,7 +220,8 @@
|
|||||||
<string name="cards">Cards</string>
|
<string name="cards">Cards</string>
|
||||||
<string name="available_balance">Available Balance</string>
|
<string name="available_balance">Available Balance</string>
|
||||||
<string name="account_blocked_label">%1$s blocked</string>
|
<string name="account_blocked_label">%1$s blocked</string>
|
||||||
<string name="dashboard_blocked">Blocked Funds</string>
|
<string name="dashboard_blocked_mvr">Blocked MVR</string>
|
||||||
|
<string name="dashboard_blocked_usd">Blocked USD</string>
|
||||||
<string name="dashboard_overdue">Overdue Financing</string>
|
<string name="dashboard_overdue">Overdue Financing</string>
|
||||||
|
|
||||||
<!-- Transfer -->
|
<!-- Transfer -->
|
||||||
@@ -235,11 +238,12 @@
|
|||||||
<string name="transfer_lookup_account">Look up account</string>
|
<string name="transfer_lookup_account">Look up account</string>
|
||||||
<string name="transfer_clear_recipient">Clear recipient</string>
|
<string name="transfer_clear_recipient">Clear recipient</string>
|
||||||
<string name="transfer_pick_contact">Pick contact</string>
|
<string name="transfer_pick_contact">Pick contact</string>
|
||||||
<string name="transfer_scan_qr">Scan QR</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="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="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_enter_account_first">Enter an account number first</string>
|
<string name="transfer_enter_account_first">Enter an account number first</string>
|
||||||
@@ -326,6 +330,9 @@
|
|||||||
<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="card_manage">Manage Card</string>
|
||||||
|
<string name="card_set_as_default">Set as Default Card</string>
|
||||||
|
<string name="card_hide_from_dashboard">Hide from Dashboard</string>
|
||||||
<string name="card_action_change_pin">Change PIN</string>
|
<string name="card_action_change_pin">Change PIN</string>
|
||||||
<string name="card_action_freeze">Freeze</string>
|
<string name="card_action_freeze">Freeze</string>
|
||||||
<string name="card_action_block">Block</string>
|
<string name="card_action_block">Block</string>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<host-apdu-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:description="@string/app_name"
|
||||||
|
android:requireDeviceUnlock="false">
|
||||||
|
|
||||||
|
<aid-group
|
||||||
|
android:description="@string/app_name"
|
||||||
|
android:category="payment">
|
||||||
|
|
||||||
|
<!-- PPSE: 2PAY.SYS.DDF01 -->
|
||||||
|
<aid-filter android:name="325041592E5359532E4444463031" />
|
||||||
|
|
||||||
|
<!-- Visa -->
|
||||||
|
<aid-filter android:name="A0000000031010" />
|
||||||
|
|
||||||
|
<!-- Mastercard -->
|
||||||
|
<aid-filter android:name="A0000000041010" />
|
||||||
|
|
||||||
|
<!-- Amex -->
|
||||||
|
<aid-filter android:name="A000000025" />
|
||||||
|
|
||||||
|
</aid-group>
|
||||||
|
|
||||||
|
</host-apdu-service>
|
||||||
@@ -28,13 +28,13 @@
|
|||||||
</shortcut>
|
</shortcut>
|
||||||
|
|
||||||
<shortcut
|
<shortcut
|
||||||
android:shortcutId="pay_with_card"
|
android:shortcutId="tap_to_pay"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:icon="@drawable/ic_shortcut_pay_card"
|
android:icon="@drawable/ic_shortcut_pay_card"
|
||||||
android:shortcutShortLabel="@string/nav_pay_with_card"
|
android:shortcutShortLabel="@string/card_pay_nfc"
|
||||||
android:shortcutLongLabel="@string/nav_pay_with_card">
|
android:shortcutLongLabel="@string/card_pay_nfc">
|
||||||
<intent
|
<intent
|
||||||
android:action="sh.sar.basedbank.OPEN_PAY_WITH_CARD"
|
android:action="sh.sar.basedbank.TAP_TO_PAY"
|
||||||
android:targetPackage="sh.sar.basedbank"
|
android:targetPackage="sh.sar.basedbank"
|
||||||
android:targetClass="sh.sar.basedbank.MainActivity" />
|
android:targetClass="sh.sar.basedbank.MainActivity" />
|
||||||
<categories android:name="android.shortcut.conversation" />
|
<categories android:name="android.shortcut.conversation" />
|
||||||
|
|||||||
Reference in New Issue
Block a user