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="lnurl" />
</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
@@ -4,6 +4,7 @@ import android.Manifest
import android.app.PendingIntent
import android.content.ActivityNotFoundException
import android.content.Intent
import android.content.IntentFilter
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.os.Build
@@ -54,8 +55,12 @@ class MainActivity : AppCompatActivity() {
private val nfcPendingIntent: PendingIntent by lazy {
PendingIntent.getActivity(
this, 0,
Intent(this, javaClass).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_MUTABLE
Intent(this, javaClass).apply {
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)
nfcAdapter = NfcAdapter.getDefaultAdapter(this)
nfcVm.onNfcIntent(intent)
enableEdgeToEdge()
setContent {
@@ -137,13 +144,18 @@ class MainActivity : AppCompatActivity() {
override fun onResume() {
super.onResume()
// Give this activity first pick of all NFC tag intents while foregrounded.
// Passing null for techLists and intentFilters means we intercept every tag type.
// Restrict to NDEF only — avoids TAG_DISCOVERED BAL blocks for non-NDEF tags
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(
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<Tag>(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() {
@@ -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
}
@@ -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,23 +63,50 @@ class NfcViewModel : ViewModel() {
* recognisable payment URI.
*/
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 {
Log.w(TAG, "Could not read NDEF from tag")
/** 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
}
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() {
_nfcOfferState.value = NfcOfferState.None
@@ -86,26 +114,29 @@ class NfcViewModel : ViewModel() {
// ── NDEF reading ─────────────────────────────────────────────────────────
private fun readNdefText(tag: Tag): String? {
// Try standard NDEF first (covers Type 15 tags, NTAG21x, etc.)
private fun readNdefText(tag: Tag) {
val uri = try {
Ndef.get(tag)?.use { ndef ->
ndef.connect()
val msg = ndef.ndefMessage ?: ndef.cachedNdefMessage ?: return null
return extractUri(msg)
}
// Try NdefFormatable (blank tags — nothing to read)
// Try IsoDep last (Type 4 / ISO-DEP tags like Bolt Cards)
IsoDep.get(tag)?.use { isoDep ->
val msg = ndef.ndefMessage ?: ndef.cachedNdefMessage ?: return
extractUri(msg)
} ?: IsoDep.get(tag)?.use { isoDep ->
isoDep.connect()
isoDep.timeout = 3000
val msg = readNdefFromIsoDep(isoDep) ?: return null
return extractUri(msg)
val msg = readNdefFromIsoDep(isoDep) ?: return
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
* same APDU sequence our HCE service responds to.