diff --git a/wallets/rn_cli_wallet/android/app/build.gradle b/wallets/rn_cli_wallet/android/app/build.gradle index 7c7e2966..6ca3fa9a 100644 --- a/wallets/rn_cli_wallet/android/app/build.gradle +++ b/wallets/rn_cli_wallet/android/app/build.gradle @@ -85,7 +85,7 @@ android { applicationId "com.walletconnect.web3wallet.rnsample" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 59 + versionCode 60 versionName "1.0" resValue "string", "build_config_package", "com.walletconnect.web3wallet.rnsample" } diff --git a/wallets/rn_cli_wallet/android/app/src/main/AndroidManifest.xml b/wallets/rn_cli_wallet/android/app/src/main/AndroidManifest.xml index 41dff387..7d9d31da 100644 --- a/wallets/rn_cli_wallet/android/app/src/main/AndroidManifest.xml +++ b/wallets/rn_cli_wallet/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,8 @@ + + + + + + + + + + + + + + + + + diff --git a/wallets/rn_cli_wallet/ios/Podfile.lock b/wallets/rn_cli_wallet/ios/Podfile.lock index 3ec67777..167127b5 100644 --- a/wallets/rn_cli_wallet/ios/Podfile.lock +++ b/wallets/rn_cli_wallet/ios/Podfile.lock @@ -1933,6 +1933,8 @@ PODS: - Yoga - react-native-netinfo (11.4.1): - React-Core + - react-native-nfc-manager (3.17.2): + - React-Core - react-native-quick-base64 (2.2.2): - React-Core - react-native-quick-crypto (0.7.17): @@ -3120,6 +3122,7 @@ DEPENDENCIES: - react-native-keyboard-controller (from `../node_modules/react-native-keyboard-controller`) - react-native-mmkv (from `../node_modules/react-native-mmkv`) - "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)" + - react-native-nfc-manager (from `../node_modules/react-native-nfc-manager`) - react-native-quick-base64 (from `../node_modules/react-native-quick-base64`) - react-native-quick-crypto (from `../node_modules/react-native-quick-crypto`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) @@ -3273,6 +3276,8 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native-mmkv" react-native-netinfo: :path: "../node_modules/@react-native-community/netinfo" + react-native-nfc-manager: + :path: "../node_modules/react-native-nfc-manager" react-native-quick-base64: :path: "../node_modules/react-native-quick-base64" react-native-quick-crypto: @@ -3419,6 +3424,7 @@ SPEC CHECKSUMS: react-native-keyboard-controller: f2ed31d12d9d8cb8ad2f9110c18fa5df499b66a3 react-native-mmkv: ac7507625cd74bac0eb5333604a7cd7b08fe9e3e react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187 + react-native-nfc-manager: c8891e460b4943b695d63f7f4effc6345bbefc83 react-native-quick-base64: 6568199bb2ac8e72ecdfdc73a230fbc5c1d3aac4 react-native-quick-crypto: dac9db2adb0a61b4881909b6db7c51eaaada66a8 react-native-safe-area-context: c6e2edd1c1da07bdce287fa9d9e60c5f7b514616 diff --git a/wallets/rn_cli_wallet/ios/RNWeb3Wallet.xcodeproj/project.pbxproj b/wallets/rn_cli_wallet/ios/RNWeb3Wallet.xcodeproj/project.pbxproj index d7c4a6ab..25a95de6 100644 --- a/wallets/rn_cli_wallet/ios/RNWeb3Wallet.xcodeproj/project.pbxproj +++ b/wallets/rn_cli_wallet/ios/RNWeb3Wallet.xcodeproj/project.pbxproj @@ -33,7 +33,7 @@ 13B07F961A680F5B00A75B9A /* RNWallet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = RNWallet.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = RNWeb3Wallet/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = RNWeb3Wallet/Info.plist; sourceTree = ""; }; - 18BB39FF004742E9BA98CBB1 /* KHTeka-Light.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = "KHTeka-Light.otf"; path = "../assets/fonts/KHTeka-Light.otf"; sourceTree = ""; }; + 18BB39FF004742E9BA98CBB1 /* KHTeka-Light.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "KHTeka-Light.otf"; path = "../assets/fonts/KHTeka-Light.otf"; sourceTree = ""; }; 1DE6EA8716545F6CED9BF875 /* Pods-App-RNWallet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-RNWallet.debug.xcconfig"; path = "Target Support Files/Pods-App-RNWallet/Pods-App-RNWallet.debug.xcconfig"; sourceTree = ""; }; 1FCDA2A22B9B8E6200E0BF0C /* RNWallet Debug.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "RNWallet Debug.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 1FCDA2BA2B9B8E6800E0BF0C /* RNWallet Internal.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "RNWallet Internal.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -44,13 +44,13 @@ 2C50482F22BB4C56ACE24EA8 /* BootSplash.storyboard */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = BootSplash.storyboard; path = RNWeb3Wallet/BootSplash.storyboard; sourceTree = ""; }; 3616329FA2535E44622E8399 /* libPods-App-RNWallet Debug.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-App-RNWallet Debug.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 3F16776DD2AD1459C80F97BA /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = RNWeb3Wallet/PrivacyInfo.xcprivacy; sourceTree = ""; }; - 4FBAB7122BF343DBAF0BB7F7 /* KHTeka-Medium.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = "KHTeka-Medium.otf"; path = "../assets/fonts/KHTeka-Medium.otf"; sourceTree = ""; }; + 4FBAB7122BF343DBAF0BB7F7 /* KHTeka-Medium.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "KHTeka-Medium.otf"; path = "../assets/fonts/KHTeka-Medium.otf"; sourceTree = ""; }; 540B49B18EE9B4B3211A85F1 /* Pods-App-RNWallet Debug.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-RNWallet Debug.debug.xcconfig"; path = "Target Support Files/Pods-App-RNWallet Debug/Pods-App-RNWallet Debug.debug.xcconfig"; sourceTree = ""; }; - 7AE83AB305C04F529AD8275C /* Colors.xcassets */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = folder.assetcatalog; name = Colors.xcassets; path = RNWeb3Wallet/Colors.xcassets; sourceTree = ""; }; + 7AE83AB305C04F529AD8275C /* Colors.xcassets */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = folder.assetcatalog; name = Colors.xcassets; path = RNWeb3Wallet/Colors.xcassets; sourceTree = ""; }; 7F0ADF9FBB4DFB43D9B42D14 /* libPods-App-RNWallet.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-App-RNWallet.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 8987A5832B077AEB1770930B /* Pods-App-RNWallet Internal.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-RNWallet Internal.debug.xcconfig"; path = "Target Support Files/Pods-App-RNWallet Internal/Pods-App-RNWallet Internal.debug.xcconfig"; sourceTree = ""; }; 936277491F1F3293104F8AD2 /* libPods-App-RNWallet Internal.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-App-RNWallet Internal.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - ABEB231DA62E483992083356 /* KHTeka-Regular.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = undefined; includeInIndex = 0; lastKnownFileType = unknown; name = "KHTeka-Regular.otf"; path = "../assets/fonts/KHTeka-Regular.otf"; sourceTree = ""; }; + ABEB231DA62E483992083356 /* KHTeka-Regular.otf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = "KHTeka-Regular.otf"; path = "../assets/fonts/KHTeka-Regular.otf"; sourceTree = ""; }; BC0E53642B7E95F80075EFC3 /* RNWeb3Wallet.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = RNWeb3Wallet.entitlements; path = RNWeb3Wallet/RNWeb3Wallet.entitlements; sourceTree = ""; }; BC0E53652B7EAFF70075EFC3 /* RNWeb3WalletDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = RNWeb3WalletDebug.entitlements; path = RNWeb3Wallet/RNWeb3WalletDebug.entitlements; sourceTree = ""; }; C3A29D35A12B15C6680651C1 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = RNWeb3Wallet/PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -346,10 +346,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Debug/Pods-App-RNWallet Debug-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Debug/Pods-App-RNWallet Debug-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Debug/Pods-App-RNWallet Debug-resources.sh\"\n"; @@ -505,10 +509,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Debug/Pods-App-RNWallet Debug-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Debug/Pods-App-RNWallet Debug-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Debug/Pods-App-RNWallet Debug-frameworks.sh\"\n"; @@ -522,10 +530,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet/Pods-App-RNWallet-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet/Pods-App-RNWallet-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App-RNWallet/Pods-App-RNWallet-frameworks.sh\"\n"; @@ -539,10 +551,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet/Pods-App-RNWallet-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet/Pods-App-RNWallet-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App-RNWallet/Pods-App-RNWallet-resources.sh\"\n"; @@ -600,10 +616,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Internal/Pods-App-RNWallet Internal-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Internal/Pods-App-RNWallet Internal-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Internal/Pods-App-RNWallet Internal-resources.sh\"\n"; @@ -617,10 +637,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Internal/Pods-App-RNWallet Internal-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Internal/Pods-App-RNWallet Internal-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App-RNWallet Internal/Pods-App-RNWallet Internal-frameworks.sh\"\n"; @@ -756,7 +780,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.walletconnect.web3wallet.rnsample; PRODUCT_NAME = RNWallet; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.walletconnect.web3wallet.rnsample 1757428941"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.walletconnect.web3wallet.rnsample"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; diff --git a/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3Wallet.entitlements b/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3Wallet.entitlements index fde45392..246e4c52 100644 --- a/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3Wallet.entitlements +++ b/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3Wallet.entitlements @@ -7,9 +7,13 @@ com.apple.developer.associated-domains applinks:lab.reown.com + applinks:pay.walletconnect.com + applinks:staging.pay.walletconnect.com + applinks:dev.pay.walletconnect.com com.apple.developer.nfc.readersession.formats + NDEF TAG diff --git a/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3WalletDebug.entitlements b/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3WalletDebug.entitlements index fde45392..246e4c52 100644 --- a/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3WalletDebug.entitlements +++ b/wallets/rn_cli_wallet/ios/RNWeb3Wallet/RNWeb3WalletDebug.entitlements @@ -7,9 +7,13 @@ com.apple.developer.associated-domains applinks:lab.reown.com + applinks:pay.walletconnect.com + applinks:staging.pay.walletconnect.com + applinks:dev.pay.walletconnect.com com.apple.developer.nfc.readersession.formats + NDEF TAG diff --git a/wallets/rn_cli_wallet/package.json b/wallets/rn_cli_wallet/package.json index 4467dce5..6babe651 100644 --- a/wallets/rn_cli_wallet/package.json +++ b/wallets/rn_cli_wallet/package.json @@ -59,6 +59,7 @@ "react-native-keyboard-controller": "^1.20.6", "react-native-mmkv": "3.3.3", "react-native-modal": "14.0.0-rc.1", + "react-native-nfc-manager": "^3.15.1", "react-native-quick-base64": "2.2.2", "react-native-quick-crypto": "0.7.17", "react-native-reanimated": "^4.2.1", diff --git a/wallets/rn_cli_wallet/src/assets/NfcTap.tsx b/wallets/rn_cli_wallet/src/assets/NfcTap.tsx new file mode 100644 index 00000000..c7b2ea5a --- /dev/null +++ b/wallets/rn_cli_wallet/src/assets/NfcTap.tsx @@ -0,0 +1,11 @@ +import Svg, { Path, type SvgProps } from 'react-native-svg'; + +const SvgNfcTap = (props: SvgProps) => ( + + + +); +export default SvgNfcTap; diff --git a/wallets/rn_cli_wallet/src/components/Header.tsx b/wallets/rn_cli_wallet/src/components/Header.tsx index ccc133c6..cb1b130e 100644 --- a/wallets/rn_cli_wallet/src/components/Header.tsx +++ b/wallets/rn_cli_wallet/src/components/Header.tsx @@ -1,21 +1,53 @@ import { View, StyleSheet, Image, Platform } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import Toast from 'react-native-toast-message'; import { useTheme } from '@/hooks/useTheme'; import { BorderRadius, Spacing } from '@/utils/ThemeUtil'; +import { useNfc, isAllowedNfcUri } from '@/hooks/useNfc'; +import { usePairing } from '@/hooks/usePairing'; import ModalStore from '@/store/ModalStore'; import BarcodeSvg from '@/assets/Barcode'; +import NfcTapSvg from '@/assets/NfcTap'; import { Button } from '@/components/Button'; export function Header() { const { top } = useSafeAreaInsets(); const Theme = useTheme(); + const { isNfcSupported, scanNfcTag } = useNfc(); + const { handleUriOrPaymentLink } = usePairing(); const onScannerPress = () => { ModalStore.open('ScannerOptionsModal', {}); }; + const onNfcPress = async () => { + try { + const uri = await scanNfcTag(); + if (uri) { + if (!isAllowedNfcUri(uri)) { + Toast.show({ + type: 'error', + text1: 'Unrecognized NFC tag', + }); + return; + } + handleUriOrPaymentLink(uri); + } else { + Toast.show({ + type: 'info', + text1: 'No data found on NFC tag', + }); + } + } catch { + Toast.show({ + type: 'error', + text1: 'NFC scan failed', + }); + } + }; + return ( - + + {Platform.OS === 'ios' && isNfcSupported && ( + + )} + + ); } @@ -67,13 +113,20 @@ const styles = StyleSheet.create({ width: 28, height: 18, }, - scanButton: { + actions: { + flexDirection: 'row', + gap: Spacing[2], + }, + actionButton: { height: 38, width: 38, borderRadius: BorderRadius[3], alignItems: 'center', justifyContent: 'center', }, + nfcButton: { + borderWidth: 1, + }, scanIcon: { height: 20, width: 20, diff --git a/wallets/rn_cli_wallet/src/hooks/useNfc.ts b/wallets/rn_cli_wallet/src/hooks/useNfc.ts new file mode 100644 index 00000000..3ced5cb1 --- /dev/null +++ b/wallets/rn_cli_wallet/src/hooks/useNfc.ts @@ -0,0 +1,283 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { AppState, Platform } from 'react-native'; +import NfcManager, { + NfcTech, + NfcEvents, + NfcAdapter, + Ndef, +} from 'react-native-nfc-manager'; + +import LogStore from '@/store/LogStore'; + +const ALLOWED_NFC_HOSTS = [ + 'pay.walletconnect.com', + 'staging.pay.walletconnect.com', + 'dev.pay.walletconnect.com', +]; + +export function isAllowedNfcUri(uri: string): boolean { + try { + const { protocol, hostname } = new URL(uri); + return ( + protocol === 'wc:' || + (protocol === 'https:' && ALLOWED_NFC_HOSTS.includes(hostname)) + ); + } catch { + return false; + } +} + +let nfcStarted = false; +let foregroundDispatchPaused = false; +let foregroundRegistered = false; +let resumeForegroundDispatch: (() => void) | null = null; + +async function ensureNfcStarted() { + if (!nfcStarted) { + await NfcManager.start(); + nfcStarted = true; + } +} + +function pauseForegroundDispatch() { + foregroundDispatchPaused = true; +} + +function unpauseForegroundDispatch() { + foregroundDispatchPaused = false; + foregroundRegistered = false; + resumeForegroundDispatch?.(); +} + +export function useNfc() { + const [isNfcSupported, setIsNfcSupported] = useState(false); + + useEffect(() => { + ensureNfcStarted() + .then(() => NfcManager.isSupported()) + .then(supported => { + setIsNfcSupported(supported); + LogStore.log(`NFC supported: ${supported}`, 'useNfc', 'init'); + }) + .catch(() => { + setIsNfcSupported(false); + }); + }, []); + + const scanNfcTag = useCallback(async (): Promise => { + pauseForegroundDispatch(); + try { + await NfcManager.cancelTechnologyRequest().catch(() => {}); + await NfcManager.requestTechnology(NfcTech.Ndef); + const tag = await NfcManager.getTag(); + + if (!tag?.ndefMessage?.length) { + LogStore.log('No NDEF message on tag', 'useNfc', 'scanNfcTag'); + return null; + } + + for (const record of tag.ndefMessage) { + const uri = extractUri(record); + if (uri) { + LogStore.log(`NFC URI found: ${uri}`, 'useNfc', 'scanNfcTag'); + return uri; + } + } + + LogStore.log('No URI found in NDEF records', 'useNfc', 'scanNfcTag'); + return null; + } catch (error: any) { + if (error?.message?.includes('cancelled')) { + return null; + } + LogStore.log(`NFC scan error: ${error?.message}`, 'useNfc', 'scanNfcTag'); + throw error; + } finally { + NfcManager.cancelTechnologyRequest().catch(() => {}); + unpauseForegroundDispatch(); + } + }, []); + + return { + isNfcSupported, + scanNfcTag, + }; +} + +/** + * Android-only: registers NFC foreground dispatch so tags are automatically + * intercepted while the app is visible. Calls `onUri` with the parsed URI. + */ +export function useNfcForegroundDispatch(onUri: (uri: string) => void) { + const onUriRef = useRef(onUri); + onUriRef.current = onUri; + + useEffect(() => { + if (Platform.OS !== 'android') { + return; + } + + let lastHandledUri = ''; + let lastHandledTime = 0; + + const register = async () => { + if (foregroundRegistered || foregroundDispatchPaused) { + return; + } + try { + await ensureNfcStarted(); + NfcManager.setEventListener(NfcEvents.DiscoverTag, (tag: any) => { + LogStore.log( + 'Foreground dispatch: tag discovered', + 'useNfc', + 'foregroundDispatch', + ); + + if (!tag?.ndefMessage?.length) { + LogStore.log( + 'Foreground dispatch: no NDEF message', + 'useNfc', + 'foregroundDispatch', + ); + return; + } + + for (const record of tag.ndefMessage) { + const uri = extractUri(record); + if (uri) { + // Debounce: skip duplicate tag reads within 3 seconds + const now = Date.now(); + if (uri === lastHandledUri && now - lastHandledTime < 3000) { + LogStore.log( + 'Foreground dispatch: duplicate tag ignored', + 'useNfc', + 'foregroundDispatch', + ); + return; + } + lastHandledUri = uri; + lastHandledTime = now; + + LogStore.log( + `Foreground dispatch URI: ${uri}`, + 'useNfc', + 'foregroundDispatch', + ); + onUriRef.current(uri); + return; + } + } + + LogStore.log( + 'Foreground dispatch: no URI in NDEF records', + 'useNfc', + 'foregroundDispatch', + ); + }); + + /* eslint-disable no-bitwise */ + const readerModeFlags = + NfcAdapter.FLAG_READER_NFC_A | + NfcAdapter.FLAG_READER_NFC_B | + NfcAdapter.FLAG_READER_NFC_V; + /* eslint-enable no-bitwise */ + await NfcManager.registerTagEvent({ + isReaderModeEnabled: true, + readerModeFlags, + }); + foregroundRegistered = true; + LogStore.log( + 'NFC reader mode registered', + 'useNfc', + 'foregroundDispatch', + ); + } catch (error: any) { + LogStore.log( + `Failed to register foreground dispatch: ${error?.message}`, + 'useNfc', + 'foregroundDispatch', + ); + } + }; + + const unregister = async () => { + if (!foregroundRegistered) { + return; + } + try { + await NfcManager.unregisterTagEvent(); + NfcManager.setEventListener(NfcEvents.DiscoverTag, null); + foregroundRegistered = false; + } catch { + // ignore + } + }; + + register(); + + resumeForegroundDispatch = () => register(); + + const subscription = AppState.addEventListener('change', state => { + if (state === 'active') { + register(); + } else { + unregister(); + } + }); + + return () => { + subscription.remove(); + resumeForegroundDispatch = null; + unregister(); + }; + }, []); +} + +function extractUri(record: { + tnf: number; + type: number[]; + payload: number[]; +}): string | null { + // TNF 3 = Absolute URI — the type field IS the URI + if (record.tnf === 3 && record.type?.length) { + return bytesToString(record.type); + } + + // TNF 1 = Well-known type + if (record.tnf === 1) { + const typeStr = bytesToString(record.type); + + // RTD_URI (type = "U" / 0x55) + if (typeStr === 'U' && record.payload?.length > 1) { + return Ndef.uri.decodePayload(new Uint8Array(record.payload) as any); + } + + // RTD_SMART_POSTER (type = "Sp") — contains nested NDEF with URI + if (typeStr === 'Sp' && record.payload?.length) { + try { + const innerBytes = new Uint8Array(record.payload); + const innerRecords = Ndef.decodeMessage(innerBytes as any); + if (innerRecords) { + for (const inner of innerRecords) { + const uri = extractUri(inner as any); + if (uri) { + return uri; + } + } + } + } catch { + // Failed to parse smart poster, skip + } + } + } + + return null; +} + +function bytesToString(bytes: number[]): string { + let result = ''; + for (const byte of bytes) { + result += String.fromCharCode(byte); + } + return result; +} diff --git a/wallets/rn_cli_wallet/src/screens/App.tsx b/wallets/rn_cli_wallet/src/screens/App.tsx index ae93edee..8d43712a 100644 --- a/wallets/rn_cli_wallet/src/screens/App.tsx +++ b/wallets/rn_cli_wallet/src/screens/App.tsx @@ -16,6 +16,7 @@ import { RootStackNavigator } from '@/navigators/RootStackNavigator'; import useInitializeWalletKit from '@/hooks/useInitializeWalletKit'; import useWalletKitEventsManager from '@/hooks/useWalletKitEventsManager'; import { usePairing } from '@/hooks/usePairing'; +import { useNfcForegroundDispatch, isAllowedNfcUri } from '@/hooks/useNfc'; import { walletKit } from '@/utils/WalletKitUtil'; import SettingsStore from '@/store/SettingsStore'; import ModalStore from '@/store/ModalStore'; @@ -63,6 +64,19 @@ const App = () => { // Get centralized URI/payment link handler const { handleUriOrPaymentLink } = usePairing(); + // Android: automatically intercept NFC tags while app is in foreground + const handleNfcUri = useCallback( + (uri: string) => { + if (isAllowedNfcUri(uri)) { + handleUriOrPaymentLink(uri); + } else { + LogStore.log(`NFC URI rejected: ${uri}`, 'App', 'handleNfcUri'); + } + }, + [handleUriOrPaymentLink], + ); + useNfcForegroundDispatch(handleNfcUri); + // Hide splash screen once wallets are initialized, addresses are loaded and theme mode is set useEffect(() => { if (initialized && eip155Address && themeMode) { @@ -88,7 +102,7 @@ const App = () => { const deeplinkHandler = useCallback( ({ url }: { url: string }) => { LogStore.log('Deep link received', 'App', 'deeplinkHandler', { - url + url, }); // 1. Link mode (wc_ev) - SDK handles it, just set the flag @@ -98,7 +112,21 @@ const App = () => { return; } - // 2. Redirection from app with encoded URI (wc?uri=) + // 2. Payment link from NFC tag or App Link (pay.walletconnect.com) + try { + const { hostname } = new URL(url); + if ( + hostname.endsWith('.pay.walletconnect.com') || + hostname === 'pay.walletconnect.com' + ) { + handleUriOrPaymentLink(url); + return; + } + } catch { + // Not a valid URL, continue to other handlers + } + + // 3. Redirection from app with encoded URI (wc?uri=) if (url.includes('wc?uri=')) { const encodedUri = url.split('wc?uri=')[1]; if (!encodedUri) { @@ -116,7 +144,7 @@ const App = () => { return; } - // 3. Direct WC protocol URI (wc:) + // 4. Direct WC protocol URI (wc:) // Extract from wc: onwards to remove app scheme prefix if (url.includes('wc:')) { const wcIndex = url.indexOf('wc:'); @@ -125,14 +153,13 @@ const App = () => { return; } - // 4. Request for already paired session (wc?) + // 5. Request for already paired session (wc?) if (url.includes('wc?')) { ModalStore.open('LoadingModal', { loadingMessage: 'Loading request...', }); return; } - }, [handleUriOrPaymentLink], ); diff --git a/wallets/rn_cli_wallet/yarn.lock b/wallets/rn_cli_wallet/yarn.lock index 3a91516b..cd6650b8 100644 --- a/wallets/rn_cli_wallet/yarn.lock +++ b/wallets/rn_cli_wallet/yarn.lock @@ -4490,6 +4490,7 @@ __metadata: react-native-keyboard-controller: ^1.20.6 react-native-mmkv: 3.3.3 react-native-modal: 14.0.0-rc.1 + react-native-nfc-manager: ^3.15.1 react-native-quick-base64: 2.2.2 react-native-quick-crypto: 0.7.17 react-native-reanimated: ^4.2.1 @@ -10548,6 +10549,18 @@ __metadata: languageName: node linkType: hard +"react-native-nfc-manager@npm:^3.15.1": + version: 3.17.2 + resolution: "react-native-nfc-manager@npm:3.17.2" + peerDependencies: + "@expo/config-plugins": "*" + peerDependenciesMeta: + "@expo/config-plugins": + optional: true + checksum: 7435840e0ddd190b442dbe4972e318da2423c953648f917ba515d99a92aba27a477d738fb0f75db89bc64fc52201b9e6880d74e5c7f800d75195c77f4c20cde3 + languageName: node + linkType: hard + "react-native-quick-base64@npm:2.2.2, react-native-quick-base64@npm:^2.2.2": version: 2.2.2 resolution: "react-native-quick-base64@npm:2.2.2"