From 0f77216d2d18de97675c1ed92f72a1922f21d13e Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Fri, 29 May 2026 15:43:13 +0500 Subject: [PATCH] tap-to-pay part 1 --- app/build.gradle.kts | 3 + app/src/main/AndroidManifest.xml | 16 + .../sh/sar/basedbank/api/bml/BmlModels.kt | 9 + .../basedbank/api/bml/BmlTapToPayClient.kt | 79 ++++ .../nfc/BmlHostCardEmulatorService.kt | 179 ++++++++ .../sh/sar/basedbank/nfc/HceTokenStore.kt | 105 +++++ .../basedbank/ui/home/PayWithCardFragment.kt | 397 +++++++++++++++++- app/src/main/res/layout/fragment_cards.xml | 8 + app/src/main/res/xml/bml_aid_list.xml | 22 + 9 files changed, 813 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/sh/sar/basedbank/api/bml/BmlTapToPayClient.kt create mode 100644 app/src/main/java/sh/sar/basedbank/nfc/BmlHostCardEmulatorService.kt create mode 100644 app/src/main/java/sh/sar/basedbank/nfc/HceTokenStore.kt create mode 100644 app/src/main/res/xml/bml_aid_list.xml 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 @@ + + + + + + + + + + + + + + + + + + + + + + +