feat: cold start on scanning nfc tag

This commit is contained in:
2026-06-05 21:07:52 +02:00
parent f53e512edf
commit 039f1c430a
4 changed files with 116 additions and 45 deletions
+20
View File
@@ -44,6 +44,26 @@
<data android:scheme="lnurlc" /> <data android:scheme="lnurlc" />
<data android:scheme="lnurl" /> <data android:scheme="lnurl" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="lightning" />
</intent-filter>
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="lnurl" />
</intent-filter>
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="bitcoin" />
</intent-filter>
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity> </activity>
<activity <activity
@@ -4,6 +4,7 @@ import android.Manifest
import android.app.PendingIntent import android.app.PendingIntent
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.content.IntentFilter
import android.nfc.NfcAdapter import android.nfc.NfcAdapter
import android.nfc.Tag import android.nfc.Tag
import android.os.Build import android.os.Build
@@ -54,8 +55,12 @@ class MainActivity : AppCompatActivity() {
private val nfcPendingIntent: PendingIntent by lazy { private val nfcPendingIntent: PendingIntent by lazy {
PendingIntent.getActivity( PendingIntent.getActivity(
this, 0, this, 0,
Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), Intent(this, javaClass).apply {
PendingIntent.FLAG_MUTABLE addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
// Do NOT add FLAG_ACTIVITY_NEW_TASK — this causes
// balDontBringExistingBackgroundTaskStackToFg = true
},
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
) )
} }
@@ -102,6 +107,8 @@ class MainActivity : AppCompatActivity() {
// Initialise NFC adapter (null on devices without NFC — handled gracefully) // Initialise NFC adapter (null on devices without NFC — handled gracefully)
nfcAdapter = NfcAdapter.getDefaultAdapter(this) nfcAdapter = NfcAdapter.getDefaultAdapter(this)
nfcVm.onNfcIntent(intent)
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
@@ -137,13 +144,18 @@ class MainActivity : AppCompatActivity() {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
// Give this activity first pick of all NFC tag intents while foregrounded. // Restrict to NDEF only — avoids TAG_DISCOVERED BAL blocks for non-NDEF tags
// Passing null for techLists and intentFilters means we intercept every tag type. val intentFilters = arrayOf(
IntentFilter(NfcAdapter.ACTION_NDEF_DISCOVERED).also { f ->
try { f.addDataType("*/*") } catch (_: IntentFilter.MalformedMimeTypeException) {}
}
)
val techLists = arrayOf(arrayOf(android.nfc.tech.Ndef::class.java.name))
nfcAdapter?.enableForegroundDispatch( nfcAdapter?.enableForegroundDispatch(
this, this,
nfcPendingIntent, nfcPendingIntent,
null, // intercept all intent filters intentFilters,
null // intercept all tech lists techLists
) )
} }
@@ -174,12 +186,20 @@ class MainActivity : AppCompatActivity() {
} }
// NFC tag discovered → hand off to NfcViewModel for reading // NFC tag discovered → hand off to NfcViewModel for reading
val tag = intent.getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG) val tag = intent.getNfcTag()
if (tag != null) { if (tag != null) {
nfcVm.onTagDiscovered(tag) 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) ────────────────────────────── // ── Permission / battery helpers (unchanged) ──────────────────────────────
private fun requestNotificationPermissionIfNeeded() { private fun requestNotificationPermissionIfNeeded() {
@@ -473,8 +473,8 @@ fun WalletScreen(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { .clickable {
nfcVm.dismissNfcOffer()
vm.scan(offer.raw) vm.scan(offer.raw)
nfcVm.dismissNfcOffer()
navController.navigate(TabItem.Send.route) { navController.navigate(TabItem.Send.route) {
launchSingleTop = true launchSingleTop = true
} }
@@ -1,22 +1,23 @@
package com.bitcointxoko.gudariwallet.ui.nfc package com.bitcointxoko.gudariwallet.ui.nfc
import android.content.ComponentName import android.content.Intent
import android.content.Context
import android.nfc.NfcAdapter
import android.nfc.NdefMessage import android.nfc.NdefMessage
import android.nfc.NdefRecord import android.nfc.NdefRecord
import android.nfc.NfcAdapter
import android.nfc.Tag import android.nfc.Tag
import android.nfc.tech.IsoDep import android.nfc.tech.IsoDep
import android.nfc.tech.Ndef import android.nfc.tech.Ndef
import android.nfc.tech.NdefFormatable import android.os.Build
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel 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.ui.NfcOfferState
import com.bitcointxoko.gudariwallet.util.SendInputDetector import com.bitcointxoko.gudariwallet.util.SendInputDetector
import com.bitcointxoko.gudariwallet.util.SendInputType import com.bitcointxoko.gudariwallet.util.SendInputType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
private const val TAG = "NfcViewModel" private const val TAG = "NfcViewModel"
@@ -62,23 +63,50 @@ class NfcViewModel : ViewModel() {
* recognisable payment URI. * recognisable payment URI.
*/ */
fun onTagDiscovered(tag: Tag) { fun onTagDiscovered(tag: Tag) {
Log.d(TAG, "onTagDiscovered: ${tag.id?.toHex()}") Log.d(TAG, "onTagDiscovered: ${tag.id.joinToString("") { "%02X".format(it) }}")
viewModelScope.launch(Dispatchers.IO) { // ← move to IO thread
readNdefText(tag)
}
}
val text = readNdefText(tag) ?: run { /** Cold-start path: Android already parsed the NDEF message into the intent.
Log.w(TAG, "Could not read NDEF from tag") * 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 return
} }
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) // 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<Tag>(NfcAdapter.EXTRA_TAG)
} }
if (tag != null) {
viewModelScope.launch(Dispatchers.IO) {
readNdefText(tag)
}
}
}
fun dismissNfcOffer() { fun dismissNfcOffer() {
_nfcOfferState.value = NfcOfferState.None _nfcOfferState.value = NfcOfferState.None
@@ -86,26 +114,29 @@ class NfcViewModel : ViewModel() {
// ── NDEF reading ───────────────────────────────────────────────────────── // ── NDEF reading ─────────────────────────────────────────────────────────
private fun readNdefText(tag: Tag): String? { private fun readNdefText(tag: Tag) {
// Try standard NDEF first (covers Type 15 tags, NTAG21x, etc.) val uri = try {
Ndef.get(tag)?.use { ndef -> Ndef.get(tag)?.use { ndef ->
ndef.connect() ndef.connect()
val msg = ndef.ndefMessage ?: ndef.cachedNdefMessage ?: return null val msg = ndef.ndefMessage ?: ndef.cachedNdefMessage ?: return
return extractUri(msg) extractUri(msg)
} } ?: IsoDep.get(tag)?.use { isoDep ->
// 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.connect()
isoDep.timeout = 3000 isoDep.timeout = 3000
val msg = readNdefFromIsoDep(isoDep) ?: return null val msg = readNdefFromIsoDep(isoDep) ?: return
return extractUri(msg) extractUri(msg)
}
} catch (e: Exception) {
Log.e(TAG, "readNdefText error: ${e.message}")
null
}
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 * Reads an NDEF message from an ISO-DEP (Type 4) tag by replaying the
* same APDU sequence our HCE service responds to. * same APDU sequence our HCE service responds to.