Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions components/NFCButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { Platform, TouchableOpacity } from 'react-native';
import { inject, observer } from 'mobx-react';
import { checkNfcEnabled } from '../utils/NFCUtils';

import HCESession, { NFCContentType, NFCTagType4 } from 'react-native-hce';
import {
HCESession,
NFCTagType4,
NFCTagType4NDEFContentType
} from 'react-native-hce';

import NfcIcon from '../assets/images/SVG/NFC-alt.svg';

Expand Down Expand Up @@ -69,12 +73,20 @@ export default class NFCButton extends React.Component<
};

startSimulation = async () => {
const tag = new NFCTagType4(NFCContentType.Text, this.props.value);
this.simulation = await new HCESession(tag).start();
const tag = new NFCTagType4({
type: NFCTagType4NDEFContentType.Text,
content: this.props.value,
writable: false
});
this.simulation = await HCESession.getInstance();
await this.simulation.setApplication(tag);
await this.simulation.setEnabled(true);
};

stopSimulation = async () => {
await this.simulation.terminate();
if (this.simulation) {
await this.simulation.setEnabled(false);
}
};

render() {
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -124,7 +124,7 @@
"react-native-gesture-handler": "2.30.0",
"react-native-get-random-values": "1.9.0",
"react-native-haptic-feedback": "2.3.3",
"react-native-hce": "0.1.2",
"react-native-hce": "0.3.0",
"react-native-image-picker": "8.2.1",
"react-native-keychain": "10.0.0",
"react-native-nfc-manager": "3.17.2",
Expand Down
56 changes: 56 additions & 0 deletions utils/NFCUtils.test.ts
Original file line number Diff line number Diff line change
@@ -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 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();
});
});
97 changes: 66 additions & 31 deletions utils/NFCUtils.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -18,37 +36,54 @@ 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 = '';

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;
}

ch = ch & (0x3f >> extra);
for (; extra > 0; extra -= 1) {
const chx = data[index++];
if ((chx & 0xc0) !== 0x80) {
return null;
}

ch = (ch << 6) | (chx & 0x3f);
}
export async function scanNfcTag(
modalStore: ModalStore
): Promise<string | undefined> {
if (!(await checkNfcEnabled(modalStore))) return undefined;

NfcManager.setEventListener(NfcEvents.DiscoverTag, null);
NfcManager.setEventListener(NfcEvents.SessionClosed, null);
await NfcManager.start().catch((e) => console.warn(e.message));

return new Promise((resolve) => {
let tagFound: TagEvent | null = null;

if (Platform.OS === 'android') modalStore.toggleAndroidNfcModal(true);

NfcManager.setEventListener(NfcEvents.DiscoverTag, (tag: TagEvent) => {
if (!tag.ndefMessage?.[0]?.payload) {
if (Platform.OS === 'android')
modalStore.toggleAndroidNfcModal(false);
resolve(undefined);
NfcManager.unregisterTagEvent().catch(() => 0);
return;
}

str += String.fromCharCode(ch);
}
tagFound = tag;
const bytes = new Uint8Array(tag.ndefMessage[0].payload);

return str.slice(3);
};
}
let str: string;
const decoded = Ndef.text.decodePayload(bytes);
if (decoded.match(/^(https?|lnurl)/)) {
str = decoded;
} else {
str = decodeNdefTextPayload(bytes) || '';
}

if (Platform.OS === 'android')
modalStore.toggleAndroidNfcModal(false);

resolve(str);
NfcManager.unregisterTagEvent().catch(() => 0);
});

const nfcUtils = new NFCUtils();
export default nfcUtils;
NfcManager.setEventListener(NfcEvents.SessionClosed, () => {
if (Platform.OS === 'android')
modalStore.toggleAndroidNfcModal(false);

if (!tagFound) resolve(undefined);
});

NfcManager.registerTagEvent();
});
}
70 changes: 6 additions & 64 deletions views/Cashu/ReceiveEcash.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -830,7 +772,7 @@ export default class ReceiveEcash extends React.Component<
/>
}
onPress={() =>
this.enableNfc()
this.scanNfc()
}
secondary
/>
Expand Down
Loading
Loading