diff --git a/package.json b/package.json index 5b6a4d413..5cae05b80 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "/node_modules" ], "transformIgnorePatterns": [ - "node_modules/(?!(react-native|@react-native|react-native-blob-util|react-native-randombytes|dateformat|nostr-tools|@nostr|@noble|@scure|uuid)/)" + "node_modules/(?!(react-native|@react-native|react-native-blob-util|react-native-randombytes|react-native-nfc-manager|dateformat|nostr-tools|@nostr|@noble|@scure|uuid)/)" ], "testPathIgnorePatterns": [ "check-styles.test.ts" diff --git a/utils/NFCUtils.test.ts b/utils/NFCUtils.test.ts new file mode 100644 index 000000000..f9a56b79a --- /dev/null +++ b/utils/NFCUtils.test.ts @@ -0,0 +1,56 @@ +// Explicit jest type reference required: the import chain goes through +// react-native-nfc-manager's ambient module declaration, which causes +// tsserver to lose automatic @types/jest inclusion (tsc is unaffected). +/// + +jest.mock('react-native-nfc-manager', () => ({ + __esModule: true, + default: { + isEnabled: () => {}, + setEventListener: () => {}, + start: () => {}, + registerTagEvent: () => {}, + unregisterTagEvent: () => {} + }, + NfcEvents: { DiscoverTag: 'DiscoverTag', SessionClosed: 'SessionClosed' }, + Ndef: { text: { decodePayload: () => {} } } +})); + +import { decodeNdefTextPayload } from './NFCUtils'; + +// NDEF Text Record payload structure: +// byte 0: status byte — lower 6 bits = language code length +// bytes 1–N: language code (e.g. "en" = 2 bytes) +// remaining: actual text (UTF-8) +const withHeader = (...textBytes: number[]) => + new Uint8Array([0x02, 0x65, 0x6e, ...textBytes]); // 0x02 = 2-char lang, "en" + +describe('decodeNdefTextPayload', () => { + it('returns empty string for empty input', () => { + expect(decodeNdefTextPayload(new Uint8Array([]))).toBe(''); + }); + + it('returns empty string when payload contains only the header', () => { + expect(decodeNdefTextPayload(withHeader())).toBe(''); + }); + + it('decodes a plain ASCII payload', () => { + // "hello" = 0x68 0x65 0x6c 0x6c 0x6f + expect( + decodeNdefTextPayload(withHeader(0x68, 0x65, 0x6c, 0x6c, 0x6f)) + ).toBe('hello'); + }); + + it('decodes a multi-byte UTF-8 character (ü = 0xC3 0xBC)', () => { + expect(decodeNdefTextPayload(withHeader(0xc3, 0xbc))).toBe('ü'); + }); + + it('returns null for an invalid UTF-8 sequence', () => { + expect(decodeNdefTextPayload(withHeader(0xff))).toBeNull(); + }); + + it('returns null for a truncated multi-byte sequence', () => { + // 0xC3 starts a 2-byte sequence but has no continuation byte + expect(decodeNdefTextPayload(withHeader(0xc3))).toBeNull(); + }); +}); diff --git a/utils/NFCUtils.ts b/utils/NFCUtils.ts index 6e0db89dc..965850987 100644 --- a/utils/NFCUtils.ts +++ b/utils/NFCUtils.ts @@ -1,6 +1,24 @@ -import NfcManager from 'react-native-nfc-manager'; +import { Platform } from 'react-native'; +import NfcManager, { + NfcEvents, + TagEvent, + Ndef +} from 'react-native-nfc-manager'; import ModalStore from '../stores/ModalStore'; +export function decodeNdefTextPayload(data: Uint8Array): string | null { + if (data.length === 0) return ''; + const langCodeLen = data[0] & 0x3f; + const textData = data.slice(1 + langCodeLen); + try { + return new global.TextDecoder('utf-8', { fatal: true }).decode( + textData + ); + } catch { + return null; + } +} + /** * Checks whether NFC is enabled on the device. * If not, shows the AndroidNfcModal with the disabled state and returns false. @@ -18,37 +36,47 @@ export async function checkNfcEnabled( return true; } -class NFCUtils { - nfcUtf8ArrayToStr = (data: any) => { - const extraByteMap = [1, 1, 1, 1, 2, 2, 3, 0]; - const count = data.length; - let str = ''; +export async function scanNfcTag( + modalStore: ModalStore +): Promise { + if (!(await checkNfcEnabled(modalStore))) return undefined; - for (let index = 0; index < count; ) { - let ch = data[index++]; - if (ch & 0x80) { - let extra = extraByteMap[(ch >> 3) & 0x07]; - if (!(ch & 0x40) || !extra || index + extra > count) { - return null; - } + NfcManager.setEventListener(NfcEvents.DiscoverTag, null); + NfcManager.setEventListener(NfcEvents.SessionClosed, null); + await NfcManager.start().catch((e) => console.warn(e.message)); - ch = ch & (0x3f >> extra); - for (; extra > 0; extra -= 1) { - const chx = data[index++]; - if ((chx & 0xc0) !== 0x80) { - return null; - } + return new Promise((resolve) => { + let tagFound: TagEvent | null = null; - ch = (ch << 6) | (chx & 0x3f); - } + if (Platform.OS === 'android') modalStore.toggleAndroidNfcModal(true); + + NfcManager.setEventListener(NfcEvents.DiscoverTag, (tag: TagEvent) => { + tagFound = tag; + if (!tag.ndefMessage?.[0]?.payload) return resolve(undefined); + const bytes = new Uint8Array(tag.ndefMessage[0].payload); + + let str: string; + const decoded = Ndef.text.decodePayload(bytes); + if (decoded.match(/^(https?|lnurl)/)) { + str = decoded; + } else { + str = decodeNdefTextPayload(bytes) || ''; } - str += String.fromCharCode(ch); - } + if (Platform.OS === 'android') + modalStore.toggleAndroidNfcModal(false); - return str.slice(3); - }; + resolve(str); + NfcManager.unregisterTagEvent().catch(() => 0); + }); + + NfcManager.setEventListener(NfcEvents.SessionClosed, () => { + if (Platform.OS === 'android') + modalStore.toggleAndroidNfcModal(false); + + if (!tagFound) resolve(undefined); + }); + + NfcManager.registerTagEvent(); + }); } - -const nfcUtils = new NFCUtils(); -export default nfcUtils; diff --git a/views/Cashu/ReceiveEcash.tsx b/views/Cashu/ReceiveEcash.tsx index cd3a422ee..fe6d965a0 100644 --- a/views/Cashu/ReceiveEcash.tsx +++ b/views/Cashu/ReceiveEcash.tsx @@ -10,11 +10,7 @@ import { import { LNURLWithdrawParams } from 'js-lnurl'; import { ButtonGroup, Icon } from '@rneui/themed'; import { inject, observer } from 'mobx-react'; -import NfcManager, { - NfcEvents, - TagEvent, - Ndef -} from 'react-native-nfc-manager'; +import NfcManager from 'react-native-nfc-manager'; import { Route } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import Clipboard from '@react-native-clipboard/clipboard'; @@ -56,7 +52,7 @@ import UnitsStore from '../../stores/UnitsStore'; import CashuInvoice from '../../models/CashuInvoice'; import { localeString } from '../../utils/LocaleUtils'; -import NFCUtils, { checkNfcEnabled } from '../../utils/NFCUtils'; +import { scanNfcTag } from '../../utils/NFCUtils'; import { themeColor } from '../../utils/ThemeUtils'; import { getAmountFromSats } from '../../utils/AmountUtils'; @@ -262,63 +258,9 @@ export default class ReceiveEcash extends React.Component< }); }; - disableNfc = () => { - NfcManager.setEventListener(NfcEvents.DiscoverTag, null); - NfcManager.setEventListener(NfcEvents.SessionClosed, null); - }; - - enableNfc = async () => { - const { ModalStore } = this.props; - - if (!(await checkNfcEnabled(ModalStore))) return; - - this.disableNfc(); - await NfcManager.start().catch((e) => console.warn(e.message)); - - return new Promise((resolve: any) => { - let tagFound: TagEvent | null = null; - - // enable NFC - if (Platform.OS === 'android') - ModalStore.toggleAndroidNfcModal(true); - - NfcManager.setEventListener( - NfcEvents.DiscoverTag, - (tag: TagEvent) => { - tagFound = tag; - const bytes = new Uint8Array( - tagFound.ndefMessage[0].payload - ); - - let str; - const decoded = Ndef.text.decodePayload(bytes); - if (decoded.match(/^(https?|lnurl)/)) { - str = decoded; - } else { - str = NFCUtils.nfcUtf8ArrayToStr(bytes) || ''; - } - - // close NFC - if (Platform.OS === 'android') - ModalStore.toggleAndroidNfcModal(false); - - resolve(this.validateAddress(str)); - NfcManager.unregisterTagEvent().catch(() => 0); - } - ); - - NfcManager.setEventListener(NfcEvents.SessionClosed, () => { - // close NFC - if (Platform.OS === 'android') - ModalStore.toggleAndroidNfcModal(false); - - if (!tagFound) { - resolve(); - } - }); - - NfcManager.registerTagEvent(); - }); + scanNfc = async () => { + const str = await scanNfcTag(this.props.ModalStore); + if (str) this.validateAddress(str); }; validateAddress = (text: string) => { @@ -822,7 +764,7 @@ export default class ReceiveEcash extends React.Component< /> } onPress={() => - this.enableNfc() + this.scanNfc() } secondary /> diff --git a/views/OpenChannel.tsx b/views/OpenChannel.tsx index 48dc2d06e..95339ee2f 100644 --- a/views/OpenChannel.tsx +++ b/views/OpenChannel.tsx @@ -1,15 +1,9 @@ import * as React from 'react'; -import { - Platform, - ScrollView, - StyleSheet, - View, - TouchableOpacity -} from 'react-native'; +import { ScrollView, StyleSheet, View, TouchableOpacity } from 'react-native'; import { Divider } from '@rneui/themed'; import Clipboard from '@react-native-clipboard/clipboard'; import { inject, observer } from 'mobx-react'; -import NfcManager, { NfcEvents, TagEvent } from 'react-native-nfc-manager'; +import NfcManager from 'react-native-nfc-manager'; import { Route } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -30,7 +24,7 @@ import TextInput from '../components/TextInput'; import UTXOPicker from '../components/UTXOPicker'; import handleAnything from '../utils/handleAnything'; -import NFCUtils, { checkNfcEnabled } from '../utils/NFCUtils'; +import { scanNfcTag } from '../utils/NFCUtils'; import NodeUriUtils from '../utils/NodeUriUtils'; import BackendUtils from '../utils/BackendUtils'; import ValidationUtils from '../utils/ValidationUtils'; @@ -180,56 +174,9 @@ export default class OpenChannel extends React.Component< this.setState({ nfcSupported }); } - disableNfc = () => { - NfcManager.setEventListener(NfcEvents.DiscoverTag, null); - NfcManager.setEventListener(NfcEvents.SessionClosed, null); - }; - - enableNfc = async () => { - const { ModalStore } = this.props; - - if (!(await checkNfcEnabled(ModalStore))) return; - - this.disableNfc(); - await NfcManager.start(); - - return new Promise((resolve: any) => { - let tagFound: TagEvent | null = null; - - // enable NFC - if (Platform.OS === 'android') - ModalStore.toggleAndroidNfcModal(true); - - NfcManager.setEventListener( - NfcEvents.DiscoverTag, - (tag: TagEvent) => { - tagFound = tag; - const bytes = new Uint8Array( - tagFound.ndefMessage[0].payload - ); - const str = NFCUtils.nfcUtf8ArrayToStr(bytes) || ''; - - // close NFC - if (Platform.OS === 'android') - ModalStore.toggleAndroidNfcModal(false); - - resolve(this.validateNodeUri(str)); - NfcManager.unregisterTagEvent().catch(() => 0); - } - ); - - NfcManager.setEventListener(NfcEvents.SessionClosed, () => { - // close NFC - if (Platform.OS === 'android') - ModalStore.toggleAndroidNfcModal(false); - - if (!tagFound) { - resolve(); - } - }); - - NfcManager.registerTagEvent(); - }); + scanNfc = async () => { + const str = await scanNfcTag(this.props.ModalStore); + if (str) this.validateNodeUri(str); }; componentDidUpdate(prevProps: OpenChannelProps) { @@ -1384,7 +1331,7 @@ export default class OpenChannel extends React.Component< style={{ marginRight: 10 }} /> } - onPress={() => this.enableNfc()} + onPress={() => this.scanNfc()} secondary /> diff --git a/views/Receive.tsx b/views/Receive.tsx index d300f5da4..1836a83f2 100644 --- a/views/Receive.tsx +++ b/views/Receive.tsx @@ -14,11 +14,7 @@ import { LNURLWithdrawParams } from 'js-lnurl'; import { ButtonGroup, Icon } from '@rneui/themed'; import { inject, observer } from 'mobx-react'; import _map from 'lodash/map'; -import NfcManager, { - NfcEvents, - TagEvent, - Ndef -} from 'react-native-nfc-manager'; +import NfcManager from 'react-native-nfc-manager'; import { Route } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -79,7 +75,7 @@ import BalanceStore from '../stores/BalanceStore'; import { localeString } from '../utils/LocaleUtils'; import BackendUtils from '../utils/BackendUtils'; import Base64Utils from '../utils/Base64Utils'; -import NFCUtils, { checkNfcEnabled } from '../utils/NFCUtils'; +import { scanNfcTag } from '../utils/NFCUtils'; import { themeColor } from '../utils/ThemeUtils'; import { SATS_PER_BTC } from '../utils/UnitsUtils'; import { getAmountFromSats } from '../utils/AmountUtils'; @@ -674,63 +670,9 @@ export default class Receive extends React.Component< }); }; - disableNfc = () => { - NfcManager.setEventListener(NfcEvents.DiscoverTag, null); - NfcManager.setEventListener(NfcEvents.SessionClosed, null); - }; - - enableNfc = async () => { - const { ModalStore } = this.props; - - if (!(await checkNfcEnabled(ModalStore))) return; - - this.disableNfc(); - await NfcManager.start().catch((e) => console.warn(e.message)); - - return new Promise((resolve: any) => { - let tagFound: TagEvent | null = null; - - // enable NFC - if (Platform.OS === 'android') - ModalStore.toggleAndroidNfcModal(true); - - NfcManager.setEventListener( - NfcEvents.DiscoverTag, - (tag: TagEvent) => { - tagFound = tag; - const bytes = new Uint8Array( - tagFound.ndefMessage[0].payload - ); - - let str; - const decoded = Ndef.text.decodePayload(bytes); - if (decoded.match(/^(https?|lnurl)/)) { - str = decoded; - } else { - str = NFCUtils.nfcUtf8ArrayToStr(bytes) || ''; - } - - // close NFC - if (Platform.OS === 'android') - ModalStore.toggleAndroidNfcModal(false); - - resolve(this.validateAddress(str)); - NfcManager.unregisterTagEvent().catch(() => 0); - } - ); - - NfcManager.setEventListener(NfcEvents.SessionClosed, () => { - // close NFC - if (Platform.OS === 'android') - ModalStore.toggleAndroidNfcModal(false); - - if (!tagFound) { - resolve(); - } - }); - - NfcManager.registerTagEvent(); - }); + scanNfc = async () => { + const str = await scanNfcTag(this.props.ModalStore); + if (str) this.validateAddress(str); }; validateAddress = (text: string) => { @@ -2340,7 +2282,7 @@ export default class Receive extends React.Component< /> } onPress={() => - this.enableNfc() + this.scanNfc() } secondary /> diff --git a/views/Send.tsx b/views/Send.tsx index 5efe68842..f11cb2f2b 100644 --- a/views/Send.tsx +++ b/views/Send.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { FlatList, Image, - Platform, NativeModules, NativeEventEmitter, StyleSheet, @@ -16,11 +15,7 @@ import { import { Chip, Icon } from '@rneui/themed'; import Clipboard from '@react-native-clipboard/clipboard'; import { inject, observer } from 'mobx-react'; -import NfcManager, { - NfcEvents, - TagEvent, - Ndef -} from 'react-native-nfc-manager'; +import NfcManager from 'react-native-nfc-manager'; import { ParamListBase, Route } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -53,7 +48,7 @@ import UTXOPicker from '../components/UTXOPicker'; import BackendUtils from '../utils/BackendUtils'; import { errorToUserFriendly } from '../utils/ErrorUtils'; -import NFCUtils, { checkNfcEnabled } from '../utils/NFCUtils'; +import { scanNfcTag } from '../utils/NFCUtils'; import { localeString } from '../utils/LocaleUtils'; import { themeColor } from '../utils/ThemeUtils'; import { getUnformattedAmount, getAmountFromSats } from '../utils/AmountUtils'; @@ -369,63 +364,9 @@ export default class Send extends React.Component { ); }; - disableNfc = () => { - NfcManager.setEventListener(NfcEvents.DiscoverTag, null); - NfcManager.setEventListener(NfcEvents.SessionClosed, null); - }; - - enableNfc = async () => { - const { ModalStore } = this.props; - - if (!(await checkNfcEnabled(ModalStore))) return; - - this.disableNfc(); - await NfcManager.start().catch((e) => console.warn(e.message)); - - return new Promise((resolve: any) => { - let tagFound: TagEvent | null = null; - - // enable NFC - if (Platform.OS === 'android') - ModalStore.toggleAndroidNfcModal(true); - - NfcManager.setEventListener( - NfcEvents.DiscoverTag, - (tag: TagEvent) => { - tagFound = tag; - const bytes = new Uint8Array( - tagFound.ndefMessage[0].payload - ); - - let str; - const decoded = Ndef.text.decodePayload(bytes); - if (decoded.match(/^(https?|lnurl)/)) { - str = decoded; - } else { - str = NFCUtils.nfcUtf8ArrayToStr(bytes) || ''; - } - - // close NFC - if (Platform.OS === 'android') - ModalStore.toggleAndroidNfcModal(false); - - resolve(this.validateAddress(str)); - NfcManager.unregisterTagEvent().catch(() => 0); - } - ); - - NfcManager.setEventListener(NfcEvents.SessionClosed, () => { - // close NFC - if (Platform.OS === 'android') - ModalStore.toggleAndroidNfcModal(false); - - if (!tagFound) { - resolve(); - } - }); - - NfcManager.registerTagEvent(); - }); + scanNfc = async () => { + const str = await scanNfcTag(this.props.ModalStore); + if (str) this.validateAddress(str); }; selectUTXOs = ( @@ -858,7 +799,7 @@ export default class Send extends React.Component { {nfcSupported && ( this.enableNfc()} + onPress={() => this.scanNfc()} >