From 2df162c09e48e941419fe344dc7db667686e699f Mon Sep 17 00:00:00 2001 From: Shihaam Abdul Rahman Date: Fri, 29 May 2026 15:58:05 +0500 Subject: [PATCH] tap-to-pay part 3: default wallet and shortcut --- app/src/main/AndroidManifest.xml | 6 + .../java/sh/sar/basedbank/MainActivity.kt | 8 + .../nfc/BmlHostCardEmulatorService.kt | 31 ++-- .../sar/basedbank/nfc/BmlTapToPayActivity.kt | 167 ++++++++++++++++++ app/src/main/res/xml/bml_aid_list.xml | 10 +- app/src/main/res/xml/shortcuts.xml | 10 +- 6 files changed, 210 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/sh/sar/basedbank/nfc/BmlTapToPayActivity.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1ed2f1e..4eb8478 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -62,6 +62,12 @@ android:exported="false" android:screenOrientation="portrait" /> + + R.id.nav_transfer "sh.sar.basedbank.OPEN_SCAN_QR" -> R.id.nav_transfer diff --git a/app/src/main/java/sh/sar/basedbank/nfc/BmlHostCardEmulatorService.kt b/app/src/main/java/sh/sar/basedbank/nfc/BmlHostCardEmulatorService.kt index 7631510..6870ba7 100644 --- a/app/src/main/java/sh/sar/basedbank/nfc/BmlHostCardEmulatorService.kt +++ b/app/src/main/java/sh/sar/basedbank/nfc/BmlHostCardEmulatorService.kt @@ -1,5 +1,6 @@ package sh.sar.basedbank.nfc +import android.content.Intent import android.nfc.cardemulation.HostApduService import android.os.Bundle import android.util.Log @@ -40,21 +41,25 @@ class BmlHostCardEmulatorService : HostApduService() { 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 + if (data.contentEquals(PPSE_BYTES)) { + val token = activeToken ?: run { launchPromptActivity(); return SW_UNKNOWN_ERROR } + return hexToBytes(buildSelectPpseResponse(token.appCode, applicationLabel(token.appCode), "01")) } + + val token = activeToken ?: return SW_UNKNOWN_ERROR + return if (data.contentEquals(hexToBytes(token.appCode))) { + hexToBytes(buildSelectAidResponse(token.appCode, applicationLabel(token.appCode))) + } else { + SW_UNKNOWN_ERROR + } + } + + private fun launchPromptActivity() { + val intent = Intent(applicationContext, BmlTapToPayActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + startActivity(intent) } private fun handleGpo(): ByteArray { diff --git a/app/src/main/java/sh/sar/basedbank/nfc/BmlTapToPayActivity.kt b/app/src/main/java/sh/sar/basedbank/nfc/BmlTapToPayActivity.kt new file mode 100644 index 0000000..bc30f70 --- /dev/null +++ b/app/src/main/java/sh/sar/basedbank/nfc/BmlTapToPayActivity.kt @@ -0,0 +1,167 @@ +package sh.sar.basedbank.nfc + +import android.content.Intent +import android.os.Bundle +import android.view.Gravity +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import com.google.android.material.button.MaterialButton +import com.google.android.material.color.MaterialColors +import sh.sar.basedbank.R + +/** + * Launched by [BmlHostCardEmulatorService] when the phone is tapped against a POS terminal + * but no payment token is armed. Handles biometric auth and token loading from the local store, + * then arms the service so the user can tap again to complete the payment. + */ +class BmlTapToPayActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + prepare() + } + + // singleTop: if already showing, just ignore the re-launch + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + } + + private fun prepare() { + val store = HceTokenStore(this) + if (!store.hasTokens()) { + Toast.makeText(this, + "Open the app and tap Tap to Pay first to load payment tokens", + Toast.LENGTH_LONG).show() + finish() + return + } + + val prefs = getSharedPreferences("prefs", MODE_PRIVATE) + val biometricEnabled = prefs.getBoolean("biometrics_transfer_confirm", false) + + if (biometricEnabled) { + val bmgr = BiometricManager.from(this) + if (bmgr.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS) { + showBiometricPrompt(store) + return + } + } + armAndShowReady(store) + } + + private fun showBiometricPrompt(store: HceTokenStore) { + val prompt = BiometricPrompt(this, ContextCompat.getMainExecutor(this), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + armAndShowReady(store) + } + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + finish() + } + }) + prompt.authenticate( + BiometricPrompt.PromptInfo.Builder() + .setTitle(getString(R.string.card_pay_nfc)) + .setSubtitle(getString(R.string.app_name)) + .setNegativeButtonText(getString(R.string.cancel)) + .build() + ) + } + + private fun armAndShowReady(store: HceTokenStore) { + val token = store.popToken() ?: run { + Toast.makeText(this, + "No valid payment tokens — please open the app", + Toast.LENGTH_LONG).show() + finish() + return + } + + BmlHostCardEmulatorService.setToken(token) + BmlHostCardEmulatorService.onTransactionComplete = { success -> + runOnUiThread { + BmlHostCardEmulatorService.clearToken() + BmlHostCardEmulatorService.onTransactionComplete = null + if (success) Toast.makeText(this, "Payment complete", Toast.LENGTH_SHORT).show() + finish() + } + } + showReadyLayout() + } + + private fun showReadyLayout() { + val dp = resources.displayMetrics.density + val colorSurface = MaterialColors.getColor(window.decorView, + com.google.android.material.R.attr.colorSurface, 0xFF1C1B1F.toInt()) + val colorOnSurface = MaterialColors.getColor(window.decorView, + com.google.android.material.R.attr.colorOnSurface, 0xFFFFFFFF.toInt()) + val colorOnSurfaceVariant = MaterialColors.getColor(window.decorView, + com.google.android.material.R.attr.colorOnSurfaceVariant, 0xFFCAC4D0.toInt()) + + val root = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setBackgroundColor(colorSurface) + setPadding((32 * dp).toInt(), (64 * dp).toInt(), (32 * dp).toInt(), (64 * dp).toInt()) + } + + root.addView(ImageView(this).apply { + setImageResource(R.drawable.ic_nfc) + setColorFilter(colorOnSurface) + layoutParams = LinearLayout.LayoutParams((72 * dp).toInt(), (72 * dp).toInt()).apply { + gravity = Gravity.CENTER_HORIZONTAL + bottomMargin = (32 * dp).toInt() + } + }) + + root.addView(TextView(this).apply { + text = getString(R.string.card_pay_nfc) + textSize = 22f + gravity = Gravity.CENTER + setTextColor(colorOnSurface) + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { bottomMargin = (8 * dp).toInt() } + }) + + root.addView(TextView(this).apply { + text = "Hold your phone near the payment terminal" + textSize = 14f + gravity = Gravity.CENTER + setTextColor(colorOnSurfaceVariant) + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { bottomMargin = (48 * dp).toInt() } + }) + + root.addView(MaterialButton(this, null, + com.google.android.material.R.attr.materialButtonOutlinedStyle + ).apply { + text = getString(R.string.cancel) + setOnClickListener { + BmlHostCardEmulatorService.clearToken() + BmlHostCardEmulatorService.onTransactionComplete = null + finish() + } + }) + + setContentView(root) + } + + override fun onDestroy() { + super.onDestroy() + // If the activity is killed without completing, clean up the armed token + if (isFinishing && BmlHostCardEmulatorService.activeToken != null) { + BmlHostCardEmulatorService.clearToken() + BmlHostCardEmulatorService.onTransactionComplete = null + } + } +} diff --git a/app/src/main/res/xml/bml_aid_list.xml b/app/src/main/res/xml/bml_aid_list.xml index fd56b9f..86ae6f8 100644 --- a/app/src/main/res/xml/bml_aid_list.xml +++ b/app/src/main/res/xml/bml_aid_list.xml @@ -1,8 +1,10 @@ - + - - @@ -19,4 +21,4 @@ - + diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml index c8d5579..7c4bba7 100644 --- a/app/src/main/res/xml/shortcuts.xml +++ b/app/src/main/res/xml/shortcuts.xml @@ -28,13 +28,13 @@ + android:icon="@drawable/ic_nfc" + android:shortcutShortLabel="@string/card_pay_nfc" + android:shortcutLongLabel="@string/card_pay_nfc">