mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-10-05 19:28:26 +00:00
NFC cold and hot start support
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user