tap-to-pay part 1
Auto Tag on Version Change / check-version (push) Failing after 14m38s

This commit is contained in:
2026-05-29 15:43:13 +05:00
parent 71e893faf8
commit 0f77216d2d
9 changed files with 813 additions and 5 deletions
+3
View File
@@ -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)
+16
View File
@@ -7,6 +7,9 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.NFC" />
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
<application
android:name=".BasedBankApp"
@@ -59,6 +62,19 @@
android:exported="false"
android:screenOrientation="portrait" />
<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
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -82,6 +82,15 @@ data class BmlQrPayResult(
val errorMessage: String = ""
)
data class BmlWalletToken(
val token: String,
val expiry: String,
val appCode: String, // AID hex, e.g. "A0000000031010"
val serviceCode: String,
val data: String,
val validUntil: String // "YYYY-MM-DD HH:mm:ss.SSS"
)
data class BmlForeignLimit(
val type: String,
val used: Double,
@@ -0,0 +1,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,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"
}
}
}
@@ -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<BmlWalletToken>) {
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 }
}
@@ -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"
@@ -93,6 +93,7 @@
<!-- Divider -->
<View
android:id="@+id/divider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginHorizontal="24dp"
@@ -242,6 +243,13 @@
</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 -->
<LinearLayout
android:id="@+id/loadingView"
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<aid-filter xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Contactless PPSE directory -->
<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>
</aid-filter>