NFC cold and hot start support

This commit is contained in:
minibits-cash
2026-06-01 16:33:53 +02:00
parent a35168f945
commit 082d9ee18e
9 changed files with 320 additions and 186 deletions
+1 -1
View File
@@ -95,7 +95,7 @@ android {
applicationId "com.minibits_wallet"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 58073
versionCode 58075
versionName "0.4.3"
}
+109 -160
View File
@@ -4,163 +4,112 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<uses-permission android:name="android.permission.NFC" />
<uses-feature android:name="android.hardware.nfc.hce" android:required="false" />
<uses-feature android:name="android.hardware.nfc" android:required="false" />
<application
android:name=".MainApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:allowBackup="false"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
android:supportsRtl="true"
>
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
</intent-filter>
</activity>
<!-- Default launcher icon alias (enabled by default) -->
<activity-alias
android:name=".MainActivityDefault"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:targetActivity=".MainActivity"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="lightning" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="lnurlw" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="lnurlp" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="cashu" />
</intent-filter>
<!-- NFC tag/peer dispatch (e.g. tapping another wallet to receive a lightning: URI).
Without these, an installed wallet that declares NDEF_DISCOVERED wins the NFC
chooser and Minibits is excluded; the VIEW filters above only catch the
ACTION_VIEW fallback that fires when no app claims NDEF_DISCOVERED. -->
<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="lnurlw" />
</intent-filter>
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="lnurlp" />
</intent-filter>
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="cashu" />
</intent-filter>
</activity-alias>
<!-- Golden theme launcher icon alias (disabled by default) -->
<activity-alias
android:name=".MainActivityGolden"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher_golden"
android:targetActivity=".MainActivity"
android:enabled="false"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="lightning" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="lnurlw" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="lnurlp" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="cashu" />
</intent-filter>
<!-- NFC tag/peer dispatch (mirror of .MainActivityDefault) -->
<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="lnurlw" />
</intent-filter>
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="lnurlp" />
</intent-filter>
<intent-filter>
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="cashu" />
</intent-filter>
</activity-alias>
<service
android:name="com.reactnativehce.services.CardService"
android:exported="true"
android:enabled="false"
android:permission="android.permission.BIND_NFC_SERVICE" >
<intent-filter>
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE" />
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
<meta-data
android:name="android.nfc.cardemulation.host_apdu_service"
android:resource="@xml/aid_list" />
</service>
</application>
</manifest>
<uses-permission android:name="android.permission.NFC"/>
<uses-feature android:name="android.hardware.nfc.hce" android:required="false"/>
<uses-feature android:name="android.hardware.nfc" android:required="false"/>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:allowBackup="false" android:theme="@style/AppTheme" android:usesCleartextTraffic="true" android:supportsRtl="true">
<activity android:name=".MainActivity" android:label="@string/app_name" android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
</intent-filter>
</activity>
<activity-alias android:name=".MainActivityDefault" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:targetActivity=".MainActivity" android:enabled="true" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="lightning"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="lnurlw"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="lnurlp"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="cashu"/>
</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>
<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="lnurlw"/>
</intent-filter>
</activity-alias>
<activity-alias android:name=".MainActivityGolden" android:label="@string/app_name" android:icon="@mipmap/ic_launcher_golden" android:targetActivity=".MainActivity" android:enabled="false" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="lightning"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="lnurlw"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="lnurlp"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="cashu"/>
</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>
<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="lnurlw"/>
</intent-filter>
</activity-alias>
<service android:name="com.reactnativehce.services.CardService" android:exported="true" android:enabled="false" android:permission="android.permission.BIND_NFC_SERVICE">
<intent-filter>
<action android:name="android.nfc.cardemulation.action.HOST_APDU_SERVICE"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
<meta-data android:name="android.nfc.cardemulation.host_apdu_service" android:resource="@xml/aid_list"/>
</service>
<meta-data android:name="com.hotupdater.FINGERPRINT_HASH" android:value="87f7f5e2ff3ace8ca5c2fd2ab6df5d0b7b513ed8"/>
</application>
</manifest>
@@ -1,6 +1,7 @@
package com.minibits_wallet
// minibits_wallet:react-native-screens
import android.content.Intent
import android.os.Bundle
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
@@ -26,4 +27,16 @@ open class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(null)
}
/**
* This activity is launchMode="singleTask", so when an NFC tap (or deep link) arrives while
* the app is already running it is delivered here via onNewIntent rather than a fresh start.
* super.onNewIntent forwards the intent to React Native's ActivityEventListeners (which the
* NFC manager and Linking module use), and setIntent updates getIntent() so launch-intent
* reads (getLaunchTagEvent / Linking.getInitialURL) reflect the new intent on warm resume.
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
}
}
@@ -1,4 +1,3 @@
<resources>
<string name="app_name">Minibits</string>
<string name="hot_updater_fingerprint_hash" moduleConfig="true">e9f51a75d88d5727c549de0e6250f351feb1e127</string>
</resources>
+8 -8
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -89,7 +89,7 @@
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>HOT_UPDATER_FINGERPRINT_HASH</key>
<string>e9f51a75d88d5727c549de0e6250f351feb1e127</string>
<string>87f7f5e2ff3ace8ca5c2fd2ab6df5d0b7b513ed8</string>
<key>CFBundleIcons</key>
<dict>
<key>CFBundlePrimaryIcon</key>
+61 -6
View File
@@ -48,6 +48,10 @@ import FastImage from 'react-native-fast-image'
type Props = StaticScreenProps<{
unit: MintUnit
// When the screen is opened by an incoming NFC dispatch (cold/warm app launch via the
// OS NFC chooser), the tag has ALREADY been read by the OS; we pass the decoded text
// here and process it directly instead of arming the reader for a live tap.
incomingData?: string
}>
const SYNC_STATE_WITH_MINT_TIMEOUT = 10 * 1000
@@ -57,6 +61,11 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
const { mintsStore, walletStore, proofsStore, transactionsStore, walletProfileStore } = useStores()
const unitRef = useRef<MintUnit>('sat')
const isOnline = useRef<boolean>(true)
// While true, the reader-mode session is re-armed after each read so the NEXT tap is
// captured by our exclusive reader-mode session instead of falling through to the OS
// "Complete action using…" chooser / other installed wallets. Turned off once a tag is
// actually being processed and on unmount.
const keepScanningRef = useRef<boolean>(false)
const isInternetReachable = useIsInternetReachable()
@@ -99,11 +108,36 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
log.trace({isInternetReachable})
const balance = proofsStore.getMintBalanceWithMaxBalance(unitRef.current)
if (balance) {
setMintBalanceToSendFrom(balance)
}
// Opened by an incoming NFC dispatch (cold/warm launch via the OS chooser):
// the tag was already read by the OS, so process the decoded data directly
// and skip arming the live reader.
const incomingData = route.params?.incomingData
if (incomingData) {
keepScanningRef.current = false
setIsProcessing(true)
try {
await handleIncomingData(incomingData)
} catch (e: any) {
log.error(e.message, { params: e.params || {} })
setResultModalInfo({
status: TransactionStatus.ERROR,
title: e.params?.message ? e.message : 'NFC Payment failed',
message: e.params?.message || e.message || 'Unknown error during NFC payment processing.',
})
setNfcInfo(e.message)
setIsError(true)
toggleResultModal()
} finally {
setIsProcessing(false)
}
return
}
/*setNfcInfo('Hold your device close to the NFC reader.')
setIsNfcEnabled(true)
return
@@ -125,6 +159,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
setNfcInfo('Hold your device close to the NFC reader.')
setIsNfcEnabled(true)
keepScanningRef.current = true
// Start listening for tags immediately
startNfcSession()
@@ -138,6 +173,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
initNfc()
return () => {
keepScanningRef.current = false
NfcManager.cancelTechnologyRequest().catch(() => {})
NfcManager.setEventListener(NfcEvents.DiscoverTag, null)
NfcManager.setEventListener(NfcEvents.SessionClosed, null)
@@ -153,6 +189,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
useEffect(() => {
const unsubscribe = navigation.addListener('focus', () => {
if (isNfcEnabled && !isProcessing) {
keepScanningRef.current = true
startNfcSession()
}
})
@@ -326,6 +363,11 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
const startNfcSession = async () => {
let decoded: string | undefined = undefined;
// Whether this session read a real tag and handed off to the payment flow. When it
// did, we must NOT re-arm the reader (the result/processing UI owns the screen now).
// When it didn't (empty read, read error), we re-arm so a retry tap is captured by
// our reader-mode session rather than the OS chooser.
let didStartProcessing = false
try {
log.debug('[startNfcSession] Starting NFC session for payment request reading...')
@@ -335,6 +377,8 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
return
}
didStartProcessing = true
keepScanningRef.current = false
setIsProcessing(true)
//log.info('NFC Tag found', {tag});
@@ -369,8 +413,20 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
setIsError(true)
toggleResultModal()
} finally {
await NfcManager.cancelTechnologyRequest().catch(() => {})
await NfcManager.cancelTechnologyRequest().catch(() => {})
setIsProcessing(false)
// Re-arm the reader for the next tap unless we handed off to the payment flow
// or the screen is gone. cancelTechnologyRequest() above tears down reader mode,
// so without this the next tap would fall through to the OS chooser again.
// The short delay lets reader mode tear down cleanly and throttles re-arming in
// case the NFC stack is transiently erroring.
if (keepScanningRef.current && !didStartProcessing) {
await new Promise(r => setTimeout(r, 300))
if (keepScanningRef.current && !didStartProcessing) {
startNfcSession()
}
}
}
}
@@ -1142,9 +1198,6 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
</View>
</View>
</View>
{info && <InfoModal message={info} />}
{error && <ErrorModal error={error} />}
<BottomModal
isVisible={isResultModalVisible}
ContentComponent={
@@ -1229,6 +1282,8 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
onBackButtonPress={toggleResultModal}
onBackdropPress={toggleResultModal}
/>
{info && <InfoModal message={info} />}
{error && <ErrorModal error={error} />}
</Screen>
)
})
@@ -1285,7 +1340,7 @@ export const PulsingContactlessIcon: React.FC<PulsingContactlessIconProps> = ({
const $screen: ViewStyle = { }
const $contentContainer: ViewStyle = { flex: 1, padding: spacing.extraSmall }
const $headerContainer: TextStyle = { alignItems: 'center', paddingVertical: spacing.medium,}
const $buttonContainer: ViewStyle = { flex: 1, marginTop: spacing.large, flexDirection: 'row', alignSelf: 'center' }
const $buttonContainer: ViewStyle = { flexDirection: 'row', alignSelf: 'center' }
const $bottomContainer: ViewStyle = {
position: 'absolute',
bottom: 0,
+34 -8
View File
@@ -45,6 +45,7 @@ import {
MINIBITS_MINT_URL,
} from '@env'
import { IncomingParser } from '../services/incomingParser'
import { NfcService } from '../services/nfcService'
import useIsInternetReachable from '../utils/useIsInternetReachable'
import { CurrencySign } from './Wallet/CurrencySign'
import { CurrencyCode, MintUnit, MintUnitCurrencyPairs, convertToFromSats, getCurrency } from "../services/wallet/currency"
@@ -166,11 +167,21 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
// On app start
useEffect(() => {
const getInitialData = async () => {
// If the app was COLD-launched by an NFC tap (e.g. another wallet's HCE share,
// with Minibits picked in the OS chooser), route the decoded tag to the NFC pay
// screen. getLaunchNdefText returns undefined for non-NFC launches, so a normal
// deep link falls through to Linking below.
const nfcText = await NfcService.getLaunchNdefText()
if (nfcText) {
handleNfcData(nfcText)
return
}
// get deeplink data if any
const url = await Linking.getInitialURL()
if (url) {
handleDeeplink({url})
if (url) {
handleDeeplink({url})
return // skip further processing so that it does not slow down or clash deep link
}
@@ -220,18 +231,23 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
}
Linking.addEventListener('url', handleDeeplink)
// WARM resume: app backgrounded, then brought to foreground by an NFC tap. The tag
// arrives via the DiscoverBackgroundTag event (no data URI for text records, so
// Linking would miss it); route it to the NFC pay screen like the cold-start path.
NfcService.setBackgroundTagListener(handleNfcData)
EventEmitter.on(`ev_${HANDLE_RECEIVED_EVENT_TASK}_result`, handleReceivedEventTaskResult)
EventEmitter.on(`ev_${HANDLE_CLAIM_TASK}_result`, handleClaimTaskResult)
getInitialData()
// Unsubscribe from the task result event on component unmount
return () => {
NfcService.removeBackgroundTagListener()
EventEmitter.off(`ev_${HANDLE_RECEIVED_EVENT_TASK}_result`, handleReceivedEventTaskResult)
EventEmitter.off(`ev_${HANDLE_CLAIM_TASK}_result`, handleClaimTaskResult)
EventEmitter.off(`ev_${HANDLE_CLAIM_TASK}_result`, handleClaimTaskResult)
}
}, [])
@@ -258,8 +274,8 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
const incomingData = IncomingParser.findAndExtract(url)
await IncomingParser.navigateWithIncomingData(
incomingData,
navigation,
incomingData,
navigation,
currentUnit
)
@@ -268,6 +284,16 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
}
}
// Routes NFC data (cold launch or warm resume) to the dedicated NFC pay screen, which
// processes the already-read payload via its own handleIncomingData.
const handleNfcData = function (data: string) {
// @ts-ignore
navigation.navigate('NfcPay', {
unit: currentUnit,
incomingData: data,
})
}
const gotoUpdate = function() {
setIsUpdateModalVisible(false)
//@ts-ignore
+93 -1
View File
@@ -6,6 +6,17 @@ import AppError, { Err } from '../utils/AppError'
const NFC_STALE_DELAY_MS = 120
// Android NfcAdapter reader-mode flags (react-native-nfc-manager passes these through
// to NfcAdapter.enableReaderMode). HCE peers present as ISO-DEP over NFC-A/NFC-B, so we
// must enable those. Enabling reader mode is what makes our foreground session take
// EXCLUSIVE control of the NFC stack and suppress the OS "Complete action using…" chooser
// + other installed wallets (Zeus/Blixt/WoS) from intercepting the tap.
const FLAG_READER_NFC_A = 0x1
const FLAG_READER_NFC_B = 0x2
const FLAG_READER_NO_PLATFORM_SOUNDS = 0x100
const READER_MODE_FLAGS =
FLAG_READER_NFC_A | FLAG_READER_NFC_B | FLAG_READER_NO_PLATFORM_SOUNDS
const init = async function () {
const supported = await NfcManager.isSupported()
if (supported) {
@@ -48,7 +59,10 @@ const goToNfcSetting = function () {
const readNdefTag = async () => {
log.trace('[readNdefTag] start')
try {
await NfcManager.requestTechnology(NfcTech.Ndef) // needs to run only once per read/write session
await NfcManager.requestTechnology(NfcTech.Ndef, {
isReaderModeEnabled: true, // use NfcAdapter.enableReaderMode, not enableForegroundDispatch
readerModeFlags: READER_MODE_FLAGS,
}) // needs to run only once per read/write session
log.trace('[readNdefTag] requestTechnology completed')
const tag = await NfcManager.getTag()
log.trace('[readNdefTag] tag read') // do not close session
@@ -73,6 +87,81 @@ const writeNdefMessage = async (text: string) => {
}
/**
* Decodes the text payload of the first NDEF record of a tag, handling both Well-Known
* Text records (how invoices/tokens are shared over NFC, see writeNdefMessage) and URI
* records (e.g. lightning: URIs).
*/
const decodeNdefTagText = function (tag: TagEvent | null | undefined): string | undefined {
const record = tag?.ndefMessage?.[0]
if (!record?.payload) return undefined
const bytes = new Uint8Array(record.payload as number[])
if (Ndef.isType(record, Ndef.TNF_WELL_KNOWN, Ndef.RTD_URI)) {
return Ndef.uri.decodePayload(bytes) || undefined
}
return Ndef.text.decodePayload(bytes) || undefined
}
/**
* Returns the text content of the NDEF tag that COLD-launched the app, if any.
*
* When the app is launched from a closed state by an Android NDEF_DISCOVERED intent (e.g.
* tapping another wallet's HCE share and picking Minibits in the OS chooser), a Well-Known
* Text record carries no data URI — only an EXTRA_NDEF_MESSAGES extra on the launch intent,
* which React Native's Linking module misses. getLaunchTagEvent() parses that launch intent.
*
* Returns undefined when the app was not launched by an NFC tag (the parsed intent has no
* EXTRA_TAG), which makes this a reliable "was this an NFC launch?" discriminator.
*
* getLaunchTagEvent() reads the activity's launch intent, which persists across React
* remounts; we therefore consume the launch tag only ONCE per app process so a remount of
* the caller (e.g. WalletScreen) does not re-process and re-navigate to the same payment.
*
* @returns the decoded NDEF text, or undefined if the app was not launched by an NFC tag
*/
let launchNdefConsumed = false
const getLaunchNdefText = async function (): Promise<string | undefined> {
if (Platform.OS !== 'android') return undefined // getLaunchTagEvent is Android-only
if (launchNdefConsumed) return undefined
try {
const tag = await NfcManager.getLaunchTagEvent()
const text = decodeNdefTagText(tag)
if (text) launchNdefConsumed = true // only consume when we actually found NFC data
return text
} catch (e: any) {
log.warn('[getLaunchNdefText] failed to read launch NFC tag', { error: String(e) })
return undefined
}
}
/**
* Registers a listener for NDEF tags that arrive while the app is backgrounded (WARM resume)
* and brought to the foreground by an NFC dispatch. The library emits DiscoverBackgroundTag
* from its onNewIntent handler when no foreground reader session is active.
*
* @param onData called with the decoded NDEF text of the background tag
*/
const setBackgroundTagListener = function (onData: (text: string) => void) {
if (Platform.OS !== 'android') return
NfcManager.setEventListener(NfcEvents.DiscoverBackgroundTag, (tag: TagEvent) => {
const text = decodeNdefTagText(tag)
// Clear the stored background tag so it cannot be re-read later (e.g. via
// getBackgroundTag on a remount); the event already delivered it to us here.
NfcManager.clearBackgroundTag().catch(() => {})
if (text) onData(text)
})
}
const removeBackgroundTagListener = function () {
if (Platform.OS !== 'android') return
NfcManager.setEventListener(NfcEvents.DiscoverBackgroundTag, null)
}
/**
* Checks if a string is safe to broadcast via Android NFC HCE (Type 4 Tag emulation)
* Safe limit: 32,000 bytes (conservative, accounts for NDEF overhead)
@@ -102,4 +191,7 @@ export const NfcService = {
goToNfcSetting,
readNdefTag,
writeNdefMessage,
getLaunchNdefText,
setBackgroundTagListener,
removeBackgroundTagListener,
}