From 039f1c430abdc5f9fa492e07a375f6b0e5bcf2d1 Mon Sep 17 00:00:00 2001 From: rasputin Date: Fri, 5 Jun 2026 21:07:52 +0200 Subject: [PATCH] feat: cold start on scanning nfc tag --- app/src/main/AndroidManifest.xml | 20 ++++ .../bitcointxoko/gudariwallet/MainActivity.kt | 34 ++++-- .../gudariwallet/ui/WalletScreen.kt | 2 +- .../gudariwallet/ui/nfc/NfcViewModel.kt | 105 ++++++++++++------ 4 files changed, 116 insertions(+), 45 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 368cc91..d8c9a30 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,6 +44,26 @@ + + + + + + + + + + + + + + + + + + + + + try { f.addDataType("*/*") } catch (_: IntentFilter.MalformedMimeTypeException) {} + } + ) + val techLists = arrayOf(arrayOf(android.nfc.tech.Ndef::class.java.name)) nfcAdapter?.enableForegroundDispatch( this, nfcPendingIntent, - null, // intercept all intent filters - null // intercept all tech lists + intentFilters, + techLists ) } @@ -174,12 +186,20 @@ class MainActivity : AppCompatActivity() { } // NFC tag discovered → hand off to NfcViewModel for reading - val tag = intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) + val tag = intent.getNfcTag() if (tag != null) { nfcVm.onTagDiscovered(tag) } } + private fun Intent.getNfcTag(): Tag? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) + } else { + @Suppress("DEPRECATION") + getParcelableExtra(NfcAdapter.EXTRA_TAG) + } + // ── Permission / battery helpers (unchanged) ────────────────────────────── private fun requestNotificationPermissionIfNeeded() { diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt index a63406c..7a569cf 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt @@ -473,8 +473,8 @@ fun WalletScreen( modifier = Modifier .fillMaxWidth() .clickable { - nfcVm.dismissNfcOffer() vm.scan(offer.raw) + nfcVm.dismissNfcOffer() navController.navigate(TabItem.Send.route) { launchSingleTop = true } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NfcViewModel.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NfcViewModel.kt index 3ed09f8..ead6782 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NfcViewModel.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/nfc/NfcViewModel.kt @@ -1,22 +1,23 @@ package com.bitcointxoko.gudariwallet.ui.nfc -import android.content.ComponentName -import android.content.Context -import android.nfc.NfcAdapter +import android.content.Intent import android.nfc.NdefMessage import android.nfc.NdefRecord +import android.nfc.NfcAdapter import android.nfc.Tag import android.nfc.tech.IsoDep import android.nfc.tech.Ndef -import android.nfc.tech.NdefFormatable +import android.os.Build import android.util.Log import androidx.lifecycle.ViewModel -import com.bitcointxoko.gudariwallet.ui.nfc.NdefHceService +import androidx.lifecycle.viewModelScope import com.bitcointxoko.gudariwallet.ui.NfcOfferState import com.bitcointxoko.gudariwallet.util.SendInputDetector import com.bitcointxoko.gudariwallet.util.SendInputType +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch private const val TAG = "NfcViewModel" @@ -62,50 +63,80 @@ class NfcViewModel : ViewModel() { * recognisable payment URI. */ fun onTagDiscovered(tag: Tag) { - Log.d(TAG, "onTagDiscovered: ${tag.id?.toHex()}") - - val text = readNdefText(tag) ?: run { - Log.w(TAG, "Could not read NDEF from tag") - return + Log.d(TAG, "onTagDiscovered: ${tag.id.joinToString("") { "%02X".format(it) }}") + viewModelScope.launch(Dispatchers.IO) { // ← move to IO thread + readNdefText(tag) } - - Log.d(TAG, "Tag NDEF text: $text") - val trimmed = text.trim() - val type = SendInputDetector.detect(trimmed) - if (type == SendInputType.Unknown) { - Log.d(TAG, "Tag content not a recognised payment URI") - return - } - - _nfcOfferState.value = NfcOfferState.Detected(raw = trimmed, inputType = type) } + /** Cold-start path: Android already parsed the NDEF message into the intent. + * No tag connection needed — avoids "Tag is out of date" SecurityException. */ + fun onNfcIntent(intent: Intent) { + if (intent.action != NfcAdapter.ACTION_NDEF_DISCOVERED && + intent.action != NfcAdapter.ACTION_TAG_DISCOVERED) return + + // Android attaches the parsed NDEF messages directly to the intent + val rawMessages = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES, NdefMessage::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableArrayExtra(NfcAdapter.EXTRA_NDEF_MESSAGES) + } + + if (rawMessages != null && rawMessages.isNotEmpty()) { + val message = rawMessages[0] as NdefMessage + val uri = extractUri(message) + if (uri != null) { + Log.d(TAG, "onNfcIntent (cold-start): uri=$uri") + _nfcOfferState.value = NfcOfferState.Detected(uri, SendInputDetector.detect(uri)) + return + } + } + + // Fallback: no NDEF messages in intent, try connecting to tag + val tag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(NfcAdapter.EXTRA_TAG, Tag::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(NfcAdapter.EXTRA_TAG) + } + if (tag != null) { + viewModelScope.launch(Dispatchers.IO) { + readNdefText(tag) + } + } + } + + fun dismissNfcOffer() { _nfcOfferState.value = NfcOfferState.None } // ── NDEF reading ───────────────────────────────────────────────────────── - private fun readNdefText(tag: Tag): String? { - // Try standard NDEF first (covers Type 1–5 tags, NTAG21x, etc.) - Ndef.get(tag)?.use { ndef -> - ndef.connect() - val msg = ndef.ndefMessage ?: ndef.cachedNdefMessage ?: return null - return extractUri(msg) + private fun readNdefText(tag: Tag) { + val uri = try { + Ndef.get(tag)?.use { ndef -> + ndef.connect() + val msg = ndef.ndefMessage ?: ndef.cachedNdefMessage ?: return + extractUri(msg) + } ?: IsoDep.get(tag)?.use { isoDep -> + isoDep.connect() + isoDep.timeout = 3000 + val msg = readNdefFromIsoDep(isoDep) ?: return + extractUri(msg) + } + } catch (e: Exception) { + Log.e(TAG, "readNdefText error: ${e.message}") + null } - - // Try NdefFormatable (blank tags — nothing to read) - // Try IsoDep last (Type 4 / ISO-DEP tags like Bolt Cards) - IsoDep.get(tag)?.use { isoDep -> - isoDep.connect() - isoDep.timeout = 3000 - val msg = readNdefFromIsoDep(isoDep) ?: return null - return extractUri(msg) + if (uri != null) { + Log.d(TAG, "readNdefText: uri=$uri") + _nfcOfferState.value = NfcOfferState.Detected(uri, SendInputDetector.detect(uri)) + } else { + Log.w(TAG, "readNdefText: no URI found in tag") } - - return null } - /** * Reads an NDEF message from an ISO-DEP (Type 4) tag by replaying the * same APDU sequence our HCE service responds to.