Merge pull request #3941 from myxmaster/refactor/centralize-nfc-scan

refactor: centralize NFC scanning into NFCUtils
This commit is contained in:
Evan Kaloudis
2026-05-01 20:43:42 -04:00
committed by GitHub
7 changed files with 138 additions and 282 deletions
+1 -1
View File
@@ -11,7 +11,7 @@
"<rootDir>/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"
+56
View File
@@ -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).
/// <reference types="jest" />
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 1N: 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();
});
});
+56 -28
View File
@@ -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<string | undefined> {
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;
+6 -64
View File
@@ -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
/>
+7 -60
View File
@@ -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
/>
</View>
+6 -64
View File
@@ -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
/>
+6 -65
View File
@@ -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<SendProps, SendState> {
);
};
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<SendProps, SendState> {
{nfcSupported && (
<View style={{ marginRight: 15 }}>
<TouchableOpacity
onPress={() => this.enableNfc()}
onPress={() => this.scanNfc()}
>
<NFC
stroke={themeColor('text')}