diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 591a4f8..825d607 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -91,6 +91,9 @@ dependencies {
// Biometric authentication
implementation("androidx.biometric:biometric:1.1.0")
+ // Encrypted SharedPreferences (HCE token store)
+ implementation("androidx.security:security-crypto:1.1.0-alpha06")
+
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 87f112a..1ed2f1e 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -7,6 +7,9 @@
+
+
+
+
+
+
+
+
+
+
+
{
+ 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 {
+ 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", "")
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/sh/sar/basedbank/nfc/BmlHostCardEmulatorService.kt b/app/src/main/java/sh/sar/basedbank/nfc/BmlHostCardEmulatorService.kt
new file mode 100644
index 0000000..7631510
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/nfc/BmlHostCardEmulatorService.kt
@@ -0,0 +1,179 @@
+package sh.sar.basedbank.nfc
+
+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
+ val token = activeToken ?: return SW_UNKNOWN_ERROR
+
+ return when {
+ data.contentEquals(PPSE_BYTES) -> {
+ // SELECT PPSE
+ val resp = buildSelectPpseResponse(token.appCode, applicationLabel(token.appCode), "01")
+ hexToBytes(resp)
+ }
+ data.contentEquals(hexToBytes(token.appCode)) -> {
+ // SELECT AID
+ val resp = buildSelectAidResponse(token.appCode, applicationLabel(token.appCode))
+ hexToBytes(resp)
+ }
+ else -> SW_UNKNOWN_ERROR
+ }
+ }
+
+ 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"
+ }
+ }
+}
diff --git a/app/src/main/java/sh/sar/basedbank/nfc/HceTokenStore.kt b/app/src/main/java/sh/sar/basedbank/nfc/HceTokenStore.kt
new file mode 100644
index 0000000..e9e5a81
--- /dev/null
+++ b/app/src/main/java/sh/sar/basedbank/nfc/HceTokenStore.kt
@@ -0,0 +1,105 @@
+package sh.sar.basedbank.nfc
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import org.json.JSONArray
+import org.json.JSONObject
+import sh.sar.basedbank.api.bml.BmlWalletToken
+
+/**
+ * Encrypted local store for a pool of single-use HCE payment tokens.
+ * Tokens are consumed one per NFC transaction.
+ */
+class HceTokenStore(context: Context) {
+
+ private val prefs: SharedPreferences = try {
+ val masterKey = MasterKey.Builder(context)
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
+ .build()
+ EncryptedSharedPreferences.create(
+ context,
+ "hce_token_store",
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
+ )
+ } catch (_: Exception) {
+ context.getSharedPreferences("hce_token_store_fallback", Context.MODE_PRIVATE)
+ }
+
+ fun saveTokens(tokens: List) {
+ val arr = JSONArray()
+ tokens.forEach { t ->
+ arr.put(JSONObject().apply {
+ put("token", t.token)
+ put("expiry", t.expiry)
+ put("app_code", t.appCode)
+ put("service_code", t.serviceCode)
+ put("data", t.data)
+ put("valid_until", t.validUntil)
+ })
+ }
+ prefs.edit().putString("tokens", arr.toString()).apply()
+ }
+
+ /** Remove and return the next token, or null if pool is empty / all expired. */
+ fun popToken(): BmlWalletToken? {
+ val raw = prefs.getString("tokens", null) ?: return null
+ val arr = try { JSONArray(raw) } catch (_: Exception) { return null }
+ if (arr.length() == 0) return null
+
+ val now = System.currentTimeMillis()
+ var found: BmlWalletToken? = null
+ var foundIndex = -1
+
+ for (i in 0 until arr.length()) {
+ val o = arr.getJSONObject(i)
+ val validUntil = o.optString("valid_until", "")
+ if (validUntil.isNotBlank() && isExpired(validUntil, now)) continue
+ found = BmlWalletToken(
+ token = o.getString("token"),
+ expiry = o.getString("expiry"),
+ appCode = o.getString("app_code"),
+ serviceCode = o.getString("service_code"),
+ data = o.optString("data", ""),
+ validUntil = validUntil
+ )
+ foundIndex = i
+ break
+ }
+
+ if (found == null || foundIndex < 0) {
+ prefs.edit().remove("tokens").apply()
+ return null
+ }
+
+ // Remove consumed token
+ val remaining = JSONArray()
+ for (i in 0 until arr.length()) {
+ if (i != foundIndex) remaining.put(arr.getJSONObject(i))
+ }
+ prefs.edit().putString("tokens", remaining.toString()).apply()
+ return found
+ }
+
+ fun remainingCount(): Int {
+ val raw = prefs.getString("tokens", null) ?: return 0
+ return try { JSONArray(raw).length() } catch (_: Exception) { 0 }
+ }
+
+ fun hasTokens(): Boolean = remainingCount() > 0
+
+ fun clear() {
+ prefs.edit().remove("tokens").apply()
+ }
+
+ /** "YYYY-MM-DD HH:mm:ss.SSS" → check if past now */
+ private fun isExpired(validUntil: String, nowMs: Long): Boolean = try {
+ val fmt = java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", java.util.Locale.US)
+ fmt.timeZone = java.util.TimeZone.getTimeZone("UTC")
+ val d = fmt.parse(validUntil) ?: return false
+ d.time < nowMs
+ } catch (_: Exception) { false }
+}
diff --git a/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt b/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt
index c0fdd29..bd82d90 100644
--- a/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt
+++ b/app/src/main/java/sh/sar/basedbank/ui/home/PayWithCardFragment.kt
@@ -24,12 +24,30 @@ import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.PagerSnapHelper
import androidx.recyclerview.widget.RecyclerView
+import android.animation.ValueAnimator
+import android.graphics.Canvas
+import android.graphics.Paint
+import android.graphics.RectF
+import android.view.Gravity
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricPrompt
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.lifecycleScope
+import com.google.android.material.button.MaterialButton
import com.google.android.material.color.MaterialColors
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import sh.sar.basedbank.BasedBankApp
import sh.sar.basedbank.R
+import sh.sar.basedbank.api.bml.BmlTapToPayClient
+import sh.sar.basedbank.nfc.BmlHostCardEmulatorService
+import sh.sar.basedbank.nfc.HceTokenStore
import sh.sar.basedbank.api.mib.MibCard
import sh.sar.basedbank.databinding.FragmentCardsBinding
import sh.sar.basedbank.util.CardsCache
import sh.sar.basedbank.util.CredentialStore
+import sh.sar.basedbank.util.Totp
import sh.sar.basedbank.util.bmlapi.BmlCardParser
import sh.sar.basedbank.util.PaymvQrParser
import kotlin.math.abs
@@ -45,6 +63,8 @@ class CardsFragment : Fragment() {
private var cardWidth: Int = 0
private var pendingQrAccountNumber: String? = null
private var isManageMode: Boolean = false
+ private var isTapMode: Boolean = false
+ private var tapAnimView: NfcTapAnimationView? = null
// Carousel snapshot captured on enter, used to reverse the exit animation
private var carouselCardLayoutTop = 0f // card layout top relative to contentLayout
@@ -136,7 +156,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
// Swipe-down on the manage card to dismiss manage mode
binding.manageCardView.root.setOnTouchListener { _, event ->
- if (!isManageMode) return@setOnTouchListener false
+ if (!isManageMode && !isTapMode) return@setOnTouchListener false
val mgr = binding.manageCardView.root
when (event.action) {
android.view.MotionEvent.ACTION_DOWN -> {
@@ -163,7 +183,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
val dy = (event.rawY - swipeDragStartRawY).coerceAtLeast(0f)
swipeIsDragging = false
if (dy > 130f) {
- setManageMode(false)
+ if (isTapMode) setTapMode(false) else setManageMode(false)
} else {
// Snap back
mgr.animate().translationY(0f).scaleX(1f).scaleY(1f)
@@ -192,8 +212,17 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
binding.btnTapToPay.isEnabled = nfcAvailable
binding.btnTapToPay.setOnClickListener {
val item = cards.getOrNull(currentCardPosition) ?: return@setOnClickListener
- val msg = if (item is CardItem.Mib) R.string.mib_qr_nfc_not_supported else R.string.work_in_progress
- Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show()
+ if (item is CardItem.Mib) {
+ Toast.makeText(requireContext(), R.string.mib_qr_nfc_not_supported, Toast.LENGTH_SHORT).show()
+ return@setOnClickListener
+ }
+ val bmlItem = item as CardItem.Bml
+ 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 {
@@ -378,6 +407,250 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
.start()
}
+ // ── Tap-to-pay mode ────────────────────────────────────────────────────────
+
+ private fun setTapMode(enabled: Boolean, item: CardItem.Bml? = null) {
+ isTapMode = enabled
+ requireActivity().title = getString(if (enabled) R.string.card_pay_nfc else R.string.nav_pay_with_card)
+ if (enabled) enterTapMode(item!!) else exitTapMode()
+ }
+
+ private fun showBiometricPromptForTap(item: CardItem.Bml) {
+ val bmgr = BiometricManager.from(requireContext())
+ if (bmgr.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) != BiometricManager.BIOMETRIC_SUCCESS) {
+ setTapMode(true, item)
+ return
+ }
+ val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(requireContext()),
+ object : BiometricPrompt.AuthenticationCallback() {
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ setTapMode(true, item)
+ }
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { }
+ })
+ prompt.authenticate(
+ BiometricPrompt.PromptInfo.Builder()
+ .setTitle(getString(R.string.card_pay_nfc))
+ .setSubtitle(item.account.accountBriefName)
+ .setNegativeButtonText(getString(R.string.cancel))
+ .build()
+ )
+ }
+
+ private fun enterTapMode(item: CardItem.Bml) {
+ // Bind card data to the shared manage card view
+ val cv = binding.manageCardView
+ cv.tvCardOwner.text = item.account.accountBriefName
+ cv.tvCardNumber.text = formatMasked(item.account.accountNumber)
+ loadCardImage(cv.ivCardImage, BmlCardParser.cardImageAsset(item.account))
+ val isActive = item.account.statusDesc.equals("Active", ignoreCase = true)
+ bindCardStatus(cv.tvCardStatus, item.account.statusDesc.takeUnless { isActive })
+ cv.root.alpha = if (isActive) 1f else 0.45f
+
+ // Snapshot carousel card position before layout changes (for animation)
+ val contentLoc = IntArray(2).also { binding.contentLayout.getLocationOnScreen(it) }
+ val lm = binding.rvCards.layoutManager as? LinearLayoutManager
+ val srcView = lm?.findViewByPosition(currentCardPosition)
+ val srcLoc = IntArray(2).also {
+ srcView?.getLocationOnScreen(it) ?: run { it[0] = contentLoc[0]; it[1] = contentLoc[1] }
+ }
+ val srcScreenTop = (srcLoc[1] - contentLoc[1]).toFloat()
+ val srcCenterX = (srcLoc[0] - contentLoc[0]).toFloat() + cardWidth / 2f
+ val textLoc = IntArray(2).also { binding.tvSelectedCardType.getLocationOnScreen(it) }
+ val textSrcScreenTop = (textLoc[1] - contentLoc[1]).toFloat()
+
+ carouselCardLayoutTop = srcScreenTop
+ carouselCardCenterX = srcCenterX
+ carouselTextLayoutTop = textSrcScreenTop
+
+ // Apply layout changes
+ binding.btnManageCard.visibility = View.GONE
+ binding.topSpacer.visibility = View.GONE
+ binding.rvCards.visibility = View.GONE
+ binding.pageIndicator.visibility = View.GONE
+ binding.divider.visibility = View.GONE
+ binding.llPayButtons.visibility = View.GONE
+ binding.llManageButtons.visibility = View.GONE
+ binding.llDefaultCardRow.visibility = View.GONE
+ binding.manageCardView.root.visibility = View.VISIBLE
+ binding.flTapMode.visibility = View.VISIBLE
+
+ // Build tap mode content: animation view + cancel button
+ binding.flTapMode.removeAllViews()
+ val animView = NfcTapAnimationView(requireContext())
+ tapAnimView = animView
+
+ val dp = resources.displayMetrics.density
+ val cancelBtn = 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
+ val tokenStore = HceTokenStore(requireContext())
+
+ viewLifecycleOwner.lifecycleScope.launch {
+ var token = withContext(Dispatchers.IO) { tokenStore.popToken() }
+
+ if (token == null) {
+ 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 fetched = result.getOrNull()
+ if (fetched.isNullOrEmpty()) {
+ if (isTapMode) {
+ Toast.makeText(requireContext(),
+ result.exceptionOrNull()?.message ?: "Failed to get payment token",
+ Toast.LENGTH_SHORT).show()
+ setTapMode(false)
+ }
+ return@launch
+ }
+ withContext(Dispatchers.IO) {
+ tokenStore.saveTokens(fetched)
+ token = tokenStore.popToken()
+ }
+ }
+
+ if (!isTapMode) return@launch // user cancelled while we were fetching
+
+ val t = token ?: run {
+ if (isTapMode) {
+ Toast.makeText(requireContext(), "No valid payment tokens", Toast.LENGTH_SHORT).show()
+ setTapMode(false)
+ }
+ return@launch
+ }
+
+ BmlHostCardEmulatorService.setToken(t)
+ 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)
@@ -433,7 +706,7 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
}
private fun buildDots(count: Int, selected: Int) {
- if (isManageMode) return
+ if (isManageMode || isTapMode) return
binding.pageIndicator.removeAllViews()
if (count <= 1) {
binding.pageIndicator.visibility = View.GONE
@@ -469,6 +742,10 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
}
fun onBackPressed(): Boolean {
+ if (isTapMode) {
+ setTapMode(false)
+ return true
+ }
if (isManageMode) {
setManageMode(false)
return true
@@ -476,12 +753,24 @@ ViewCompat.setOnApplyWindowInsetsListener(binding.contentLayout) { v, insets ->
return false
}
+ override fun onPause() {
+ super.onPause()
+ if (isTapMode) {
+ BmlHostCardEmulatorService.clearToken()
+ BmlHostCardEmulatorService.onTransactionComplete = null
+ }
+ }
+
override fun onResume() {
super.onResume()
requireActivity().title = getString(R.string.nav_pay_with_card)
}
override fun onDestroyView() {
+ tapAnimView?.stopAnimation()
+ tapAnimView = null
+ BmlHostCardEmulatorService.clearToken()
+ BmlHostCardEmulatorService.onTransactionComplete = null
super.onDestroyView()
_binding = null
}
@@ -543,6 +832,104 @@ 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 {
fun cardImageAsset(card: MibCard): String? = when (card.cardType) {
"51" -> "cards/mib/faisa_card.png"
diff --git a/app/src/main/res/layout/fragment_cards.xml b/app/src/main/res/layout/fragment_cards.xml
index 0cea026..3abfdcb 100644
--- a/app/src/main/res/layout/fragment_cards.xml
+++ b/app/src/main/res/layout/fragment_cards.xml
@@ -93,6 +93,7 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+