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">